Skip to content

课程 36 - Animation ​

在这节课中你将学习到以下内容:

  • 如何设计动画 API
  • 参考 Web Animation API 实现声明式 Keyframe 与控制器
  • 实现路径、笔迹、形变等动画效果
  • 支持 Lottie 等格式

如何设计动画 API ​

Motion 是完全兼容声明式动画 WAAPI 的,详见:Improvements to Web Animations API。它直接调用浏览器原生的 element.animate(),享受 GPU 加速、独立渲染线程、不阻塞主线程的优势。同时用 JavaScript 轻量实现 WAAPI 缺失的能力:

  • Spring 物理动画(WAAPI 只支持贝塞尔曲线)
  • 独立 transform 属性(单独动画 x, y, scale 而非组合 transform)
  • 可以指定缩放和旋转中心 transformOrigin
  • Timeline 序列控制 sequence(), stagger()
ts
import { animate, stagger } from 'motion';

// 返回动画控制器,可暂停、播放、反向
const controls = animate(
    '.box',
    { x: [0, 100], opacity: [0, 1] }, // keyframes
    { duration: 0.5, delay: stagger(0.1), easing: 'spring(1, 100, 10, 0)' },
);

// 可序列化的控制指令
controls.pause();
controls.play();
controls.reverse();

其中 Keyframes 和 Options 是纯对象,可直接 JSON 化。但运行时状态:animate() 返回的 Animation 对象包含与 DOM 的绑定、当前播放时间、velocity 等运行时状态,无法序列化。

参考 WAAPI 实现 ​

我们可以参考 WAAPI 的 polyfill web-animations-js,结合我们的 ECS 系统实现。

在数据层使用类 WAAPI 的 Keyframes 格式(可序列化)

ts
interface Keyframe {
    offset?: number; // 0-1,对应 WAAPI 的 offset
    [property: string]: any; // x, y, scale, fill, strokeWidth...
    easing?: string; // "ease-out", "spring(1, 100)"
}

interface AnimationOptions {
    duration: number; // ms
    delay?: number;
    iterations?: number | 'infinite';
    direction?: 'normal' | 'reverse' | 'alternate';
    fill?: 'forwards' | 'backwards' | 'both'; // This property specifies how the graph will be displayed when the animation is in a non-running state (e.g. before the animation starts, after it ends).
    easing?: string; // 全局缓动(若 keyframe 未指定)
}

在控制层实现类 WAAPI 的 Animation 控制器。与真正 WAAPI polyfill 的区别:

  • 不要解析 CSS 字符串。WAAPI 支持 { transform: 'translate(100px)' } 这类 CSS 字符串,解析成本高。直接支持 { x: 100 }(类似 Motion 的独立 transform 属性)
  • 内置 ease, ease-in, ease-out, linear,以及 Motion 风格的 spring(mass, stiffness, damping)
  • Composite 模式。参考 WAAPI 的 composite: 'add' | 'replace',支持动画叠加到基础值上(如 Entity 已有位置,动画在其上叠加偏移)
  • Timeline 支持。像 Motion 的 timeline() 或 WAAPI 的 GroupEffect,支持多 Entity 的 stagger 动画

控制器 ​

ts
const animation = api.animate(
    node1,
    [
        { x: 100, fill: 'green' },
        { x: 200, fill: 'red' },
    ],
    {
        duration: 1000,
        direction: 'alternate',
        iterations: 'infinite',
        easing: 'ease-in-out',
    },
);
animation.pause();
animation.play();
animation.finish();
State:—

矩形在 x: 100 ↔ 200 之间往复,填充在 green 与 red 之间插值;可用按钮控制同一 AnimationController。

变量插值 ​

像 x/y/opacity 这种数字很容易插值,对于 fill/stroke 这样的颜色值,需要先用 d3-color 解析出 rgba 各个分量再分别插值。

线性渐变的色标数量相同时,逐个插值色标位置与颜色;数量不同时,使用两端色标位置的并集,并保留表示硬边过渡的重复位置。因此,两端关键帧使用同一渐变时,不均匀的色标间距不会被改成均匀分布。

ECS 中,fill / fillOpacity 和 stroke / strokeOpacity 会同时更新第一个启用的绘制层,保留其他层及其启用状态。如果没有启用的层,颜色动画会新增一层;仅有透明度动画时不会创建新层。

