HarmonyOS 手势与 animator 实战 —— 捏出跟手又有弹性的翻页物理

一、前言:直接拿手势驱动进度,必然翻车

先看几个真实的翻车现场。

现场一:松手就弹回。 手指拖了页面卷起一半,松手------页面「啪」地弹回原位。用户觉得「我明明拖了一大半了,为什么不翻过去?」

现场二:怎么滑都翻一整页。 不管手指拖多远,松手一律翻完整一页。用户想「瞄一眼下一页」做不到,翻页变成了全有或全无。

现场三:快速连翻卡死。 用户在书脊上快速连扫想翻好几页,结果第二页还没翻完就被第三页打断,画面抽搐,状态错乱。

现场四:跟手太顶或太松。 要么手指动一点点页面就翻到底(跟手太顶,没有「顶住」的反馈),要么手指拖满整页宽度页面才卷起一小角(跟手太松,像在拖一块湿毛巾)。

现场五:纹理丢了 / 控制器失效。@Prop 传了一个控制器和一组 PixelMap,结果翻着翻着纹理没了,或者程序化调用(nextPage())不响应。

根因可以归成两类:

  1. 交互层:手势直接绑进度,没有物理和决策------松手怎么动、翻不翻全凭运气。

  2. 状态层:用 ArkUI 的值语义传了不该值拷贝的对象------native 句柄被深拷贝破坏,运行时失效。

本文要讲的,就是把这两类问题一起解决:在交互上建一个四层模型,在状态上守住引用传递和 epoch 重绑。


二、翻页状态机:idle → dragging → settling

2.1 三个状态,一条主轴

先把翻页建模成一个状态机,主轴是「进度」progress ∈ [0,1]0 = 页面平贴原位,1 = 完全翻到对侧。

复制代码
        按下/移动                 松手(commit)
  idle ──────────► dragging ──────────► settling (commit)
   ▲                   │                    │
   │      松手(回弹)    │                    │
   │ ◄─────────────────▼                    │
   │                                        │ 弹簧到达目标
   └────────────────────────────────────────▼
  • idle :没有翻页在进行,progress = 0(或刚翻完,spread 已前进);

  • dragging :手指在拖,progress 由跟手封顶算出,跟随手指;

  • settling :手指已松开,progress 由弹簧驱动,向 0(回弹)或 1(commit)收敛。

把这三个状态显式建模,是后面一切清晰的前提。不要让 progress既跟手又弹簧------它在一个时刻只该归一个驱动源管。

2.2 page-map:当前看哪两页、在翻哪一页

翻页不是「单张图左右移」,而是「对开页(spread)里有一张在翻」。一个 spread 显示左右两页,翻动时会涉及四张页面纹理:

角色 含义
baseLeft / baseRight 当前静止的左右页
leafFront 正在翻动的纸的正面(翻动开始时朝向读者那侧)
leafBack 正在翻动的纸的背面(翻过去后露出那侧)

「向前翻」时 leafFront = baseRight,翻完后它落到左边,成为新的 baseLeft。这个 page-map 模型是快速连翻、边界判断的基础------后面会反复用到。


三、为什么不用 ease,而自己积分弹簧

3.1 ease 曲线的两个硬伤

ArkUI 自带 easeInOut / curves.springMotion 等动画曲线,为什么翻页要自己积分弹簧?两个硬伤:

  1. ease 没有物理感。 它是「时间 → 进度」的固定映射,跟手松手后的运动和用户拖动的速度无关。用户轻轻松手和用力甩出去,松手后的轨迹一模一样------没有「甩」的反馈。

  2. ease 没有速度连续性。 松手瞬间,手势驱动的速度和 ease 起始速度(恒为 0)对不上,页面会「顿一下」再开始动。

3.2 弹簧:刚度、阻尼、临界阻尼

一个二阶弹簧由两个参数决定性格:

  • 刚度 stiffness(又称 k):拉回原点的力气大小,越大越「脆」;

  • 阻尼比 dampingRatio1.0 = 临界阻尼(不 overshoot,最快回到目标);< 1.0 = 欠阻尼(会冲过目标再回弹,有「弹性」)。

