算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 高级交互:拖拽惯性、滚动驱动、FLIP 待审核 24 / 47
drag · scroll-linked · FLIP

高级交互:拖拽惯性、滚动驱动、FLIP

前面的要素组合起来就能支撑三类常见的交互动画。它们都不引入新机制,只是把输入接到动画值上,或把播放头接到别的来源上。

1 · 拖拽与惯性

松手瞬间用最近一小段时间窗的位移估出速度 Δx/Δt\Delta x / \Delta t,作为弹簧的初速度接管,于是顺势抛出并在边界回弹。

图 1-1 · 带惯性与边界回弹的拖拽。可把方块甩到容器外,观察它被弹回范围内。

注 · 速度估计的时间窗长度是一处隐含取舍。窗口太短,最后一两个 pointer 事件的抖动会被放大成一个荒谬的初速度;太长则跟不上急停——用户明明已经停住手指,松开却仍飞出去。这也是「甩一下再停住」这个手势在很多实现里表现不一致的原因。

2 · 滚动驱动

进度不再由时间流动,而是由 scrollTop 在区间内归一化得到——滚动条就是动画的播放头。这一步之所以成立,正因为播放头可被任意 seek 而无需重放历史。

图 2-1 · 滚动驱动的旋转、缩放与进度条,三者绑定到同一个滚动进度。可在框内上下滚动。

3 · FLIP

直接对 width 做动画会逐帧重排。FLIP 换个次序:先量变更位置(First),让 DOM 直接跳到态并量新位置(Last),用一个 transform 把它反推回看起来没变(Invert),再把 transform 动画回 identity(Play)。整个过渡只动 transform,不触发重排。

图 3-1 · 共享布局的 FLIP:点任意方块切换大小,被挤压的邻居一起平移。全程只有 transform 在变。

警示 · Invert 那一步必须同步写上,不能等到动画的首帧。Animation 的第一次 onUpdate 落在下一个 requestAnimationFrame 里,而布局变更已经在当前任务内生效——中间隔着一次绘制,终态会先闪现一帧。本页的 lab 因此在建立 Animation 之前就把 invertTransform(delta, 1) 直接写进 style.transform

被点的方块除了尺寸还要换颜色,而 class 切换会让背景色立刻跳到目标值。做法是在 mutate 之后立刻把 backgroundColor 写回旧色,再由 onUpdatemixColor 逐帧推进,onComplete 时清空内联样式交还给 CSS。

import { drag, onScroll, invert, invertTransform, Animation } from '@vega/anim';

// 拖拽惯性: 松手用估出的速度做弹簧初速度, 越界回弹
drag(box, { axis: 'x', min: -120, max: 120, stiffness: 500, damping: 32 });

// 滚动驱动: 播放头 = scrollTop 在 [start, end] 内归一化
onScroll((p) => { el.style.transform = `rotate(${p * 360}deg)`; },
         { container: scroller, start: 0, end });

// FLIP: First → Last → Invert → Play, 全程只动 transform
const first = el.getBoundingClientRect();
mutateLayout();                                    // 直接跳到终态
const d = invert(first, el.getBoundingClientRect());
el.style.transform = invertTransform(d, 1);        // 反推回原位 (须同步)
new Animation({ keyframes: [1, 0], duration: 420, ease: 'easeOut',
  onUpdate: (f) => (el.style.transform = invertTransform(d, f)) });

拖拽惯性的物理见弹簧惯性衰减;真正的中途速度接管见中断与重定向;归一化映射见插值interpolate(input, output)。为什么只动 transform 就不重排,见合成器