算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 进度条:把跳变的真实进度译成可信的运动 待审核 29 / 47
determinate · indeterminate · trickle · segmented · circular

进度条:把跳变的真实进度译成可信的运动

进度条看似只是一条会变长的方块,难点全在动画这一侧:真实进度往往从外部离散跳变着到(下载分块 02323711000 \to 23 \to 23 \to 71 \to 100),直接把宽度设过去会一段段跳;有时压根测不到真实进度;有时它是多段的,需要可暂停、可跳段的播放控制。

收敛成一句话:进度条是把一个不连续、甚至不可知的外部信号,翻译成视觉上连续而可信的运动。本页五种形态全程不依赖 CSS transitionanimation

1 · 确定态的平滑追赶

真实进度已知,但它是分块跳着到的。把宽度直接设成真实值,每块到达都会「啪」地跳一下;正确做法是真实值只当目标,显示值由一个弹簧持续追它。每块到达时 animateTo 以当前显示值与当前速度为新初始条件重建弹簧。

图 1-1 · 真实进度(阶梯)与被弹簧抹平的显示进度。可调弹簧参数,把阻尼调低到 12 会看到过冲再回落,调高到 44 则沉稳无过冲。

无论参数怎么变,连点「手动来一块」都不会看到跳变——因为新目标是从当前 valuevelocity 续上的,这正是 animateTo 相对「取消旧动画再起一条」的根本优势。

2 · 不确定态与落定

总量未知时(服务端还在统计、流式响应没有 Content-Length),进度条无法给出百分比,只能表达「正在进行」:一段 repeat: Infinity 的循环。一旦拿到总量就交接成确定态。

图 2-1 · 循环占位与落定的两段接力。可切换滑块来回与条纹流动两种循环样式。

这与转盘落点是同一个结构——空转是占位,落定才是结果。区别在于转盘的交接要保住速度连续,而进度条的落定重新从 0 填起——循环占位本就没有「当前进度」这个量。

3 · trickle 的诚实假进度

单页应用路由切换、首屏白屏期这些场景没有可读的进度,但用户需要反馈。业界做法是给一条假进度:朝一条渐近线减速逼近、永不到顶,真正完成时再一把 snap 到 100%。

图 3-1 · 朝上限渐近的假进度。可调渐近上限,拉到 95% 再开始会看到爬升明显更慢。

注 · 上限那条红线是 trickle 的灵魂,它故意到不了。若让假进度跑到 100% 却迟迟不结束,反而比卡在 90% 更伤信任。本页用 expoOut 把 0 推向上限:前期快、越接近越慢,天然的渐近形状——expoOut 的值是 1210t1 - 2^{-10t},理论上永远差一点点,正好符合「不知道还剩多少」的语义。

4 · 分段进度是一条时间轴

故事条是 NN 段独立进度的串联。它本质是一条时间轴:整体进度 p[0,N)p \in [0, N),第 p\lfloor p \rfloor 段正在填,段内进度 ppp - \lfloor p \rfloor。于是暂停与继续就是播放器的 pauseplay,跳段就是把游标 seek 到段边界。

图 4-1 · 五段故事条与它的播放控制。上一段、下一段都是 seek 到段边界,不打断播放状态。

「上一段」有个容易忽略的细节:段内进度不足 8% 时跳到上上段边界,否则只跳回本段开头——这是媒体播放器普遍的行为,刚播两秒按上一首是回到本首开头,播到一半按才是真的上一首。

5 · 圆环的两条实现路径

把进度画成圆环有两条互不相干的实现,却吃同一个 010 \to 1conic-gradient 按角度扫出扇形再叠内圆挖空;SVG 的 stroke-dashoffset 把虚线段长设成周长,dashoffset=C(1p)\text{dashoffset} = C(1 - p) 推出实线弧。

图 5-1 · 同一个进度值同时驱动两种圆环画法。拖滑块看两者是否同步,或打开不确定态变成 spinner。

警示 · 两条路径的起始角不同。conic-gradient 从 12 点方向顺时针扫,而 SVG 圆的 0° 在 3 点方向,所以 .rarc 必须额外 rotate(-90deg) 才对得上。开 spinner 时这个 90°-90° 要叠进旋转量里(rotate(deg − 90)),漏掉就会看到两个环差四分之一圈。

const disp = new Animation({
  spring: { from: 0, to: 0, stiffness, damping },
  onUpdate: v => bar.style.width = clamp01(v) * 100 + '%',
});

function onChunk(realProgress) {
  disp.animateTo(realProgress);   // 以当前 value + velocity 续上, 速度连续
}

// 圆环: 同一个 p 喂给两条路径
const C = 2 * Math.PI * r;
arc.style.strokeDasharray = C;
function setProgress(p) {
  const deg = p * 360;
  conic.style.background =
    `conic-gradient(var(--accent) ${deg}deg, var(--accent-soft) ${deg}deg)`;
  arc.style.strokeDashoffset = C * (1 - p);
}

五种形态拆开是五个交互,合起来是同一条主线:能测就平滑追赶、不能测就诚实渐近、多段就交给游标。值怎么算、时间怎么塑形、播放怎么控制,正是本系列从插值播放控制的同一套地基落到 UI 上。