ts
function interpolateValue(from: unknown, to: unknown, t: number) {
    if (isFiniteNumber(from) && isFiniteNumber(to)) {
        return interpolateNumber(from, to, t);
    }
    const fromColor = parseColor(from);
    const toColor = parseColor(to);
    if (fromColor && toColor) {
        return colorToRgbaString({
            r: interpolateNumber(fromColor.r, toColor.r, t),
            g: interpolateNumber(fromColor.g, toColor.g, t),
            b: interpolateNumber(fromColor.b, toColor.b, t),
            a: interpolateNumber(fromColor.a, toColor.a, t),
        });
    }
    return t < 1 ? from : to;
}

缓动函数 ​

除了常规的缓动函数,我们还可以支持 spring

ts
function evaluateEasing(easing: string, t: number) {
    const p = clamp01(t);
    const bezier = EASING_FUNCTION[easing as keyof typeof EASING_FUNCTION];
    if (bezier) {
        return clamp01(bezier(p));
    }
    if (easing.startsWith('spring(')) {
        return evaluateSpringEasing(p, easing);
    }
    return p;
}
缓动
State:—

切换缓动会重新创建动画(新 easing 在构造时生效)。矩形仍在 x: 100 ↔ 200 往复,便于对比不同曲线的加减速感。

变换中心 ​

State:—

transformOrigin: { x: 50, y: 50 }
scale: 0.5 ↔ 1.2rotation: Math.PI / 4 ↔ -Math.PI / 4

特殊的动画效果 ​

路径动画 ​

Moving graphics along a path is a common requirement, and is accomplished in CSS via MotionPath.

css
#motion-demo {
    animation: move 3000ms infinite alternate ease-in-out;
    offset-path: path('M20,20 C20,100 200,0 200,100');
}
@keyframes move {
    0% {
        offset-distance: 0%;
    }
    100% {
        offset-distance: 100%;
    }
}

笔迹动画 ​

需要支持获取路径的长度:

ts
const length = api.getTotalLength(path);
api.animate(
    path,
    [{ strokeDasharray: [0, length] }, { strokeDasharray: [length, 0] }],
    {
        duration: 3500,
    },
);
State:—

虚线偏移 ​

drawio 中通过动画表示连接线的方向:

source: https://www.drawio.com/doc/faq/connector-animate

Export your diagram to a SVG file to include the connector animation when you publish it in a web page or on a content platform that supports SVG images.

ts
api.animate(node, [{ strokeDashoffset: -20 }, { strokeDashoffset: 0 }], {
    duration: 500,
    iterations: Infinity,
});
State:—

形变效果 ​

在很多 SVG 相关的库中都能看到形变动画的例子,例如:

  • Paper.js
  • Kute.js 提供了 Morph 和 CubicMorph 两个组件
  • GreenSocks 提供的 MorphSVGPlugin 插件甚至能在 Canvas 中渲染
  • vectalign

以上部分库会要求变换前后的路径定义包含相同的分段,不然无法进行插值。

参考 Kute.js 中的 CubicMorph,首先将 Path 定义中的各个部分转成三阶贝塞尔曲线表示,然后利用三阶贝塞尔曲线易于分割的特性,将变换前后的路径规范到相同数目的分段,最后对各个分段中的控制点进行插值实现动画效果

只有两端路径都包含单个闭合轮廓时,才对齐绕向和起点。开放路径保留端点顺序,避免平移曲线时交换端点,导致动画中途曲线塌缩。

ts
function mergePaths(
    left: { absolutePath: AbsoluteArray; curve: CurveArray | null },
    right: { absolutePath: AbsoluteArray; curve: CurveArray | null },
): [CurveArray, CurveArray, (b: CurveArray) => CurveArray] {
    let curve1 = left.curve;
    let curve2 = right.curve;
    if (!curve1 || curve1.length === 0) {
        // convert to curves to do morphing & picking later
        // @see http://thednp.github.io/kute.js/svgCubicMorph.html
        curve1 = path2Curve(left.absolutePath, false) as CurveArray;
        left.curve = curve1;
    }
    if (!curve2 || curve2.length === 0) {
        curve2 = path2Curve(right.absolutePath, false) as CurveArray;
        right.curve = curve2;
    }

    let curves = [curve1, curve2];
    if (curve1.length !== curve2.length) {
        curves = equalizeSegments(curve1, curve2);
    }

    const closed = [left.absolutePath, right.absolutePath].every(
        (path) =>
            path[path.length - 1]?.[0] === 'Z' &&
            path.filter(([command]) => command === 'M').length === 1,
    );
    const curve0 =
        closed && getDrawDirection(curves[0]) !== getDrawDirection(curves[1])
            ? reverseCurve(curves[0])
            : (clonePath(curves[0]) as CurveArray);

    return [
        curve0,
        closed ? (getRotatedCurve(curves[1], curve0) as CurveArray) : curves[1],
        (pathArray: CurveArray) => pathArray,
    ];
}
State:—

