算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 滚动数字:计数器与翻牌 待审核 6 / 47
number tween · odometer · 格式化

滚动数字:计数器与翻牌

数字从旧值滚到新值,是插值最直接的一个落地:被补间的「值」就是这个数本身,一次 mixNumber 就够了。真正的难点在呈现——同一条 0N0 \to N 的补间,接到文本上是计数器,接到位移上就是里程表式的翻牌。

1 · 两种呈现共吃一条补间

纯计数器的做法只有两步:对插值结果 Math.round,再 toLocaleString 补千分位。翻牌 odometer 复杂得多:每一位做成一条可上下滚动的数字带,低位转得快、高位转得慢,靠 translateY 把对应数字推到视窗里。

图 1-1 · 同一条数字补间的两种呈现。可切换缓动与时长,或点不同目标值观察低位与高位的转速差。

2 · 齿轮式取位的破绽

直觉的写法是让第 ii 位取 v/10imod10\lfloor v / 10^i \rfloor \bmod 10 的连续值——像一排啮合的齿轮。滚动过程确实顺滑,但静止时会露馅:v=86400v = 86400 时千位算得 86.4mod10=6.486.4 \bmod 10 = 6.4,数字带停在 6 与 7 之间,整块读数看上去「没对齐」。这不是取整精度问题,而是模型错了——真实的里程表并非齿轮连续联动。

改用进位传递:个位仍取 vmod10v \bmod 10 连续滚动;每个高位等于自己的整数位,加上从下位溢出来的零头——当下位偏移进入 [9,10)[9, 10) 时,那段 010 \to 1 的零头平滑爬升到本位。滚动处处连续,静止时下位归整、进位归零,每一位又恰好落在整数位上。

new Animation({
  keyframes: [from, target],   // 被补间的值就是这个数
  duration: 1600,
  ease: 'easeOut',
  onUpdate: (v) => {
    counter.textContent = Math.round(v).toLocaleString();    // 纯计数器
    let below = v % 10;                                      // 翻牌: 个位连续滚
    strip[0].style.transform = `translateY(${-below * H}px)`;
    for (let i = 1; i < COLS; i++) {                          // 高位 = 整数位 + 进位零头
      const digit = Math.floor(v / 10 ** i) % 10;
      const offset = digit + (below >= 9 ? below - 9 : 0);
      strip[i].style.transform = `translateY(${-offset * H}px)`;
      below = offset;
    }
  },
});

注 · 数字带要放 11 个数字而不是 10 个:末尾多补一个 0,让偏移量走到 10 时露出的是下一轮的 0,而非空白。这就是上面那段代码里 offset 允许取到 [9,10)[9, 10) 之外一点点的前提。

落定的节奏由缓动决定,计数器惯用 easeOut——数字快速冲出再缓缓定格,末段的减速给了读数时间。若被补间的是颜色而非数字,插值还要考虑在哪个空间里做,见插值色彩空间