弹簧对当前进度 p 施加加速度:

复制代码
a = -k · (p - target) - 2 · ζ · √k · v

其中 ζ 是阻尼比,v 是当前速度,target 是目标进度(0 或 1)。松手时把手势的末速度喂给弹簧作为初速度 v0,运动就和用户的手无缝衔接------这就是弹簧比 ease 强的根本原因。

3.3 物理参数的典型档位

复制代码
export class FlipPhysics {
  springStiffness: number = 180;       // 弹簧刚度:越大越脆
  springDampingRatio: number = 1.0;    // 1.0 = 临界阻尼,不 overshoot
  commitThreshold: number = 0.5;       // 进度 commit 阈值(相对跟手封顶)
  commitVelocity: number = 1.2;        // 满足此速度即使没到阈值也 commit
  velocityLookAhead: number = 0.12;    // 速度前瞻秒数
  settleEpsilon: number = 0.001;       // 视为已停的误差
}

dampingRatio = 1.0 是翻页的推荐值:纸翻过去就该利落到位,不该来回弹。如果你做的是「橡皮」/「果冻」类效果,才用 < 1.0 的欠阻尼让它 overshoot。


四、@ohos.animator 的正确接法

4.1 它只是一个「节拍器」

关键认知:@ohos.animator不懂弹簧,它只给你一个稳定的每帧回调。 弹簧是你自己在回调里积分的。

复制代码
import { animator } from '@kit.ArkUI';

const result: AnimatorResult = animator.create({
  duration: Number.MAX_SAFE_INTEGER,   // 长持续,我们自己停
  easing: 'linear',
  iterations: -1                        // 无限循环
});

result.onFrame = (progress: number): void => {
  // progress 是 animator 内部的 0..1,我们不用它
  // 这里只是借它的节拍,调自己的物理积分
  this.tickSpring();   // ← 弹簧积分
};

result.play();

很多人误以为 onFrameprogress 参数能直接用------它不能,那是线性插值器的值。我们把 animator 当成 requestAnimationFrame用,真正的运动学在 tickSpring里。

4.2 每帧积分弹簧

复制代码
private tickSpring(): void {
  const now = getCurrentTimeMs();
  let dt = (now - this.lastTickMs) / 1000;
  this.lastTickMs = now;
  if (dt <= 0) return;
  // 防止切后台回来 dt 过大炸掉
  if (dt > 0.05) dt = 0.05;

  const k = this.physics.springStiffness;
  const zeta = this.physics.springDampingRatio;
  const target = this.springTarget;     // 0 = 回弹,1 = commit

  // 半隐式欧拉,稳定且简单
  const accel = -k * (this.progress - target)
                - 2 * zeta * Math.sqrt(k) * this.velocity;
  this.velocity += accel * dt;
  this.progress += this.velocity * dt;

  // 收敛判定
  if (Math.abs(this.progress - target) < this.physics.settleEpsilon
      && Math.abs(this.velocity) < this.physics.settleEpsilon) {
    this.progress = target;
    this.onSettled();                   // commit 或回弹完成
    this.stopSpring();
    return;
  }
  this.invalidate();                    // 请求重绘
}

三个细节决定它稳不稳:

  1. dt兜底 :切后台再回前台,dt 可能是几秒,不夹会一帧飞到天外。夹到 0.05s(相当于最低 20fps)。

  2. 半隐式欧拉:先更新速度再更新位置,比显式欧拉稳定得多,临界阻尼下不会发散。

  3. 收敛后主动停 :到了目标就 stopSpring(),别让 animator 空转浪费电。

4.3 松手时把手势速度喂给弹簧

这是「甩」的反馈来源。在手势结束时,把最近几帧的位移除以时间算出速度,作为弹簧初速度:

复制代码
onPanEnd(): void {
  // 用最近一小段位移估速度(progress 单位 / 秒)
  const v = this.estimateVelocity();
  this.velocity = v;
  // 决策目标(见第六节)
  const decision = decideCommit(this.progress, v, this.physics);
  this.springTarget = decision.target;   // 0 或 1
  this.state = 'settling';
  this.startSpring();
}