Lottie ​

使用方法 ​

我们实现了一个插件,将 lottie json 转换成图形和 keyframes,以下是一些实现要点:

  • 支持 Shape Layer 中定义的以下元素:
  • lottie 中的 anchorX/anchorY 表示缩放和旋转中心,相对于图形的包围盒左上角,在映射到 transformOrigin 时需要注意
  • 将多组动画轨道合并成一组 keyframes,补全缺失的属性
ts
import { loadAnimation } from '@infinite-canvas-tutorial/lottie';

const response = await fetch('/bouncy_ball.json');
const animation = loadAnimation(await response.json(), {
    loop: true,
    autoplay: true,
    onDiagnostic: ({ code, path, message }) =>
        console.info(code, path, message),
});

await api.edit(() => animation.render(api), { capture: 'NEVER' });

// On component unmount (also automatically cleaned up on canvas destruction):
// await animation.destroy();

下面是官方示例的运行效果:Bouncy Ball

State:—

兼容性范围 ​

这是将 Lottie 转换成 ECS 节点和关键帧的导入器,目前并非完整的 Lottie 播放器。下表描述的是实际渲染能力,不能把“解析了字段”等同于“支持了效果”。

特性当前状态
矩形、椭圆、路径、分组;基础填充和描边支持基础静态形状、路径 morph 和 2D 位置/旋转/缩放动画;原始图形的尺寸/圆角动画仍有限制
PolyStar星形和多边形、正反路径方向;点数、位置、旋转、内外半径和圆角的数值关键帧,支持时间缓动及保持关键帧
Repeater复制前面的形状/分组;支持数量、偏移、锚点、位移、缩放、旋转、起止透明度和堆叠顺序,以及数值关键帧动画
Solid、Null、父子层级、Precomp基础支持;图层 in/out 可见性、时间拉伸和重映射未完整实现
渐变、多重填充/描边基础渐变可用;渐变几何动画、径向高光、多重绘制及运算顺序有限制
Trim Paths用描边虚线近似;已修正完整/空路径、端点排序和偏移。填充裁切、多路径模式、与原有虚线样式叠加、动画中的零长度圆头描边、路径方向及 modifier 顺序仍有限制
空间贝塞尔运动、Skew未完整实现,导入时报告诊断
Merge Paths、Round Corners 等 modifier尚未实现渲染
Image部分支持;未处理资源目录和预加载
Text、Mask、Track Matte、Effects、Blend Mode、3D尚未实现或无法可靠还原;ECS 支持某项能力不代表 Lottie 已完成映射
表达式导入时烘焙;默认使用带有限形状图层环境的 lottie-web ExpressionManager,不等同于完整 AE 环境

inspectLottie(data) 可在不修改 JSON、不执行表达式的情况下检查已知缺口。animation.getDiagnostics() 和 onDiagnostic 返回 code、severity(partial / unsupported)、JSON Pointer path 和说明。诊断不会中止导入;没有诊断也不代表完全兼容 AE。示例中的 Compatibility notes 会展示具体缺口。

播放器使用统一合成时间轴。goTo(value, true) 按相对于合成起点的帧定位,默认单位为秒,且保留播放/暂停状态。stop() 暂停并回到第 0 帧;setSpeed() 接受正数,setDirection(-1) 保留速度并从当前位置倒放。playSegments([start, end]) 遵守两个端点,逆序端点表示倒放;loop 数字表示额外重复次数。底层 getAnimations() 返回的 ECS 控制器由播放器采样,请通过播放器控制时间。

同一实例重复 render(api) 不重复创建动画,不允许挂载到另一个画布;destroy() 可重复调用,取消时间轴并在安全编辑阶段移除导入的节点树。切换示例或卸载组件还需取消未完成的 fetch 和排队的编辑。固定帧浏览器测试使用锁定版本的 lottie-web 作为参考,覆盖位置动画、单路径描边 Trim Paths,以及整数帧和小数帧下的 PolyStar 几何和 Repeater 分组,不代表整个格式已通过一致性验证。

星形与多边形 ​

PolyStar(ty: "sr")生成闭合贝塞尔路径:sy: 1 为星形,sy: 2 为多边形。点数 pt、位置 p、旋转 r、半径 or / ir 和圆角 os / is 可以独立动画,内半径和内圆角仅用于星形。d: 3 反转路径方向。小数点数与 lottie-web 5.13 一样向下取整,并非不同点数之间的连续形变。

