SMIL动画的时间规划图:一种可视化同步方法
<img>标签无需JavaScript,就能让SVG中的所有元素实现完整动画。我们都知道,网页元素默认都是矩形,但很多动画效果会用<div>模拟圆形。可一旦你用过真正的<circle>标签就会明白,它的优势远不止于此。SVG赋予的原生圆形,适配场景比靠HTML/CSS模拟的<div>要广泛得多。而<img>标签有个严格规则:绝不允许嵌入.html内容。
<img>标签并不像它的名字那样“静态”。虽然用<img>加载SVG时,内嵌的JavaScript无法运行,但CSS动画能完美生效。许多SVG属性都有对应的CSS属性,自2024年起,主流浏览器也已全面支持几何属性。不过像viewBox这类你可能想用来做动画的属性,目前还没有对应的CSS写法。
除了JavaScript和CSS,还有另一种SVG动画实现方式:同步多媒体集成语言(SMIL)。尽管它有些特性不太常规,但依然值得学习。和CSS动画一样,SMIL动画在<img>标签中也能生效,而且完全不需要JavaScript,就能让SVG中的所有元素动起来。
如果你从未听说过SMIL,或是需要温故知新,可以看看Andy Clarke那篇标题恰如其分的文章。接下来我们会探讨如何规划动画,让SMIL代码更易于管理。
痛点:代码臃肿
SMIL有个问题:代码很容易变得冗长。CSS和JavaScript可以在单个关键帧里列出多个属性,还能轻松复用动画,但SMIL的每个标签一次只能作用于一个元素的一个属性。虽然一个属性可以通过一组值实现动画,但本质上还是“一个标签、一个元素、一个属性”的模式。要实现颜色和透明度变化的最简代码如下:
<animate
attributeName="fill"
to="someOtherColor"
dur="someDuration"
/>
<animate
attributeName="opacity"
to="someOtherValue"
dur="someDuration"
/>
这样看起来还好,但如果动画涉及多个元素,这段代码就得重复多次。很快,SMIL动画的代码量就会超过对应的CSS版本。
为了简化新动画的创建流程,我们可以先列出所有需要动效的元素和属性,再为每个标签创建一套清晰易懂的ID命名规则。
用时间轴规划动画
我习惯用时间轴来规划动画。时间轴本质上就是一条线段——有人喜欢横向绘制,有人偏爱纵向。这是个形容动画的绝佳类比:线段可以平行、重叠、前后衔接,中间可留间隙也可无缝连接,动画的运行逻辑也是如此。
目前我们只需要关注动画的开始和结束时间。绘制时间轴时,不用考虑中间过程,只需为每个独立动画绘制一段线段,标记好起点和终点。我习惯用圆圈标记开始,用短杠标记结束。你可以用任何工具绘制时间轴,不需要严格按比例,只要各个小动画之间的相对时间关系清晰即可。实在不行,直接标注时长也是个偷懒的好办法。
下面是我制作多动画时间轴的示例:
查看示例colorAndOpacityChange [forked],作者Johan Grobler。
colorAndOpacityChange [forked] 作者:Johan Grobler
需要重点注意的是:时间轴上的线段要严格按照动画的实际时间顺序排列。一个动画接一个动画,依次推进。它能直观展示各个动画片段如何配合,以及整个动画的节奏变化。
基于同步基准的SMIL动画
SMIL的核心在于“同步”——这一点从它的名字里就能看出来。它提供了多种指定动画开始时间的方式(你可以通过这个测试用例查看你的浏览器支持哪些)。其中最实用的是“同步基准值”,格式为某个SMIL标签的ID加上.begin或.end,还可以添加正负偏移量。
我们沿用之前颜色和透明度变化的例子。如果想让透明度动画在颜色动画结束前300毫秒启动,你可以手动计算时间,也可以直接用同步基准值colorChange.end - 300ms。这样一来,两个动画的相对时间关系就一目了然了。
<!-- 绝对时间启动 -->
<animate
id="colorChange"
begin="1s"
...
/>
<!-- 相对于第一个动画的结束时间启动 -->
<animate
id="opacityChange"
begin="colorChange.end - 300ms"
...
/>
通过同步基准值,一个动画的启动时间可以关联到另一个动画的.begin或.end时刻。正偏移量会推迟启动时间(向时间轴右侧移动),负偏移量则会提前启动时间(向时间轴左侧移动)。
查看示例syncbase.end [forked],作者Johan Grobler。
syncbase.end [forked] 作者:Johan Grobler
使用负偏移量时要注意:它可能指定一个早于文档加载完成或早于点击事件发生的时间。电脑可不会预知未来(至少目前不会),它最多只能把动画直接跳转到“如果提前启动,现在应该运行到的位置”。也就是说,只有当时间足够时,第二个动画才能完整播放一遍。
查看示例syncbase.begin [forked],作者Johan Grobler。
syncbase.begin [forked] 作者:Johan Grobler
同步基准值不仅能让一个动画的开始关联到另一个动画的结束。你可以选定一个“主动画”——通常第一个想到的动画最能代表整个动画组——然后让所有“子动画”都设置为begin="primary.begin"。这里我用#primary作为ID只是为了强调。这样一来,所有子动画都会以主动画的启动时间为基准。这种组合方式能降低维护成本,比如以后如果想让整个动画组改变启动时间,只需修改主动画的设置即可。
现在我们把这个思路付诸实践,制作一个加载指示器(也就是加载动画),看看调整各部分的相对时间会如何改变整体动画效果:
第一步:选择图片实现方案
浏览器已经广泛支持prefers-reduced-motion媒体特性,Val Head在另一篇文章中对此有深入讲解。在设计动画时,我们必须尊重用户的这一偏好,这一点毫无商量余地。
在SMIL动画中,有多种方式适配用户的prefers-reduced-motion设置,各有优劣。提前评估哪种方案最适合你的使用场景,可以避免后续返工重写。
比如,我们可以考虑用<picture>元素代替普通的<img>,因为<picture>支持多个<source>元素,能通过media属性为需要减少动画的用户提供静态 fallback。或者在单个SVG文件中嵌入CSS的@media查询,用display: none切换动画版和静态版。不过这种方法可能会在某些环境下出问题,但浏览器一直在更新,未来这个问题或许会得到解决。
你也可以考虑用CSS的background-image,因为可以把样式包裹在@media (prefers-reduced-motion)媒体查询中,为需要减少动画的用户设置静态图片作为 fallback。
还有更多可选方案!比如SVG的<view>元素也能根据用户的动画偏好切换内容。
如果你希望所有内容打包在一起,还可以用JavaScript的.matchMedia()结合便捷的SMIL DOM接口来控制动画的启动,而不是完全替换文件。
本次示例中,我避免了位移类动画,只使用透明度动画,这类动画引发的问题更少。对于这种非交互式动画,我们可以用<img>标签加载。如果后续要添加位移效果,再改用<picture>元素来展示最适合用户偏好的版本。
第二步:绘制图形
我们要制作一个经典的三点加载动画:
查看示例StaticDots [forked],作者Johan Grobler。
StaticDots [forked] 作者:Johan Grobler
SVG高手或许能直接用文本编辑器完成所有绘制。如果你难以想象代码对应的视觉效果,我推荐用Inkscape这类图形编辑器。Andy Clarke也写过一篇很棒的文章,介绍他自己优化和整理SVG图形的流程。
注意:Inkscape有个小陷阱。在图层窗口设置的“元素ID”,实际上是Inkscape内部使用的元数据属性。要设置元素真正的ID,需要用Inkscape的“对象属性”或“XML编辑器”窗口。不同编辑器的操作可能有所不同。另外,绘制完成后记得保存为“优化SVG”格式,去除不必要的元数据。
第三步:梳理动画逻辑
我们确定用透明度动画,让三个点依次淡入淡出。每个点需要一对<animate>标签,总共六个标签。
我们的命名规则很简单:淡入标签叫#fadeIn,淡出标签叫#fadeOut,再分别加上Left、Middle或Right后缀来区分三个点。你自己命名时,也要遵循一套容易理解的规则。
左侧点的淡入标签代码如下:
<animate
id="fadeInLeft"
href="#leftDot"
attributeName="opacity"
from="0"
to="1"
...
/>
中间点的淡出标签代码如下:
<animate
id="fadeOutMiddle"
href="#middleDot"
attributeName="opacity"
from="1"
to="0"
...
/>
第四步:设置动画时序
这六个动画的时间排布方式有无数种。我们来看几个例子,结合它们的时间轴,看看调整各部分的时间关系会如何影响整体视觉效果。
为了缩小范围,所有<animate>标签都使用相同的dur值,同步基准值也不添加偏移量。
对于习惯从左到右阅读的人来说,点按从左到右的顺序出现会更自然。我们先让所有点在最后一起淡出:
查看示例dotsVersion1 [forked],作者Johan Grobler。
dotsVersion1 [forked] 作者:Johan Grobler
通过同步基准值实现淡入的依次启动,并在所有点消失后重新开始循环:
<animate
id="fadeInLeft"
...
begin="0s; fadeOutLeft.end"
/>
<animate
id="fadeInMiddle"
...
begin="fadeInLeft.end"
/>
<animate
id="fadeInRight"
...
begin="fadeInMiddle.end"
/>
由于所有淡出动画同时结束,选哪个来触发循环重启都可以。这里我们把#fadeOutLeft作为主动画,让其他淡出动画都用同步基准值fadeOutLeft.begin来同步启动。以后如果想调整淡出的时间,只需修改#fadeOutLeft的启动时间即可。
<animate
id="fadeOutLeft"
...
begin="fadeInRight.end"
/>
<animate
id="fadeOutMiddle"
...
begin="fadeOutLeft.begin"
/>
<animate
id="fadeOutRight"
...
begin="fadeOutLeft.begin"
/>
其他时序方案
我们可以像设置淡入那样,让淡出也依次进行,而不是同时淡出:
<animate
id="fadeOutMiddle"
...
begin="fadeOutLeft.end"
/>
<animate
id="fadeOutRight"
...
begin="fadeOutMiddle.end"
/>
不添加偏移量的话,#fadeOutLeft的启动时间有几个可选点。如果设置为fadeInRight.end:
查看示例dotsVersion2 [forked],作者Johan Grobler。
dotsVersion2 [forked] 作者:Johan Grobler
如果把#fadeOutLeft的启动时间提前到fadeInMiddle.end,视觉效果会有细微变化:
查看示例dotsVersion3 [forked],作者Johan Grobler。
dotsVersion3 [forked] 作者:Johan Grobler
从时间轴上可以看到,我们还可以把启动时间再提前到fadeInLeft.end:
查看示例dotsVersion4 [forked],作者Johan Grobler。
dotsVersion4 [forked] 作者:Johan Grobler
我们还可以让动画从淡出开始:
查看示例dotsVersion5 [forked],作者Johan Grobler。
dotsVersion5 [forked] 作者:Johan Grobler
你也可以让中间的点先出现:
查看示例centerFirstDots [forked],作者Johan Grobler。
centerFirstDots [forked] 作者:Johan Grobler
在迭代动画的过程中,时间轴是很好的记录工具,能让你直观对比不同版本的效果。绘制时间轴还可能让你发现动画各部分之间的时间规律,而这些规律如果只看代码很容易被忽略。
第五步:添加更多动画效果
随着动画元素和属性增多,要记住每个动画的启动时间会越来越难。为了展示时间轴如何帮你理清逻辑,我们在基础加载动画的基础上进行扩展:
查看示例staticDotsWithClipPaths [forked],作者Johan Grobler。
staticDotsWithClipPaths [forked] 作者:Johan Grobler
我为每个点都添加了一个<rect>元素,接下来要把它们移到<clipPath>标签中,并移除fill="white"属性。动画运行时,这些矩形会在点的上方移动,以此实现描边动画,替代传统的stroke-dashoffset动画方式。虽然三个点共用一个<clipPath>就够了,但把它和类似标签一起放在<defs>标签里,能让文档结构更清晰,这也是一种最佳实践:
<defs>
<clipPath id="dotsClipPath">
<!-- 这里及后续使用的矩形尺寸和坐标,取决于父级<svg>元素的viewBox设置。 -->
<rect
id="clipPathLeftRect"
width="2" height="2"
x="1" y="6"
/>
<rect
id="clipPathMiddleRect"
width="2" height="2"
x="4" y="2"
/>
<rect
id="clipPathRightRect"
width="2" height="2"
x="7" y="6">
</clipPath>
</defs>
记得为<circle>元素设置裁剪路径,可以用CSS,也可以用clip-path属性:
<circle
id="leftDot"
...
clip-path="url(#dotsClipPath)"
/>
<circle
id="middleDot"
...
clip-path="url(#dotsClipPath)"
/>
<circle
id="rightDot"
...
clip-path="url(#dotsClipPath)"
/>
因为我们给点添加了stroke属性,为了保持点的大小不变,需要把r的值减去stroke-width的一半:
<circle
...
r="0.9"
stroke-width="0.2"
...
/>
图形部分的修改就完成了。先看看带时间轴的动画版本,再详细讲解动画代码的变化:
查看示例clipPathDots [forked],作者Johan Grobler。
clipPathDots [forked] 作者:Johan Grobler
新增的#moveClipPathLeft动画是整个序列的起点,为了调整动画节奏,我们在淡出结束和循环重启之间设置了1秒的延迟:
<animate
id="moveClipPathLeft"
href="#clipPathLeftRect"
attributeName="y"
from="6"
to="4"
begin="0s; fadeOutLeft.end + 1s"
fill="freeze"
/>
你也可以用<animateTransform>来移动矩形,但为了让动画平滑重启,需要在最后把矩形移回初始位置。如果你决定用transform属性做动画,要注意哪些标签可以动画化,以及它们支持的数据类型。
为了做出变化,中间点对应的矩形是向下移动的:
<animate
id="moveClipPathMiddle"
href="#clipPathMiddleRect"
attributeName="y"
from="2"
to="4"
begin="moveClipPathLeft.end"
fill="freeze"
/>
我们还把淡入动画的opacity属性换成了fill-opacity,并且让淡入动画在对应裁剪矩形移动完成后再启动:
<animate
id="fadeInLeft"
href="#leftDot"
attributeName="fill-opacity"
to="1"
dur="1s"
begin="moveClipPathLeft.end"
fill="freeze"
/>
<animate
id="fadeInMiddle"
href="#middleDot"
...
begin="moveClipPathMiddle.end"
...
/>
<animate
id="fadeInRight"
href="#rightDot"
...
begin="moveClipPathRight.end"
...
/>
淡出动画依然使用普通的opacity属性,这样填充色和描边会一起淡出。由于我们设置的是整体淡出,代码有几处可以优化,比如去掉fill="freeze",这样标签运行结束后,点的opacity会自动重置为初始值:
<animate
id="fadeOutLeft"
href="#leftDot"
attributeName="opacity"
to="0"
dur="1s"
begin="fadeInRight.end"
/>
<!-- 我们依然把#fadeOutLeft作为主动画,让其他淡出动画同步它的启动时间。 -->
<animate
id="fadeOutMiddle"
href="#middleDot"
...
begin="fadeOutLeft.begin"
/>
<animate
id="fadeOutRight"
href="#rightDot"
...
begin="fadeOutLeft.begin"
/>
对于那些使用了fill="freeze"的<animate>标签,我们要用<set>标签把属性重置回初始值。为了简化时间轴,我把这些<set>标签归在了一起。由于<set>标签没有持续时间,在时间轴上我把它们的起点和终点标记在了同一位置。
重置左侧点的fill-opacity属性:
<set
href="#leftDot"
attributeName="fill-opacity"
to="0"
begin="fadeOutLeft.end"
fill="freeze"
/>
如果淡出动画使用了fill="freeze",还需要为每个点添加额外的<set>标签来重置opacity,比如中间点:
<set
href="#middleDot"
attributeName="opacity"
to="1"
begin="fadeOutLeft.end"
fill="freeze"
/>
最后一步是把裁剪矩形移回初始位置,比如右侧矩形:
<set
href="#clipPathRightRect"
attributeName="y"
to="6"
begin="fadeOutLeft.end"
fill="freeze"
/>
这只是其中一种时序方案,要尝试其他方案的话,基础代码已经准备好了,就像我们在基础加载动画中做的那样。你可以试着自己设计几种不同的时序。
时间轴的价值
总结一下,平衡动画的不同阶段难度不小,复杂时甚至会让人无从下手。只要动画超过一两步,就相当于在编排一场“演出”,代码简直像鲁布·戈德堡机械一样复杂。而时间轴是我常用的一种策略,希望它也能帮到你。它能清晰展示动画的各个环节和时间节点,不仅能帮你规划代码,还能让后续的更新和维护变得轻松很多,不至于毫无头绪。
虽然时间轴无法减少代码的复杂度,但它能让你对动画的整体时序一目了然。时间轴绝非只适用于SMIL动画——可惜同步基准值是SMIL独有的,但即使你用其他方式实现动画,时间轴依然能发挥作用。
拓展阅读
我强烈推荐你看看Andy在Smashing Magazine上发表的其他文章,里面有很多关于CSS和SVG结合的内容。
Yosra Emad写过一篇很棒的文章,讲解如何用CSS创建多步骤动画,你可以边读边试着画几个时间轴。
当你准备在时间轴上添加中间过渡环节时,Nash Vail的缓动函数详解文章会深入讲解缓动曲线的细节。
你可能还会对浏览器SVG实现测试感兴趣,看看你的浏览器支持哪些特性。
(gg, yk)
(gg, yk)