一、前言:直接拿手势驱动进度,必然翻车
先看几个真实的翻车现场。
现场一:松手就弹回。 手指拖了页面卷起一半,松手------页面「啪」地弹回原位。用户觉得「我明明拖了一大半了,为什么不翻过去?」
现场二:怎么滑都翻一整页。 不管手指拖多远,松手一律翻完整一页。用户想「瞄一眼下一页」做不到,翻页变成了全有或全无。
现场三:快速连翻卡死。 用户在书脊上快速连扫想翻好几页,结果第二页还没翻完就被第三页打断,画面抽搐,状态错乱。
现场四:跟手太顶或太松。 要么手指动一点点页面就翻到底(跟手太顶,没有「顶住」的反馈),要么手指拖满整页宽度页面才卷起一小角(跟手太松,像在拖一块湿毛巾)。
现场五:纹理丢了 / 控制器失效。 用 @Prop 传了一个控制器和一组 PixelMap,结果翻着翻着纹理没了,或者程序化调用(nextPage())不响应。
根因可以归成两类:
交互层:手势直接绑进度,没有物理和决策------松手怎么动、翻不翻全凭运气。
状态层:用 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 等动画曲线,为什么翻页要自己积分弹簧?两个硬伤:
-
ease 没有物理感。 它是「时间 → 进度」的固定映射,跟手松手后的运动和用户拖动的速度无关。用户轻轻松手和用力甩出去,松手后的轨迹一模一样------没有「甩」的反馈。
-
ease 没有速度连续性。 松手瞬间,手势驱动的速度和 ease 起始速度(恒为 0)对不上,页面会「顿一下」再开始动。
3.2 弹簧:刚度、阻尼、临界阻尼
一个二阶弹簧由两个参数决定性格:
-
刚度
stiffness(又称 k):拉回原点的力气大小,越大越「脆」; -
阻尼比
dampingRatio:1.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();
很多人误以为 onFrame 的 progress 参数能直接用------它不能,那是线性插值器的值。我们把 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(); // 请求重绘
}
三个细节决定它稳不稳:
-
dt兜底 :切后台再回前台,dt可能是几秒,不夹会一帧飞到天外。夹到0.05s(相当于最低 20fps)。 -
半隐式欧拉:先更新速度再更新位置,比显式欧拉稳定得多,临界阻尼下不会发散。
-
收敛后主动停 :到了目标就
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 };
}
两层判定:
-
位置(含前瞻) :
lookAhead ≥ posThreshold------慢手停在阈值以上,或快手即将到达,都判 commit; -
速度 :
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]:聚焦态下再放大看细节。
捏合手势的两个关键判定:
-
从总览进入聚焦 :双指捏合的相对比
rEff超过阈值,或focusAmount超过阈值 → 推进focusAmount到 1; -
从聚焦退回总览 :松手时
zoom或focusAmount低于各自阈值 → 回弹到 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 ------ 手势速度和弹簧速度不连续,页面「顿一下」。
-
❌
onFrame的progress参数直接当进度用 ------ 那是线性插值器的值,不是你的弹簧。 -
❌ 不夹
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 上的连续物理交互场景。
