算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 手势进阶:磁吸、跟随、滑动消除 待审核 33 / 47
magnetic · swipe-to-dismiss · follow

手势进阶:磁吸、跟随、滑动消除

拖拽惯性是把松手速度交给弹簧顺势抛出;本页是它的另一面——把持续的指针位置接到动画值上。

三个手势共用同一套零件:pointerdown / move / up 取输入、createSpring 平滑逼近目标、createDecay 接住松手速度。区别只在于「目标是什么、谁来接管」。

1 · 磁吸

指针进入按钮一定半径内,按钮被弹簧吸向指针:目标偏移等于指针到中心的向量乘一个比例系数。指针离开半径,目标偏移归零,弹簧把它弹回原位。

图 1-1 · 磁吸按钮与它的吸附半径。可调半径与吸附比例,读数给出指针距中心与当前目标偏移。

关键是弹簧的 from 始终取实时位置to当下目标。目标随指针连续变化时,每一次重建都从当前值与当前速度续上,所以目标突变(比如指针跨过半径边界)也不会让按钮瞬跳——弹簧自然把这段差值演化掉。

2 · 跟随

小圆点用弹簧平滑跟随指针而非瞬移:每帧把弹簧目标更新为最新指针位置,值朝它逼近。欠阻尼参数下会带轻微延迟与过冲——指针骤停时圆点冲过一点再收回。

图 2-1 · 弹簧跟随的滞后与过冲。调小 stiffness 让跟随更黏,调小 damping 放大过冲。

注 · 本页每次 pointermove 都重建一次弹簧,而 pointermove 在高刷屏上可以每秒触发上百次——比 rAF 还密。之所以不做节流,是因为重建的成本只是算几个系数(见向量弹簧的同一笔账),而节流会让目标滞后于指针,把「弹簧的滞后」与「采样的滞后」混在一起,调参时分不清哪个在起作用。

3 · 滑动消除

水平拖动卡片,松手时用最近两帧的位移除以时间估出速度。若位移或速度超过阈值,把这个速度交给 createDecay,卡片顺着惯性甩出并淡出;否则用 createSpring 弹回原位。

图 3-1 · 滑动消除的阈值分叉。可调位移与速度两个阈值,读数给出松手速度与判定结果。

两条路的分叉点就是松手那一刻的一次比较。判定「消除」之后就是无目标的惯性滑行——惯性衰减那一套,落点由初速度决定。

警示 · 光靠 decay 的自然落点不够。慢速但超过位移阈值地松手时,powerv0\text{power} \cdot v_0 算出的落点可能还在屏幕内,卡片会停在半路。所以本页补了一道:若自然落点没冲出「舞台一半宽加一个卡片宽」,就用 modifyTarget 强制改到那个距离。物理手感与「必须消失」这个业务约束在此处冲突,解法与转盘落点的吸附是同一个——用 modifyTarget 在物理之外加一层约束。

import { createSpring, createDecay } from '@vega/anim';

// 磁吸 / 跟随: 每次目标变化都用「当前位置 + 当前速度」重建弹簧
function aim(tx, ty) {
  const vx = sx.velocity(now - t0);          // 接住进行中的速度
  sx = createSpring({ stiffness, damping, from: cur.x, to: tx, velocity: vx });
  t0 = now;
}

// 滑动消除: 松手时按阈值分叉
function onPointerUp() {
  const v = (dx - lastX) / dt * 1000;        // 最近两帧估速度 (px/s)
  const over = Math.abs(dx) > THRESH_DX || Math.abs(v) > THRESH_V;
  if (over) {
    anim = createDecay({ velocity: v, from: dx });   // 无目标, 顺势甩出
  } else {
    anim = createSpring({ from: dx, to: 0, velocity: v }); // 弹回原位
  }
}

松手抛出的惯性来源见拖拽惯性;弹簧的过冲与回弹形态见弹簧;不断改目标、由弹簧从当前位置与速度接管下一段,正是中断与重定向讲的速度接管。