没有这一步,松手后页面永远是从 0 速度开始动------用户「甩」的力气完全丢失。


五、跟手封顶:「顶住再松」的手感

5.1 为什么不能 progress = dx / pageWidth

如果让进度严格等于「手指位移 / 页宽」,会有两个问题:

  • 太松:用户要拖满整页宽度页面才卷完,手指行程太长,累;

  • 没有顶住感:页面永远线性跟手,没有「我顶到头了,松手就会翻」的触觉预期。

5.2 封顶到一个角度

更好的做法是:让进度封顶在一个跟手角度对应的最大值上。 比如设定跟手最大 45°:

复制代码
const DRAG_FOLLOW_ANGLE_DEG = 45;
const DRAG_FOLLOW_PROGRESS_MAX = DRAG_FOLLOW_ANGLE_DEG / 180;  // = 0.25

含义:用户手指拖动时,页面最多卷起到 45°(进度 0.25),就「顶住」不再跟了。松手后,弹簧再决定是回弹(回 0)还是 commit(弹簧驱动冲到 1)。跟手段负责「让用户摸到页面」,弹簧段负责「完成翻页」------两段分工。

复制代码
onPanUpdate(dx: number): void {
  // 把手指位移映射到 [0, MAX]
  let raw = (dx / this.pageWidth) * DRAG_FOLLOW_PROGRESS_MAX * 4; // 增益
  if (raw < 0) raw = 0;
  if (raw > DRAG_FOLLOW_PROGRESS_MAX) raw = DRAG_FOLLOW_PROGRESS_MAX; // 封顶
  this.progress = raw;
  this.recordVelocitySample(raw);   // 持续采样,供松手估速
  this.invalidate();
}

45° 是怎么定的?它是手感的旋钮:

角度 手感 适用
30° 顶死偏早(约 1/6 页宽行程),跟手紧 快速操作型
45° 约 1/4 行程,peek 自然,仍保留松手完成 推荐
60°+ 接近自由跟手,「顶住再松」感变弱 沉浸阅读型

口诀:跟手段封顶给「摸得到」,弹簧段放开给「翻得动」。 把两段混在一起,必然要么太松要么太顶。


六、commit 决策:阈值 + 速度前瞻

6.1 松手翻不翻,不能只看停在哪儿

最朴素的决策是「进度过半就 commit」------但这是个陷阱。考虑两个场景:

  • 用户慢慢拖到 60% 然后停住松手:意图明确,应该 commit。

  • 用户快速往回抽手,松手瞬间进度恰好停在 55%,但速度是负的(往回):应该回弹,不该 commit。

只看位置会判错第二种。正确决策要同时看位置速度

6.2 速度前瞻

把松手时的速度往前推一小段时间(velocityLookAhead,约 0.12s),看「如果保持这个速度,进度会到哪儿」:

复制代码
export function decideCommit(
  progress: number,
  velocity: number,
  physics: FlipPhysics
): { target: number; willCommit: boolean } {
  const lookAhead = progress + velocity * physics.velocityLookAhead;
  // 位置阈值用「跟手封顶」为基准:commit 在封顶值的一半附近
  const posThreshold = DRAG_FOLLOW_PROGRESS_MAX * physics.commitThreshold;
  const willCommit =
    lookAhead >= posThreshold ||            // 位置够了(含前瞻)
    velocity >= physics.commitVelocity;     // 或者速度够快(甩)
  return { target: willCommit ? 1 : 0, willCommit };
}

两层判定:

  1. 位置(含前瞻)lookAhead ≥ posThreshold------慢手停在阈值以上,或快手即将到达,都判 commit;

  2. 速度velocity ≥ commitVelocity------哪怕位置不够,只要甩得够快也 commit。

commitThreshold 是相对跟手封顶值 的(不是相对 1.0)。因为跟手段进度只到 0.25,阈值要在这个尺度上定------0.5 × 0.25 = 0.125,即在跟手段过半附近就判 commit,符合「顶住再松」的预期。

