小米

小米

还没有添加图片

智谱

智谱

还没有添加图片

返回看板
×
图标动效的阻尼调整

前端 · 设计 · 动效

图标动效的阻尼调整

一个图标的回弹,单独看很舒服。放进 95 个里,它变成了默认质感。先看在线画廊的实拍,再用一个只改阻尼比的对照实验,弄清「过冲」到底由哪个数字决定,以及一条真正拦得住它的约束该长什么样。

单独看,一个铃铛被点一下,铃舌甩出去再荡回来,很好。把它旁边再放 87 个同样会荡的东西,一起点,就不对了——不是某一个图标难看,是回弹本身变成了默认质感。单看每个都合格,合起来像在喊。

这件事发生在我自己的 apple-motion-icons 仓库里。它收集了 95 个对象(13 个通用图标、14 个学习/录屏/导航图标、60 个图库图标,以及 8 个循环加载动画),每个对象都有一张自己的动作表和一个弹簧。这一篇不介绍怎么画图标,只讲一件事:弹性的质感是被一个数字控制的,而那条控制线应该由代码来守,而不是靠手感。

先说明本文的三种对象,免得混在一起:

  • 在线画廊 https://icon.yhazrin.xyz/motion/ 和它的源码仓库,是我自己的作品,MIT 许可,文章里的截图是实拍。
  • 后面出现的连续变形示例,是同一个仓库里的一个文件,跑的是同一套生产渲染器。
  • 那个只改阻尼比的对照实验,是本文新写的工具,不是图标库的一部分,也不替代它。

一屏画廊:动作表长什么样

在线画廊的一屏:每个对象都带着自己的动作表

2026-10-19 从 https://icon.yhazrin.xyz/motion/ 实拍的运行界面。卡片不是示意图:点动作名就会播放,姿态之间可以切换,「展开进度」这类滑杆可以直接把对象停在中间。

本文独立实验本文实拍:icon.yhazrin.xyz/motion/ · 2026-10-19

截图里每张卡片下面的那串词,就是这个对象的动作表:今天有「天光舒展 / 迎接白昼 / 迎接夜晚 / 太阳变月亮」四种;词库有「书封带出内页 / 沿书脊展开 / 内页收回书封」。右边那条展开进度滑杆更关键——它不是播放按钮,而是把对象直接停在 0% 到 100% 之间的任意一帧。

把 95 张卡片排在一起,问题就藏不住了:每个动作都在动,动法却各不相同。原问题是这个集合没有统一的弹性纪律,而不是某几个图标没调好。所以接下来的修法,也不是逐个去调那 95 份配方。

过冲不是一个形容词,是一个数字

弹簧系统用两个量描述:刚度 k 和阻尼 c。SwiftUI 不直接给这两个,它给响应时间 response 和阻尼比 ζ,换算关系是:

text
stiffness = (2π / response)²
damping   = 2 ζ √stiffness        (质量取 1)

ζ 是本文唯一的主角。它小于 1,系统会冲过目标再荡回来,过冲量;等于 1,刚好停在目标不越界,不多不少地叫临界阻尼。大于 1,爬升慢一点但绝不越界。

对一个阶跃输入,过冲大致是:ζ 1 → 0%,.86 → ≈ 0.6%,.72 → ≈ 4%,.3 → ≈ 37%。

问题的根就在这张表里。我引擎最初的预设是照着卡通的强调感调的:pop 用 ζ .39(约 26% 过冲),rebound ζ .34,swing ζ .19,shake ζ .25。.19 是什么概念——来回荡将近十次才停。这些值单看都有理由,用在一个图标上也没人会有意见。可它们是默认预设,87 个对象的动作表里谁都能调,于是过冲变成了这个库的出厂纹理。廉价感来自默认值,不来自某一次具体的调参。

先自己量一遍

下面的实验不看图标,只看一个方块。它求解真实的弹簧-质量-阻尼系统,用的是上面那套换算式;它不调用任何 CSS 缓动函数,所以你看到的每一条曲线都是数值积分出来的。响应时间固定为 0.40s(与库里 bouncy 相同),变的只有阻尼比。

只改阻尼比,让它停在你面前
查看实际运行的源码

实际运行的 CSS

