算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 橡皮筋:越界阻尼与回弹 待审核 10 / 47
rubber-band · over-scroll · 阻尼

橡皮筋:越界阻尼与回弹

iOS 的列表拖到顶还能再拽一段,但越拽越沉,松手弹回。这个手感由两段拼成:边界之内内容 1:1 跟手;越界之后,真实位移按一条阻尼函数对跟手位移做压缩,渐近饱和,怎么拽都到不了无穷。

offset=limit+d(11x/d+1)\text{offset} = \text{limit} + d \left(1 - \frac{1}{x/d + 1}\right)

其中 xx 是越界量(跟手位移减去边界,界内不走这条式子),limit\text{limit} 是边界,dd 是阻尼系数(像素)。

1 · 两段拼接的手感

图 1-1 · 越界阻尼与松手回弹。可调阻尼系数与回弹弹簧参数;蓝色虚框是未经阻尼的跟手位移,实色块是压缩后的实际位移。

2 · 这条函数为什么好用

它有三个性质,缺一个手感就会塌。

渐近饱和xx \to \inftyoffsetlimit+d\text{offset} \to \text{limit} + d,越界量被压在 dd 以内,所以怎么拽都拽不穿。dd 因此不是「阻力大小」而是「最多能拽出多远」,调它等于调那条看不见的天花板。

在边界处与跟手段一阶连续:导数是 1/(x/d+1)21 / (x/d + 1)^2,在 x=0x = 0 处恰为 1,与界内的 1:1 跟手完全接上。这是这条式子相比其他饱和函数(如 tanh\tanh 或对数)的优势——不必额外配系数就自动衔接,越过边界的那一瞬不会有速度突变的卡顿感。

严格递增因而可逆:每个实际位移唯一对应一个跟手位移,松手回弹与继续拖拽之间可以随时切换,不会出现两个跟手位置映射到同一屏幕位置的歧义。

3 · 松手后交给谁

松手时分三种情况。已越界,用 createSpring 以当前位置与松手速度为初始条件、目标设为边界值拉回;在界内且甩得够快,用 createDecay 带速度滑行;否则原地不动。

第三种衔接最有意思:decay 滑行途中每帧检查是否滑出边界,一旦越界立刻废弃这条 decay、就地建一条 spring 拉回。两个物理模型在同一帧交接,而交接点的速度由 decay 的 velocity() 提供——惯性衰减那一页讲过它是位移的严格导数,正因如此交接才不会跳变。

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

// 越界阻尼: 界内 1:1, 越界部分按 limit + d·(1 − 1/(over/d + 1)) 压缩
function rubberBand(raw, limit, d) {
  if (raw <= limit) return raw;                  // 边界之内, 跟手
  const over = raw - limit;                      // 越界量
  return limit + d * (1 - 1 / (over / d + 1));   // 渐近饱和到 limit + d
}

// 松手且越界: spring 拉回边界, 接住松手速度 velocity
const spring = createSpring({ stiffness, damping, mass: 1,
                             from: y, to: edge, velocity });
// 界内甩动: decay 带速度滑行, 滑出界再交给上面的 spring
const decay = createDecay({ velocity, power: 0.5, from: y });

回弹的物理形态(欠阻尼时会小幅过冲再收敛)见弹簧。把这一整套接到真实 pointer 手势上、做成可复用的拖拽行为,见手势