下面的示例同时动画两种图形。可以暂停、倒放,或拖动 Seek frame 查看中间状态。实现先插值参数再生成路径,因此旋转时半径不收缩,点数也在对应时刻切换。

State: —

Star (blue) · Polygon (red)

PolyStar 几何动画由插件控制器随合成时间轴采样。目前普通 ECS 关键帧序列化及动画编辑器还不能保留或编辑这些参数轨道,重新加载时需保留原始 Lottie JSON。位置的空间切线、modifier 组合(包括变化几何上的 Trim Paths)、表达式等仍受上述兼容性范围限制。曲线描边还会受到 ECS 渲染器已有的细分接缝透明度问题影响,因此这类描边的参考测试验证轮廓和颜色,不验证 alpha 完全一致。

Repeater ​

Repeater(ty: "rp")复制当前形状分组中位于它之前的操作符。将填充/描边放在被复制的分组内部,即可分别绘制每个副本。支持嵌套形状分组,以及带动画的 PolyStar 几何。嵌套或串联多个 Repeater 目前按递归方式复制,并产生 shape.repeater.nested 诊断;复合绘制顺序和保留的原始副本可能与 lottie-web 不同。

数量 c 向上取整,零会隐藏所有副本。偏移 o 支持小数和负数。变换 tr 包括锚点 a、位移 p、非等比缩放 s、旋转 r 和起止透明度 so / eo。合成模式 m 控制副本堆叠顺序。数值轨道支持时间缓动和保持关键帧。变换遵循 lottie-web 5.13,包括负整数偏移穿过恒等变换时的行为。

State: —

Animated copies · Offset · Scale · Rotation · Opacity

导入时根据数量轨道的上界(包含缓动超调)建立副本池,播放和跳帧复用节点。较大的数量及嵌套 Repeater 会成倍增加节点数。与 PolyStar 一样,目前普通 ECS 关键帧序列化和动画编辑器不能保留这些参数轨道,需要保留原始 Lottie JSON。

位于 Repeater 之后的填充/描边仅部分支持,并会产生 shape.repeater.paint-scope 诊断:逐副本继承样式不能还原 lottie-web 共享复合路径的填充范围和透明度。modifier 顺序、skew、空间运动和图层时间仍受兼容性矩阵限制。无法求逆的缩放(例如负偏移搭配零缩放)会隐藏受影响副本,避免提交无效变换。

表达式 ​

Expressions 描述了 After Effects 导出到 Bodymovin JSON 时,如何在属性上挂一段 JavaScript(属性对象上的字符串字段 x)。本教程里的 Lottie 插件不会在每一帧实时执行这些脚本,而是在 loadAnimation / parse 时按合成时间范围把表达式烘焙成普通关键帧,再由统一时间轴采样 ECS 关键帧。支持范围取决于有限的表达式环境,不能保证与 AE 一致。

下面是一段路径属性上的表达式示例(运行时仍表现为普通 shape 关键帧动画):

json
{
    "ty": "sh",
    "ks": {
        "a": 0,
        "k": {
            "i": [],
            "o": [],
            "v": []
        },
        "x": "var group = thisLayer.content(\"Quadratic Points\");\nvar num_points = 3;\nvar points = [];\nvar ip = [];\nvar op = [];\nfor ( var i = 0; i < num_points; i++ )\n{\n    var pos = group.content(\"p\" + i).position;\n    points.push(pos);\n    ip.push(pos);\n    op.push(pos);\n}\nvar $bm_rt = {\n    v: points,\n    i: ip,\n    o: op\n};\n"
    }
}

下面的示例来自:Beziers in Lottie

State:—

Text layer ​

尚未实现文本图层和文本动画器的导入。

Clipping mask ​

现有解析不能可靠还原 mask 模式、反相、动画及 Track Matte。

clipping-masks

Layer effects ​

尚未实现从 Lottie effects 到 ECS 的映射。

Layer Effects

Rive ​

Rive vs Lottie

source: https://rive.app/blog/rive-as-a-lottie-alternative

Manim ​

https://github.com/3b1b/manim

动画编辑器 ​

参考 lottielab、Jitter 等产品,我们在 Web Components 层实现了一套轻量动画编辑器:右侧 Animation 面板负责编辑当前选中元素的 keyframes,底部 Timeline 面板展示整场景的时间轴并驱动当前画布的播放头。两者共用同一份可序列化的 Keyframes 数据与 AppState 里的场景时钟。

source: https://jitter.video/

整体布局 ​

Taskbar 提供两个独立开关:

  • SHOW_ANIMATION_PANEL → ic-spectrum-animation-panel(右侧,与 Properties 面板并列)
  • SHOW_TIMELINE_PANEL → ic-spectrum-timeline-panel(底部 dock,横跨画布宽度)