css
/* Experiment geometry and semantic markers only. Controls come from journal-controls/v1. */
.lab{max-width:720px;margin:0 auto}.slider{flex:1 1 220px;display:block;font-size:12px;color:var(--jc-muted)}.slider span{display:flex;justify-content:space-between;margin-bottom:5px}.slider b{font-variant-numeric:tabular-nums;color:var(--jc-ink);font-size:13px}.check{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--jc-muted)}.track{position:relative;height:56px}.target{position:absolute;left:78%;top:-6px;bottom:-6px;width:2px;background:var(--jc-ink);opacity:.85;border-radius:1px}.target::after{content:"目标";position:absolute;left:5px;top:-2px;font-size:10px;color:var(--jc-muted);white-space:nowrap}.peak{position:absolute;top:-6px;bottom:-6px;width:2px;background:var(--jc-warn);border-radius:1px;opacity:0;transition:opacity .2s ease}.peak.on{opacity:1}.peak::after{content:"过冲";position:absolute;left:5px;bottom:-2px;font-size:10px;color:var(--jc-warn);white-space:nowrap}.block{position:absolute;top:10px;left:0;width:44px;height:36px;border-radius:9px;background:var(--jc-accent)}@media(prefers-reduced-motion:reduce){.peak{transition:none}}

实际运行的 JavaScript

js
/* 本文实验:阻尼比对照。
 * 求解一个真实的质量-弹簧-阻尼系统,不使用 CSS 缓动。
 * 换算与被讨论的图标库一致:k = (2π / response)²,c = 2ζ√k,质量为 1。 */
(function () {
  'use strict';

  var RESPONSE = 0.40;          // 秒;与库中 bouncy 的 response 相同
  var REST = 0.005;             // 静止阈值:行程的 0.5%
  var STEP = 1 / 240;           // 固定子步长,保证高阻尼下也稳定
  var MAX_TIME = 6;             // 秒;超时判定为已经停住

  var frameId = 0;
  var preference = matchMedia('(prefers-reduced-motion: reduce)');
  var zeta = 0.72;
  var stiffness = Math.pow(2 * Math.PI / RESPONSE, 2);

  var el = {
    zeta: document.getElementById('zeta'),
    zetaValue: document.getElementById('zeta-value'),
    play: document.getElementById('play'),
    reset: document.getElementById('reset'),
    reduce: document.getElementById('reduce'),
    presets: document.getElementById('presets'),
    block: document.getElementById('block'),
    peak: document.getElementById('peak'),
    over: document.getElementById('over'),
    settle: document.getElementById('settle'),
    crossings: document.getElementById('crossings'),
    state: document.getElementById('state')
  };

  function reduced() { return el.reduce.checked || preference.matches; }
  function stop() { cancelAnimationFrame(frameId); frameId = 0; }

  function metrics() {
    var track = el.block.parentElement;
    var span = track.clientWidth - el.block.offsetWidth;
    return { targetPx: span * 0.78 };
  }

  function paint(value, peakValue, stats) {
    var m = metrics();
    el.block.style.transform = 'translateX(' + (value * m.targetPx).toFixed(2) + 'px)';
    if (peakValue > 1.001) {
      el.peak.classList.add('on');
      el.peak.style.left = (peakValue * m.targetPx).toFixed(2) + 'px';
    } else {
      el.peak.classList.remove('on');
    }
    el.over.textContent = stats.over.toFixed(1) + '%';
    el.settle.textContent = stats.settle === null ? '—' : stats.settle.toFixed(2) + 's';
    el.crossings.textContent = String(stats.crossings);
  }

  function run() {
    stop();
    var damping = 2 * zeta * Math.sqrt(stiffness);

    if (reduced()) {
      // 减少动态:直接落位,不制造往返;但仍报告这条曲线本该有的量。
      el.block.style.transform = 'translateX(' + metrics().targetPx.toFixed(2) + 'px)';
      el.peak.classList.remove('on');
      el.over.textContent = '0%(已按减少动态落位)';
      el.settle.textContent = '—';
      el.crossings.textContent = '0';
      el.state.textContent = 'ζ ' + zeta.toFixed(2) + ' · 减少动态:直接到达目标';
      return;
    }

    var x = -1, v = 0, t = 0;         // 相对目标的位移与速度;起点离目标一个单位
    var peak = 0, crossings = 0, settled = null, lastSign = -1;
    var previous = performance.now();

    function frame(now) {
      var dt = Math.min((now - previous) / 1000, 0.064);
      previous = now;
      var remaining = dt;
      while (remaining > 0) {
        var h = Math.min(STEP, remaining);
        remaining -= h;
        var a = -stiffness * x - damping * v;
        v += a * h;
        x += v * h;
        t += h;
        if (x > peak) peak = x;
        if (settled === null && Math.abs(x) <= REST && Math.abs(v) <= REST * 4) {
          settled = t;
        }
        if (settled === null && t > 0.02) {
          var sign = x > 0 ? 1 : x < 0 ? -1 : 0;
          if (sign !== 0 && sign !== lastSign) { crossings++; lastSign = sign; }
        }
      }

      var value = 1 + x;
      paint(value, 1 + peak, {
        over: Math.max(0, peak) * 100,
        settle: settled,
        crossings: Math.max(0, crossings - 1)
      });
      el.state.textContent = 'ζ ' + zeta.toFixed(2) + ' · 播放中…';

      if ((settled === null && t < MAX_TIME) || (settled !== null && Math.abs(x) > REST)) {
        frameId = requestAnimationFrame(frame);
      } else {
        el.block.style.transform = 'translateX(' + metrics().targetPx.toFixed(2) + 'px)';
        el.state.textContent = 'ζ ' + zeta.toFixed(2) + ' · 已静止';
      }
    }
    frameId = requestAnimationFrame(frame);
  }

  function reset() {
    stop();
    el.block.style.transform = 'translateX(0px)';
    el.peak.classList.remove('on');
    el.over.textContent = '—';
    el.settle.textContent = '—';
    el.crossings.textContent = '—';
    el.state.textContent = 'ζ ' + zeta.toFixed(2) + ' · 等待播放';
  }

  el.zeta.addEventListener('input', function () {
    zeta = parseFloat(el.zeta.value);
    el.zetaValue.textContent = zeta.toFixed(2);
    el.state.textContent = 'ζ ' + zeta.toFixed(2) + ' · 等待播放';
    reset();
  });
  el.play.addEventListener('click', run);
  el.reset.addEventListener('click', reset);
  el.presets.addEventListener('click', function (event) {
    var button = event.target.closest('button[data-zeta]');
    if (!button) return;
    zeta = parseFloat(button.dataset.zeta);
    el.zeta.value = String(zeta);
    el.zetaValue.textContent = zeta.toFixed(2);
    reset();
    run();
  });

  el.reduce.addEventListener('change', function () { if (reduced()) run(); else reset(); });
  preference.addEventListener('change', function () { if (reduced()) run(); else reset(); });
  window.addEventListener('pagehide', stop);
  reset();
})();

