算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 文字变形:一个词逐字长成另一个词 待审核 14 / 47
text morph · 槽位对齐 · 一个时钟

文字变形:一个词逐字长成另一个词

标题里那种不断切换的月份动画,字不是整块换掉的:每个字母各自滑向新位置、在半路换成另一张脸,长出来的字母从串尾外侧挤进来。文本动画那页把一句话拆开做入场,本页更进一步——两串字之间要先对齐:谁只是搬家(keep)、谁淡出(out)、谁淡入(in)。

对齐排出一组槽位之后,剩下的全是老朋友:一段插值、一条缓动、一把交错的延迟。

1 · 唯一时钟与纯函数的 render

整页只有一个 Animation 在跑,它的值就是「当前是第几毫秒」。每个字的位置与不透明度都由这一个数经纯函数算出:

q=ease ⁣(tdidur),xi=fromi+(toifromi)qq = \text{ease}\!\left(\frac{t - d_i}{\text{dur}}\right), \qquad x_i = \text{from}_i + (\text{to}_i - \text{from}_i)\, q

十几个字大可各建一个 Animation,那正是 stagger 的常规做法。但那样「整段 morph 现在走到哪」就散在十几个播放器里,想统一 seek 得逐个 seek。换成一个时钟之后,交错从「一堆播放器的起跑时间」降级成一个纯函数的参数——delay 只是 render(t) 里的一次减法。

图 1-1 · 一个时钟驱动的整段 morph。甘特图一条 lane 是一个字,色块是它的 [delay, delay + dur] 区间,红线扫过谁谁就在动。可拖播放头停在任意中间帧,或点反向看 September 缩回 August。

页内把淡出的字标红、淡入的字标紫,只是搬家的字保持墨色;真实使用时它们当然是同一个颜色。把 each 拉大,色块阶梯错得越开,过渡中字距被拉开得越夸张——那些「Janua ry」「Oc to ber」的瞬间全部由此而来。

注 · 首帧必须等字体就位。字宽由一把隐藏的 ruler 量出来,回退字体与真正的显示字体宽度不同,document.fonts.ready 之前量到的偏移全是错的——表现为页面刚加载时整串字位置突然抖一下。两个 lab 都把首次 build 挂在 fonts.ready 之后。

2 · 未播完即被打断的起点

真实场景里没人等播完——用户连点、数据连续刷新。此时新的一次 morph 该从哪儿起步?

按算法给的起点(旧串的静止位置)重新开始,字会瞬间跳回去,过渡断成两截。正确的做法是先读出每个字此刻真正渲染在哪、多透明,拿它当新一段的起点。实现上只需每帧把渲染值记进一个 live 数组,新一段开始时按槽位序覆盖 slot.from

图 2-1 · 同一次打断在两种起点策略下的对照。读数给出被抹掉的最大跳变像素数,点「连点 3 次」差别最明显。

警示 · 这件事 CSS 版是白送的。transition 的语义就是「从当前计算值过渡到新值」,半路改目标浏览器自动从中间值接着走,不需要一行代码。引擎版把插值权拿回自己手里,这份「接住」就得自己实现。反过来,CSS 版拿不到播放头、没法 seek 与反向,那是引擎版白送的。两边各自免费的东西,恰好是对方要手写的东西。

注 · 本页的「接住」只对齐了位置,速度仍从新缓动的起点重新算——默认 expoOut 起步最快,所以观感尚可。若要连速度也连续,得把每个字的 xx 换成弹簧值源、在飞行中改目标,做法见飞行中重定向中断与重定向。代价是放弃「一个时钟」的可 seek 性——弹簧的时长是结果而非给定。可 seek 与可无缝打断因而是一对需要取舍的性质。

import { Animation } from '@vega/anim';
import { planMorph, slotDelays } from './text-morph.ts';

// 1. 对齐: 两串字 → 一组槽位 { role, from, to, order }
const slots  = planMorph(oldGlyphs, newGlyphs, 'index');
const delays = slotDelays(slots, each, 'ltr');   // 每个槽位晚多久起跑
const total  = Math.max(...delays) + Math.max(dur, fade);

// 2. render 是纯函数: 给定 t, 每个字的位置与不透明度唯一确定
const render = (t) => slots.forEach((s, i) => {
  const q = ease(clamp01((t - delays[i]) / dur));    // 位移进度
  const f =      clamp01((t - delays[i]) / fade);    // 换脸进度
  el[i].style.transform = `translateX(${s.from + (s.to - s.from) * q}px)`;
  el[i].style.opacity   = s.role === 'keep' ? 1 : s.role === 'in' ? f : 1 - f;
});

// 3. 唯一的播放器: 它的「值」就是毫秒数
const clock = new Animation({
  keyframes: [0, total], duration: total, ease: 'linear',
  onUpdate: render,
});
clock.seek(320);   clock.reverse();   clock.speed = 0.25;

对齐用的最长公共子序列就是 diff 的内核,展开见序列对齐 DP列表 diff。把同一套「量、对齐、交错」交给 transition 表达,以及随之而来的无障碍与降级取舍,见 CSS 侧那一页