算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / shared element:两个节点的连续 morph 待审核 34 / 47
shared element · magic-move · FLIP 跨节点

shared element:两个节点的连续 morph

FLIP 处理的是同一个 DOM 元素布局变化前后的过渡,列表生命周期处理重排。本页更进一步:列表视图里的小头像和详情视图里的大头像是两个不同的 DOM 节点,却代表同一个对象——切换视图时要让它看起来是同一个元素连续放大飞过去。

手法仍是 FLIP 的「先量首末、反推、播回 identity」,只是首末取自两个节点。

1 · 列表与详情之间

图 1-1 · 头像作为共享元素在列表与详情之间飞放。可调过渡时长,点任一人进入详情、点返回飞回原位。

进入时量出列表头像的盒子作 first,让详情视图显示出来后量详情头像的盒子作 last,用 invert(first, last) 求出位移与缩放差,先把详情头像反推到列表头像的位置,再让一个 Animation 把这个因子从 1 播到 0。视觉上就是同一个头像飞过去。

2 · 时机比几何更难

几何部分与单元素 FLIP 完全一样,真正的难点在什么时候量 last。详情头像必须先真正参与布局,才能量出它该在的位置;而一旦它参与了布局又还没被反推,就会有一帧画出终态。

警示 · 在 Vue 里这一步要用 nextTick() 而不是 requestAnimationFrame()nextTick 在 microtask 里冲刷 DOM 更新,仍在当前这一帧之内,反推 transform 赶得上;requestAnimationFrame 的回调发生在下一帧的渲染前,但样式变更已经在上一帧末尾被绘制过一次——终态会闪出来。原始的命令式版本直接改 hidden 属性,DOM 是同步更新的,所以根本不需要等待这一步。

两个头像的 background 颜色由 .av0 / .av1 / .av2 三个类给出,进入详情时要把类名同步过去。若忘了这一步,飞过去的形状对但颜色不对——这类「几何连续而外观跳变」的 bug 在共享元素里很常见,因为几何由 FLIP 保证,其余属性得自己对齐。

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

// el 从 firstRect 的盒子, FLIP 回它自己当前的位置
function morphFrom(el, firstRect, dur) {
  const last = el.getBoundingClientRect();            // 它真正该在的位置
  const d = invert(box(firstRect), box(last));        // 位移 + 缩放差
  el.style.transformOrigin = 'top left';
  el.style.transform = invertTransform(d, 1);         // 反推到 firstRect 处
  return new Animation({
    keyframes: [1, 0], duration: dur, ease: 'easeInOut',
    onUpdate: (f) => el.style.transform = invertTransform(d, f), // 1→0 播回 identity
    onComplete: () => el.style.transform = '',
  });
}

// 进入详情: 量列表头像 (first), 挂出详情头像并从 first 飞过去
const first = chipAvatar.getBoundingClientRect();
showDetail();
morphFrom(heroAvatar, first, 460);   // 两个不同节点, 过渡却连续

浏览器原生的 View Transitions 把这一整套自动化——给两个节点同名 view-transition-name 即可,对照见与原生对照。手写一遍是为看清它内部到底做了什么。单元素 FLIP 与拖拽惯性见高级交互,列表增删重排的 FLIP 见列表生命周期;为什么用 transform 反推而非动 left / width,见合成层