本文新写的对照实验。它求解真实的弹簧-质量-阻尼系统,k = (2π/response)²、c = 2ζ√k、质量为 1,与被讨论的图标库同一套换算;不用任何 CSS 缓动函数。配色与字体属于实验本身。

本文独立实验本文实验:阻尼比对照

按 旧 pop 0.39 播放一次,再按 smooth 1.00 播放一次。读数里的过冲和来回次数是你要看的两列——前者是幅度,后者是次数;很多「活泼」的错觉其实只发生在前者。

实验给出的几组数字:

ζ 过冲 稳定时间 来回次数
1.00 0.0% 0.59s 0
0.86 0.2% 0.40s 0
0.72 3.2% 0.53s 1
0.39 25.9% 0.85s 3
0.30 36.8% 1.22s 5

仓库文档里写的是 ζ .72 → ≈ 4%、ζ .39 → ≈ 26%、ζ .3 → ≈ 37%。两边对得上,说明这个模型和图标库实际用的是同一套参数。(数字略有出入,是因为仓库文档给的是该阻尼比下的概数,实验固定了 response = 0.40s 后实测更准。)

顺带能看出另一件事:从 0.72 降到 0.39,过冲从 3.2% 涨到 25.9%,稳定时间只从 0.53s 涨到 0.85s。多付出的那 0.32 秒,换来的是八倍的过冲。所以「让它更有弹性」几乎从来不是免费的动作。

阻尼比 0.39 静止后的读数

文章里嵌的是同一个实验;这张静态图给不方便动交互的读者(例如系统开启了减少动态)留一份同样的结果。数字来自实验真实运行,不是估算。

本文独立实验本文实验实拍 · 2026-10-19

真正的修法:让代码守住下限

知道了数字,就能定规矩。我最后没有去逐个改那 95 份配方,也没有重写动画引擎——那样要重调的正是最不需要重调的东西。改的是一个数字的取值范围。