一句话记住:松手翻不翻,看位置更要看速度;速度前瞻让「正在甩」的手不被误判为「停在中间」。


七、边界回弹与快速连翻

7.1 边界:只回弹,不 commit

在第一页向前翻、最后一页向后翻,是不存在的翻页。这种情况下松手,只能回弹,永远不 commit。用 page-map 模型判断:

复制代码
const atBoundary =
  (direction === Forward && !canForward(spread, pageCount)) ||
  (direction === Backward && !canBackward(spread));

if (atBoundary) {
  this.springTarget = 0;     // 强制回弹
  // 即使位置和速度都满足 commit,也忽略
}

边界还要给一个视觉反馈:页面被「拽」到边界后,跟手行程可以略微缩短或加阻尼,让用户感觉「到了边,拽不动了」。

7.2 快速连翻:不要等上一页落定

快速连翻是最容易出 bug 的场景。朴素实现是「等当前页 settle 完,再开始下一页」------结果用户连扫三下,只翻了一页,剩下的被丢了。

正确做法是用 page-map 直接跳到目标 spread,中间页用简化的快速翻动动画掠过

复制代码
// 用户连扫,目标 spread 比当前远 3 页
const distance = targetSpread - currentSpread;
const visibleLeaves = Math.min(distance, MAX_VISIBLE_LEAVES); // 最多同时画几张
// 对 visibleLeaves 张纸做「错峰」翻动:每张延迟 stagger 毫秒启动
for (let i = 0; i < visibleLeaves; i++) {
  const delay = i * STAGGER_MS;
  // 每张纸跑完一个 0→1 的快速进度,落定后并入 base spread
}

关键纪律:

  • 永不凭空造纸:同时画的叶子数 ≤ 实际要翻的页数,不要为了「好看」多翻几张;

  • 错峰启动:每张纸延迟一小段启动,避免所有三角形同时形变造成瞬时高负载;

  • 落定即合并 :一张纸进度到 1,立刻把它并入 baseLeft/baseRight,腾出绘制槽位给下一张。

这正是上一篇「一次 drawVertices」的用武之地:快速连翻时也是一锅 triangle soup 提交,区别只在顶点数随叶子数增长。控制好 MAX_VISIBLE_LEAVES(通常 3-4),性能就稳。


八、捏合聚焦与缩放:阅读相机

翻页之外,阅读类交互常需要「双指捏合放大看细节」。这同样是手势 + 自定义运动的组合,但它驱动的不是 progress,而是「阅读相机」的两个量:

  • focusAmount ∈ [0,1]:0 = 总览(整本小书居中),1 = 聚焦(单页放大入主舞台);

  • contentZoom ∈ [1, 1.5]:聚焦态下再放大看细节。

捏合手势的两个关键判定:

  1. 从总览进入聚焦 :双指捏合的相对比 rEff 超过阈值,或 focusAmount 超过阈值 → 推进 focusAmount 到 1;

  2. 从聚焦退回总览 :松手时 zoomfocusAmount 低于各自阈值 → 回弹到 0。

    const MAX_CONTENT_ZOOM = 1.5;
    const ZOOM_ACTIVE = 1.02; // zoom 超过此值视为「在放大态」
    const PINCH_TO_OVERVIEW_FOCUS = 0.72; // focusAmount 低于此 → 回总览
    const PINCH_TO_OVERVIEW_ZOOM = 0.88; // zoom 低于此 → 回总览
    const PINCH_SHRINK_GAIN = 1.4; // 缩小比放大更灵敏(>1)

PINCH_SHRINK_GAIN > 1 是个常见的体验细节:用户「缩小」的预期比「放大」更强(缩小往往意味着「我不想看了,退出去」),所以让缩小手势更灵敏。

这两个量和上一篇的「阅读相机布局拟合」是对应的:focusAmount 在总览布局和聚焦布局之间 lerp,contentZoom 绕舞台中心放大。手势改状态,布局读状态,绘制只管画------三层解耦。


