从缩略图到全图:Photo Viewer 的飞行动画设计与实现

Tokimo Web Desktop OS · 动画系统

目录

动机

用户在 Photo 时间线里点击一张缩略图,Photo Viewer 窗口打开,图片展示出来。这个过程在任何系统里都有,但大多数系统的体验是:

  1. 点击缩略图
  2. 窗口弹出来(空白/加载态)
  3. 图片加载完成,显示

三步之间有明显的视觉断层。用户点击的是一个小缩略图,但看到的是一个大窗口——视觉上没有任何连续性。

macOS 的 Photo Viewer 做了一个飞行动画:缩略图从点击位置"飞"到 viewer 窗口里的图片位置。这个动画建立了视觉连续性,让用户理解"我点的就是这张图,它飞到了这里"。

我们要在 Tokimo 里实现同样的效果,而且要做得更好——支持变轨

动画架构

为什么不用 CSS Transition

飞行动画看起来简单:A 点到 B 点,加个 easeOut 缓动。用 CSS Transition 就够了?

不行。因为 B 点在动画过程中会变

Tokimo 的窗口打开时有一个开窗动画:scale(0.92) → scale(1),持续 300ms。这 300ms 内,viewer 里的图片位置在持续变化。如果用 CSS Transition,飞元素会飞到动画开始时测量的位置——等它到达时,图片已经不在那里了。

时间轴:
  0ms        100ms       200ms       300ms
  │           │           │           │
  ├─ 开窗动画 ─┤ scale 0.92→1 ────────┤
  ├─ 飞行动画 ─┤ A→B (300ms) ─────────┤
              │                       │
              │ 飞元素在这里时,        │ 最终位置
              │ 目标已经偏移了 ~15px    │

CSS Transition 中途改变目标会怎样?浏览器会从当前位置重新计算路径,但动画会在中途突然改变速度和方向——视觉上很突兀。

我们选择 requestAnimationFrame 手动控制每一帧的位置。

核心思路

动画由两个并行的 rAF 循环驱动:

tick()  ── 每帧计算飞元素的当前位置(从 source 插值到 dest)
track() ── 每帧更新 dest(追踪目标图片的实时位置)

tick() 读取 destX/Y/W/H 来计算插值。track() 每帧更新 destX/Y/W/H。两个循环共享同一组变量,飞元素的运动轨迹会实时跟随目标"漂移"。

// tick() — 动画主循环
const tick = (now: number) => {
  const t = Math.min((now - startTime) / DURATION, 1);
  const e = easeOut(t);  // 1 - (1 - t)³

  // destX/Y/W/H 可能每帧都在变——track() 在更新它们
  flyEl.style.left = `${source.left + (destX - source.left) * e}px`;
  flyEl.style.top  = `${source.top  + (destY - source.top)  * e}px`;
  flyEl.style.width  = `${source.width  + (destW - source.width)  * e}px`;
  flyEl.style.height = `${source.height + (destH - source.height) * e}px`;

  if (t < 1) requestAnimationFrame(tick);
  else land();
};

// track() — 持续追踪目标
const track = () => {
  if (cancelled) return;
  if (imgEl.dataset.displayReady === "true") {
    const r = imgEl.getBoundingClientRect();
    destX = r.left;
    destY = r.top;
    destW = r.width;
    destH = r.height;
  }
  requestAnimationFrame(track);
};

为什么是两个循环而不是一个?因为职责不同:

  • tick() 有明确的生命周期(300ms 后结束)
  • track() 没有——它会一直跑到组件卸载

如果合并成一个循环,tick() 结束后还要继续跑 track() 来清理,逻辑会纠缠在一起。分开写,各管各的。

为什么 dest 是变量而不是参数

tick() 不接收 dest 作为参数,而是直接读取闭包里的 destX/Y/W/H。这是因为 tick() 通过 requestAnimationFrame 递归调用,每帧读取的都是最新的值。如果传参数,每帧都要重新绑定,没有意义。

这也是变轨的核心:tick() 不知道目标会变,它只管从 source 插值到 dest。track() 在背后悄悄更新 dest,tick() 下一帧自然就用新值了。

变轨设计

问题

窗口打开时有一个开窗动画:scale(0.92) → scale(1),持续 300ms。在这 300ms 内,viewer 里的图片位置在持续变化。如果我们在动画开始时测量一次目标位置,测量值是错的(因为窗口还在 scale 动画中)。

如果等 300ms 开窗动画结束再测量,用户就要等 300ms 才看到动画——体验太差。

解决方案:持续追踪