典型工作流:

  1. 选中单个元素,在 Animation 面板添加动画或编辑属性轨道。
  2. 打开 Timeline,查看场景中所有带动画的图层及其时间范围。
  3. 拖动播放头或点击播放,预览整场景在同一时刻的合成效果。
  4. 在 Timeline 选中某条轨道会同步选中对应元素,右侧 Animation 面板随即展示该元素的 keyframes。

场景时钟与编辑模式 ​

Timeline 驱动 AppState 中当前画布的播放头,与每个 AnimationController 解耦。共享同一个 ECS world 的多个画布,各自维护独立的播放头、播放状态和场景时长:

字段含义
animationEditing为 true 时进入确定性 scrub 模式:当前画布的所有控制器按同一 animationCurrentTime 采样,而非各自 free-run
animationCurrentTime当前画布的播放头位置(毫秒)
animationPlaying是否自动推进播放头
animationLoop到达场景末尾是否回到 0

AnimationSystem 在 animationEditing === true 时只采样属于当前画布的实体。暂停状态下把控制器固定在 animationCurrentTime;播放状态下用 performance.now() 的 delta 推进播放头。恢复播放时不会计入暂停期间经过的时间,场景时长也只由当前画布的轨道决定。

离开编辑模式后,被编辑器暂停的控制器从当前位置继续 autoplay。调用方在进入编辑模式前已经暂停的控制器仍保持暂停,已取消的控制器不会自动重启。

打开 Timeline 时会自动 setAnimationEditing(true),保证 scrub 与预览行为一致。

Timeline 面板设计 ​

底部为 ic-spectrum-timeline-panel:拖动播放头 scrub、点击 ▶ 预览整场景; 点击轨道可选中元素并在右侧 Animation 面板编辑 keyframes。Hero 淡入 2s,Orbit 延迟 0.5s 后位移 1.5s。

轨道数据 ​

Timeline 不直接读节点 JSON,而是通过 api.getAnimatedTracks() 聚合:

ts
interface Track {
    id: string; // 节点 id
    name: string; // 图层名,缺省为 id
    properties: string[]; // 如 ['opacity', 'x']
    delay: number; // ms
    duration: number; // 有效动画时长(total − delay)
    totalDuration: number;
}

场景总时长 = 所有 track 的 totalDuration 最大值。条形块的 left / width 分别由 delay * PX_PER_MS 与 duration * PX_PER_MS 换算。

交互 ​

操作行为
点击轨道标签layersSelected = [track.id],高亮轨道,驱动 Animation 面板
拖动 lane / rulerscrub 播放头,画布实时预览该帧
Play / PausetoggleAnimationPlaying(),在编辑模式下推进当前画布时钟
LoopsetAnimationLoop(),到场景末尾是否回绕

Animation 面板(与 Timeline 配合) ​

组件:packages/webcomponents/src/spectrum/animation-panel.ts。

  • 仅单选元素时可编辑;多选或未选显示占位提示。
  • 全局选项:duration、delay、默认 easing、iterations(Loop 开关)。
  • 按属性轨道分组展示 keyframes:每行包含 offset(0–1)、属性值、easing、删除按钮。
  • Add keyframe at playhead:读取当前 animationCurrentTime,换算为 normalized offset,并调用 controller.getCurrentValues() 采样当前属性值——因此应先打开 Timeline 并 scrub 到目标时刻再插入 keyframe。
  • fill / stroke 使用 popover + ic-spectrum-color-picker;其余数值属性用 sp-number-field。
  • 面板宽高可拖拽调整,尺寸持久化到 localStorage(与 Properties 面板相同的 handle 交互)。

所有编辑经 setNodeAnimation / updateNodeAnimationKeyframe 等 API 写入,参与 undo 历史与文档序列化。

与 Lottie 式编辑器的差异 ​

当前实现刻意保持简单,与 lottielab 等完整 DCC 相比:

  • Timeline 以节点为轨道,而非 Lottie 的 layer + property 多轨展开;属性名显示在标签后缀(Rect · opacity, x)。
  • 暂不支持在 Timeline 上直接拖动 keyframe 或条形块改 timing;timing 在 Animation 面板通过 offset 编辑。
  • 表达式由插件在导入时有限烘焙;Text layer、Clipping mask 尚未可靠支持,也不能由该编辑器直接创作。

后续可在此基础上扩展:property 子轨、keyframe 菱形标记、条形块 edge 拖拽改 delay/duration 等。

外部参考 ​

扩展阅读 ​

Released under the MIT License.