九、两个 ArkUI 状态传递的致命 gotcha

上面都是交互逻辑。但有一类 bug 和逻辑无关,纯粹是 ArkUI 的状态传递语义导致的,踩中就是「纹理丢了 / 控制器不响应」。这两个 gotcha 必须单独讲。

9.1 gotcha 一:引用 vs @Prop ------ 别深拷贝 native 句柄

@Prop值拷贝 。对于普通数据(数字、字符串、纯数据对象)这是好事------父子解耦,互不影响。但对于持有 native 句柄的对象,深拷贝是灾难:

  • 控制器对象 (持有 animator、回调列表、内部状态):拷贝出一个副本后,你对副本的调用(nextPage())作用在副本上,父组件持有的原件毫不知情------程序化调用不响应;

  • PixelMap[](纹理数组)PixelMap 是 native 资源,深拷贝要么失效要么复制出一份无效引用,翻着翻着纹理没了。

正确做法:这类对象用普通成员变量「按引用」传递,不要用 @Prop

复制代码
@Component
export struct BookFlip {
  // ❌ 控制器不能 @Prop:深拷贝后程序化调用失效
  // @Prop controller: FlipController;

  // ✅ 按引用传递,父子共享同一个实例
  private controller: FlipController;
  // ✅ PixelMap[] 也按引用,native 句柄不能被深拷贝
  private pagesHolder: Array<PixelMap> = [];
}

判断标准很简单:这个对象是不是「有身份」的(identity matters)? 控制器、动画句柄、native 资源------都是。它们要的是「全 app 只有一个它」,拷贝就破坏了这个前提。普通数据对象(进度、配置)才是 @Prop 的适用对象。

记住:@Prop适合「值」,引用适合「身份」。 凡是带 native 句柄或需要被外部程序化操作的对象,一律按引用传。

9.2 gotcha 二:@Watch epoch ------ 重绑纹理而不 remount

翻页的纹理(PixelMap[])会变------用户在页面上写了字,需要重新快照、替换纹理。但如果你直接用一个 @State pages: PixelMap[],赋新值时 ArkUI 可能重新构建整个组件,导致:

  • 翻页中的手势状态丢失;

  • 滚动位置重置;

  • animator 被销毁重建,进行中的翻页被打断。

解法是 epoch(纪元)模式 :用一个递增的数字 @Prop 表示「数据版本」,内容变化时只让这个数字 +1,用 @Watch 监听它来触发重绑,而真正的数据通过引用传递

复制代码
@Component
export struct BookFlip {
  // 真正的数据按引用传(不触发重建)
  private pagesHolder: Array<PixelMap> = [];

  // 纪元:仅用来通知「数据变了,该重绑了」
  @Prop @Watch('onPagesEpoch') pagesEpoch: number = 0;

  private onPagesEpoch(): void {
    // epoch 变了 → 把 pagesHolder 里的新纹理重绑到绘制 scene
    // 组件本身不重建,手势/滚动/animator 状态全部保留
    this.rebindTextures();
  }
}

// 父组件:内容变化时,更新引用 + 推进 epoch
this.pagesHolder = newSnapshots;   // 引用更新
this.pagesEpoch++;                 // 通知子组件重绑

为什么这样能避免重建?因为 @Prop pagesEpoch 是一个数字 ,它的变化只触发 @Watch 回调,不会让 ArkUI 认为「组件的输入结构变了」从而重新构建整棵子树。组件实例保持存活,所有运行时状态(手势、animator、滚动)原封不动。

口诀:数据走引用,通知走 epoch。 引用负责「把新数据递进去」,epoch 负责「告诉子组件该重读了」------两者配合,既能更新纹理又不丢状态。


十、把四层接起来的总览

把前面所有零件拼成一个完整的数据流:

