从缩略图到全图:Photo Viewer 的飞行动画设计与实现
Tokimo Web Desktop OS · 动画系统
目录
动机
用户在 Photo 时间线里点击一张缩略图,Photo Viewer 窗口打开,图片展示出来。这个过程在任何系统里都有,但大多数系统的体验是:
- 点击缩略图
- 窗口弹出来(空白/加载态)
- 图片加载完成,显示
三步之间有明显的视觉断层。用户点击的是一个小缩略图,但看到的是一个大窗口——视觉上没有任何连续性。
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/H 是 tick() 函数里用的变量。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,和窗口开窗动画同时开始、同时结束。用户点击缩略图的瞬间,动画就已经起飞了。
版权属于:一名宅。
本文链接:https://zhaiyiming.com/archives/77.html
转载时须注明出处及本声明