规矩写在仓库的第七层运行时 assets/motion-icons-signature.js 里,它明确说明自己「不动引擎的字节」就做三件事。跟阻尼相关的部分是这样:

  1. 命名预设全部重新对齐。新增 smooth(ζ 1)、snappy(.86)、bouncy(.72)、quick、glide,对应 SwiftUI 的 .smooth / .snappy / .bouncy;旧的 pop、rebound、swing、shake 只改 ζ,不改刚度。

    SwiftUI preset response ζ
    .smooth smooth 0.42 s 1.0
    .snappy snappy 0.30 s 0.86
    .bouncy bouncy 0.40 s 0.72
    —(压缩进入) quick 0.16 s 1.0
    —(长行程) glide 0.55 s 1.0

    重新标定后:snap .82、pop .72、impact .86、rebound .72、settle .88、swing .30、shake .38。

  2. 装一个下限治理器。每个动作的 run 和 drive 拿到的上下文里都有一个 to(),它会检查传进来的弹簧对象:只要 ζ 低于 0.7,就地抬到 0.7,同时把这次请求记进 MotionIcon.apple.audit = {floor, clamped, subjects}。命名预设原样放行。逐帧检查器的页眉会打印「收敛 N 次低阻尼请求」。

    这一步是关键。它把一条口头约定变成了一件可观测的事实:有多少配方想违规、违规了多少次,都写在运行时的计数器里,不需要人去数配方。

  3. 留两个有意的例外。swing(ζ .30)用于自由摆——铃舌、沙漏——shake(ζ .38)只用于报错。规矩里写着它们故意留在下限之下,理由是钟摆本来就该荡,SF Symbols 的 wiggle 也给同样的自由度。

例外必须写进契约,否则半年后没人记得它是例外,只记得下限是 0.7。

下限管不到的部分

治理器只能拦数字。真正决定一组动效好不好看的,是它拦不住的那几条,所以它们只写成了规则:

  • 一个动作只用一次强调。 先选准那个时刻——沙落进瓶口、令牌被接收——在那里用 bouncy,其余全用 smooth 或 snappy。
  • 不透明度、颜色、描边、姿态变形永远不用弹性的弹簧。
  • 活泼应该来自因果和连续性:手停下时物体有惯性,沙子还在流,工作从一个 agent 交到另一个手上——而不是来自多晃两下。

这三条代码一条也验不了。但它们才是那条下限的意义所在:0.7 解决的是「别失控」,不是「别平庸」。 一个守住了下限、却每个动作都用 bouncy 的界面,会精确地落在最难看的那个位置上——不越界,但处处在强调。

最后一条同样属于契约:减少动态时给正确的终态,而不是停在中间。 上面那个实验里勾选「减少动态」,方块会直接落位、不制造往返,但读数仍然如实报告这条曲线本该有的过冲。

反过来:有些时候该动的是几何

阻尼管的是「弹多少」。它管不了另一种失控——过渡中途换了对象。

仓库里那份连续变形的规范记了一个很具体的失败。Add 图标展开成加号再变成对勾,旧公式把最后一个端点的 Y 和曲线的出口 Y 分开插值,结果在 50% 处,曲线从 (48,45) 水平出去,下一段线却奔向 (52,31):两个向量的叉积是 -42,接缝处起了折角。

改法是让一整段圆角肘部由同一个参数 t 生成:

js
const x = 60 - 30*t;
const y = 32 - 2*t;
const r = 6*t;
const d = `M${x} ${60-16*t}`
  + `L${x} ${y+r}`
  + `Q${x} ${y} ${x+r} ${y}`
  + `L${60-16*t} ${y}`;

第一条线和圆角垂直进,圆角和最后一条线水平出,切向叉积恒为零,轮廓不用改。

另一个方向:让几何自己接上,而不是靠不透明度交接

2026-10-19 实拍仓库里的 examples/continuous-morph.html。它跑的是生产渲染器,不是另画的示意图;截图静止在四个中间进度上,正是最容易被淡入淡出卖的地方。

本文独立实验本文实拍:apple-motion-icons 仓库 examples/continuous-morph.html · 2026-10-19

上面 Add 的四帧中间进度,形状还是同一条线在走,没有中途换成另一张完成的图。下面 Library 相反:封套保持平面和圆角半径,书页从背后转出来,装订移进书沟而不是淡出,书签沿着封套形状收回。规范里那句话说得直接——对象要一直在场,故事由几何讲,不由不透明度讲。

这两件事放在一起才是完整的约束:该弹的少弹,不该换的别换。 弹簧参数管住前者,几何连续性管住后者,而两者都需要写成能被代码检查或被人名正言顺豁免的条款,而不是留在感觉里。

在线画廊首屏截图,非第三方作品把 95 个对象挨个点一遍在线画廊:按用途分组、可搜索(按 /),每个动作都能播放,尺寸可在 16–96 px 之间调,明暗两套外观,可一键下载复核用的 ZIP。查看资料 ↗

卡片预览用的是同一个站点的截图;源码在 GitHub 上公开,两边是同一套配方。

画廊里可以按 / 搜索,按用途分组,每个动作都能单独播放,尺寸在 16–96 px 之间调,明暗两套外观都有。想直接看配方,仓库在 GitHub 上是公开的,README、构造规范和逐个对象的词汇表都在里面。

显示

外观
字体