复制代码
手指 ──PanGesture──► 跟手封顶 ──► progress (dragging)
                                  │
                          松手 ───┴──► decideCommit (位置+速度前瞻)
                                  │            │
                                  │     ┌──────┴──────┐
                                  │     ▼             ▼
                              target=0           target=1
                            (回弹)              (commit)
                                  │             │
                          ┌───────┴─────────────┴───────┐
                          │   @ohos.animator 节拍        │
                          │   tickSpring() 每帧积分      │
                          └───────┬─────────────────────┘
                                  │
                              progress (settling)
                                  │
                          invalidate() ──► drawVertices (上一篇)
                                  │
                          settle 到 target
                                  │
                    ┌─────────────┴─────────────┐
                    ▼                           ▼
              回到 idle                spread 前进,更新 page-map
  • 手势层采集输入;

  • 跟手封顶层约束「摸得到」;

  • 决策层决定翻不翻;

  • 弹簧层(animator + 自积分)完成「翻得动」;

  • 绘制层 (上一篇的 drawVertices)只管画当前 progress

每一层都不越权:手势不算物理,决策不画图,弹簧不管纹理。这是这套交互能稳、能调、能扩展的根本。


十一、调参清单(起步推荐值)

直接拿去当默认值,再按手感微调:

参数 推荐起步值 调大效果 调小效果
springStiffness 180 更脆、到位更快 更肉、更拖
springDampingRatio 1.0 (已临界) 会 overshoot 来回弹
commitThreshold 0.5 更难 commit(要拖更多) 更容易 commit
commitVelocity 1.2 需要甩更狠才 commit 轻甩就翻
velocityLookAhead 0.12s 更看重「正在动」 更看重「停在哪」
dragFollowAngleDeg 45 跟手段更长 更早顶住
maxContentZoom 1.5 能放更大 放大幅度受限
pinchShrinkGain 1.4 缩小更灵敏 缩小更迟钝

调参顺序建议:先定 dragFollowAngleDeg(跟手手感),再定 commitThreshold(翻页判定),最后微调 springStiffness(到位节奏)。 速度相关的两个(commitVelocity / velocityLookAhead)作为「甩」的补强,最后调。


十二、常见错误清单(对照自检)

  • progress = dx / pageWidth 直接绑手势 ------ 太松、没顶住感、松手必弹回。

  • ❌ 松手后用 ease 曲线驱动 ------ 没有物理感,丢掉「甩」的反馈。

  • ❌ 松手初速度设为 0 ------ 手势速度和弹簧速度不连续,页面「顿一下」。

  • onFrameprogress 参数直接当进度用 ------ 那是线性插值器的值,不是你的弹簧。

  • ❌ 不夹 dt ------ 切后台回来一帧飞掉。

  • ❌ 用显式欧拉积分弹簧 ------ 临界阻尼下可能发散。

  • ❌ commit 只看位置不看速度 ------ 快速回抽的手被误判为翻页。

  • ❌ 阈值相对 1.0 而非相对跟手封顶值 ------ 跟手段永远到不了阈值。

  • ❌ 边界也走 commit ------ 第一页往前翻翻出一张不存在的页。

  • ❌ 快速连翻等上一页 settle ------ 连扫三下只翻一页。

  • ❌ 连翻凭空造纸 ------ 同时画 10 张纸,性能爆炸。

  • ❌ 控制器 / PixelMap\[\] 用 @Prop ------ 深拷贝破坏 native 句柄,程序化调用失效、纹理丢失。

  • ❌ 纹理变化直接赋 @State 数组 ------ 触发组件 remount,手势和 animator 状态丢失。

  • ❌ 缩小和放大用同样灵敏度 ------ 退出阅读的操作偏迟钝。

  • ❌ animator 不在收敛后停 ------ 空转耗电。


十三、测试与验收

13.1 纯逻辑测试(可脱离 UI)

状态机、page-map、decideCommit、边界判断、快速连翻的叶子数------全部提成纯函数,零 ArkUI 依赖,用单元测试覆盖:

  • decideCommit:阈值边界(阈值±1%)、零速度、正负速度各档;

  • 边界:第一页前翻、最后一页后翻强制回弹;

  • 快速连翻:叶子数 ≤ 实际距离且 ≤ 最大可见数;

  • canForward/canBackward:0/1/2/奇数页各种 pageCount。