不等,也不只测一次。每帧都测量目标位置,飞元素实时跟随:

const track = () => {
  if (cancelled) return;
  if (imgEl.dataset.displayReady === "true") {
    const r = imgEl.getBoundingClientRect();
    destX = r.left;
    destY = r.top;
    destW = r.width;
    destH = r.height;
  }
  requestAnimationFrame(track);
};
requestAnimationFrame(track);

destX/Y/W/Htick() 函数里用的变量。track() 每帧更新它们,tick() 下一帧就用新值。飞元素的运动轨迹会实时跟随目标"漂移"。

displayReady 是一个自定义 data 属性,当 viewer 的 displaySize 计算完成并写入 DOM 后才设置。在 displayReady 出现之前,track() 不会更新 dest——飞元素用预估落点飞行。displayReady 出现后,track() 开始追踪真实位置。

整个过程中,飞元素的运动是连续的。它从预估落点出发,随着 track() 更新 dest,飞行路径逐渐修正到真实位置。视觉上像是飞元素"感知"到了目标在移动,主动跟了过去。

预估落点

变轨需要一个初始目标。如果初始目标和最终目标差距太大,变轨会很明显——飞元素先飞到一个地方,然后突然拐弯。

所以我们用原图尺寸来计算一个精准的初始预估:

const imgMeta = win.metadata?.dataSource as DataSourceItem[];
const curItem = imgMeta?.[win.metadata?.index ?? 0];
const cRect = container.getBoundingClientRect();

if (curItem?.width && curItem?.height) {
  const aspect = curItem.width / curItem.height;
  if (aspect > cRect.width / cRect.height) {
    estW = cRect.width;
    estH = cRect.width / aspect;
  } else {
    estH = cRect.height;
    estW = cRect.height * aspect;
  }
}

这和 viewer 里 displaySize 的计算逻辑完全一致——用原图宽高比,在容器内 fit。所以对于大图,初始预估和最终落点几乎一样——变轨幅度极小,视觉上几乎察觉不到。

对于小图(不足以撑满容器),直接用原图尺寸作为预估落点。

大图 (4000×3000):预估落点 = 容器适配大小 ≈ 最终落点 → 变轨幅度 ~0
小图 (200×150):  预估落点 = 原图大小     ≈ 最终落点 → 变轨幅度 ~0

防闪烁设计

问题

动画结束时,飞元素被移除。如果此时 viewer 里的图片还没显示出来(opacity: 0),就会出现黑色闪烁——飞元素消失了,但下面什么都没有。

viewer 的图片加载器默认隐藏 thumbnail(opacity: 0),等飞行动画结束后才显示。但飞行动画结束后才派发事件、React 渲染、thumbnail 变为可见——中间有几帧的空隙。

解决方案:photo-fly-end 事件 + 一帧延迟

viewer 的图片加载器监听 photo-fly-end 事件。收到事件后,mounted 状态变为 true,thumbnail 从 opacity: 0 变为 opacity: 1

// use-viewer-image-loader.ts
useEffect(() => {
  const show = () => setMounted(true);
  window.addEventListener("photo-fly-end", show);
  const fallback = setTimeout(show, 400);  // 兜底
  return () => {
    window.removeEventListener("photo-fly-end", show);
    clearTimeout(fallback);
  };
}, []);

飞行动画结束时,先派发事件,等一帧让 React 渲染 thumbnail,再移除飞元素:

if (t < 1) {
  requestAnimationFrame(tick);
} else {
  // 先通知 image loader 显示 thumbnail
  window.dispatchEvent(new Event("photo-fly-end"));
  // 等一帧,让 React 把 thumbnail 渲染出来
  requestAnimationFrame(() => flyEl.remove());
}

这样飞元素消失的瞬间,viewer 里的 thumbnail 已经可见——无缝衔接。

总结

这个动画的核心挑战不是"A 飞到 B",而是 B 在动

窗口的开窗动画(scale 0.92→1)让目标位置在 300ms 内持续变化。如果只测一次,飞元素会落在错误的位置。如果等动画结束再测,用户要白等 300ms。

解决方案是持续追踪:两个 rAF 循环并行,一个控制飞行,一个追踪目标。飞元素不需要知道目标在动——它只管从 source 插值到 dest,而 dest 每帧都在被悄悄更新。

为了让变轨不那么明显,我们用原图尺寸预计算了一个和最终落点几乎一致的初始目标。大图的变轨幅度趋近于零,视觉上就是一条直线飞过去。

整个动画 300ms,和窗口开窗动画同时开始、同时结束。用户点击缩略图的瞬间,动画就已经起飞了。