13.2 交互验收

  • 慢拖到一半松手 → commit(位置满足);

  • 快速甩一下松手 → commit(速度满足),且有「甩」的余速;

  • 快速回抽松手 → 回弹(速度为负);

  • 第一页前翻 / 最后页后翻 → 只回弹;

  • 连扫三下 → 翻三页,画面不抽搐;

  • 捏合放大到 1.4x → 聚焦 + 放大;缩小 → 回总览。

13.3 状态保持验收

  • 翻页过程中纹理被替换(epoch +1)→ 手势不中断、animator 不重建、翻页继续;

  • 程序化调用 controller.nextPage() → 响应(说明控制器按引用传对了)。


十四、写在最后

跟手又有弹性的翻页,心智可以收成四层:

手势采集只管「输入」,跟手封顶只管「摸得到」,commit 决策只管「翻不翻」,弹簧积分只管「怎么动」。 四层各司其职,互不越权。

再配上两条状态传递的铁律:

带 native 句柄的对象(控制器、PixelMap\[\])一律按引用传------ @Prop的深拷贝会破坏身份。

纹理更新走「引用递数据 + epoch 通知重绑」------别让数据变化触发组件 remount。

记住这套方法的最简表达:手势算输入,封顶给手感,决策看位置更看速度,弹簧接管松手后的运动;引用管身份,epoch 管通知。

一旦你接受了「手势不直接等于进度」这个前提,翻页就不再是「一个滑动手势绑个动画」,而是「任意连续物理交互」的一个特例------拖拽排序的回弹、卡片翻开闭合、抽屉拉出吸入,都共享同一套「采集 → 封顶 → 决策 → 弹簧」的四层模型。这套逻辑写一次,未来的手感类交互就自动复用了。

这是本系列的第二篇,和第一篇《用 ArkGraphics2D 画一个会卷曲翻动的页面网格》合在一起,就是一套「画得动 + 听手势」的完整翻页方案:第一篇负责「让它看起来像张会卷的纸」,这一篇负责「让这张纸听你的手」。两者通过一个 progress ∈ [0,1] 和一个 invalidate() 衔接------状态层驱动,绘制层响应,干净的单向数据流。


本文基于 ArkUI 的 PanGesture/ PinchGesture @ohos.animator能力整理,核心方法(状态机 + 自积分弹簧 + 跟手封顶 + 速度前瞻 commit + 引用/epoch 状态传递)可复用于任何 HarmonyOS 上的连续物理交互场景。

相关推荐
腾科IT教育2 小时前
HarmonyOS开发|ArkTS UI颜色API通用规则
ui·华为·harmonyos·harmonyos开发·鸿蒙应用开发工程师
北墨NoLimit9 小时前
DevEco Code:在终端里用 AI 写鸿蒙应用
harmonyos
智塑未来10 小时前
打开快、切换顺、游戏稳:鸿蒙的日常流畅表现
游戏·华为·harmonyos
智塑未来1 天前
鸿蒙游戏体验手册:四种能力从性能到玩法逐一解锁
游戏·华为·harmonyos
math_hongfan1 天前
鸿蒙离线数据缓存高级架构:弱网预加载/离线数据优先级/同步冲突解决/上线后数据合并策略
学习·缓存·华为·架构·harmonyos·鸿蒙
math_hongfan1 天前
鸿蒙企业级数据存储高级架构:从读写分离到冷热数据分层/归档策略/数据生命周期管理最佳实践
人工智能·学习·华为·架构·harmonyos·鸿蒙
math_hongfan1 天前
鸿蒙存储异常高级排查:文件损坏检测/数据恢复/读写失败重试/磁盘空间预警系统性根治方案
学习·华为·harmonyos·鸿蒙
lilian2331 天前
Harmony os 技术实战|拼豆制图10:把取消、解析失败和保存失败写成可恢复状态机
java·javascript·华为·harmonyos
2501_919749031 天前
华为鸿蒙美缝剂实用APP—小羊美缝
华为·harmonyos