前言
animejs(中文官网animejs.cn),是一个老牌的前端动画库,它允许我们使用简明的参数配置来编排、实现一些复杂的动画效果。这个库提供的动画类型有很多,对于我个人来讲,使用最多的是它的SVG路径动画。在使用这个功能的过程中踩了一些坑,而官方文档并未对这些潜在的坑进行说明,因此我写了这篇文章,从原理的角度讲述一下一些常见的坑点,希望可以帮助到有需要的同学。
SVG路径动画简介
文档地址
animejs的官方文档非常的简明,左边是清晰的动画演示,右边则是直奔主题的使用介绍与实例代码,这里直接贴出内容:
我们可以看到,这个功能的核心内容就是
anime.path
这个方法,这个方法读取一个svg图形(经过阅读源码,这个图形可以是path
、circle
、rect
、line
、polyline
、polygon
),然后返回一个函数(以下称为path
函数)。从文档上我们可以看出来,这个path
函数接受一个属性名(x/y、角度)作为参数,然后返回某种可以为平移变换提供参考的数据(后续会在源码解析中进行解释),根据这些数据对动画目标进行操作,使其按照预期的路径运动。
常见的坑
文档介绍看起来非常简单,实际使用也是如此,短短几行代码即可上手。但是它对我们选择的运动目标和SVG路径有着比较严格的要求,这是文档中没有提及的,而这些坑对于初次遇到的同学可能要花费很多精力去排查。
这里为了方便说明,我使用官网中的示例这个比较简单的场景来进行展示,实际项目中的情况可能要复杂很多,但是根本原因是一样的。
(在以下演示中,紫色的部分都是正常的情况,而其他颜色的部分则是错误示例)
问题1 - 运动中心偏移
我们可以看到,蓝色方块虽然在沿着正确的路径运动,但是它的“运动中心”并不在图形的中心上,而是看起来像围绕着某个顶点(不完全是)在运动。而在我遇到的需求场景中,基本都是需要图形的中心落在运动路径上的。
问题2 - 运动路径偏移
在这个例子中,黄色的方块虽然正在按照正确的路径运动,但是位置与预期有着很大的偏移,仿佛在这个地方有另一条形状一致的透明路径在引导它移动。
问题3 - 运动轨迹完全变形
如果说前两种只是各种位置上的偏移,运动轨迹本身还是正确的话,第三种情况就更容易让人摸不着头脑:
红色方块的运动轨迹上虽然还能看得出预期路径的影子,但是已经严重扭曲变形。
问题解析
源码部分
animejs的源码是一个一千多行的单文件,内容量并不大,而且在结构上根据不同的功能做了比较清晰的分块,阅读起来难度不大。对于路径动画,核心的函数只有四个,所以我在这里只对这四个函数进行介绍,对于涉及到的其他部分,会做适当的补充说明。
这四个函数的调用关系非常简单,第四个依赖第三个的结果,第三个依赖第二个,以此类推。但是在调用时机上有比较大的区别。前三个函数getParentSvgEl
、getParentSvg
、getPath
是在动画的创建阶段进行调用的,并将最终的返回值作为动画参数传入,而在动画进行的过程中,getPathProgress
这个函数将会在浏览器渲染的每一帧(requestAnimationFrame
)被调用,调用过程中所依赖的参数就是前面函数的返回值。
function getParentSvgEl(el) {
let parentEl = el.parentNode;
while (is.svg(parentEl)) {
if (!is.svg(parentEl.parentNode)) break;
parentEl = parentEl.parentNode;
}
return parentEl;
}
function getParentSvg(pathEl, svgData) {
const svg = svgData || {};
const parentSvgEl = svg.el || getParentSvgEl(pathEl);
const rect = parentSvgEl.getBoundingClientRect();
const viewBoxAttr = getAttribute(parentSvgEl, 'viewBox');
const width = rect.width;
const height = rect.height;
const viewBox = svg.viewBox || (viewBoxAttr ? viewBoxAttr.split(' ') : [0, 0, width, height]);
return {
el: parentSvgEl,
viewBox: viewBox,
x: viewBox[0] / 1,
y: viewBox[1] / 1,
w: width,
h: height,
vW: viewBox[2],
vH: viewBox[3]
}
}
前两个函数总体做的是一件事,就是找到路径所处的svg的最外层及其相关的一些尺寸、viewport的信息,因为有很多svg的视口并没有与其尺寸完全吻合(在本文中,为了简化说明,我们只考虑viewport与尺寸完全吻合的情况),这一步的目的是确定我们这个动画的“画布”位置,确切来说是确定“画布”的左上角。
function getPath(path, percent) {
const pathEl = is.str(path) ? selectString(path)[0] : path;
const p = percent || 100;
return function(property) {
return {
property,
el: pathEl,
svg: getParentSvg(pathEl),
totalLength: getTotalLength(pathEl) * (p / 100)
}
}
}
第三个函数getPath
就是暴露给外部使用的anime.path
这个方法,我们将指定的svg路径传入,它就会返回一个函数,紧接着我们像path('x')
path('y')
path('angle')
这样调用这个函数,并且填入对应的参数位置。其实我们通过阅读这段源码可以发现,这个函数直接返回了一个对象,而且我们传入的'x'
, 'y'
这些参数的值仅仅只是成为了property
这个属性的值,作为一个标识符存在,并不影响其他属性的计算。
而这一步真正额外做的是什么呢?是计算了totalLength
这个属性,这是路径的总长度,也就是一个动画周期中,动画目标所要运动的路程。在随后生成每一帧动画时,就可以根据动画周期的时间,动画周期的长度,当前播放的总时间计算出当前周期内在路径上运动过的长度,进而可以使用SVGGeometryElement.getPointAtLength()
这个方法获取到当前时刻在路径上的那个点,最后根据这个点计算位置、角度等信息。(在实际情况下,还需要将淡入淡出、变速、反转、暂停等诸多情况一并计算,感兴趣的朋友可以去看源码,在此不再赘述)
function getPathProgress(path, progress, isPathTargetInsideSVG) {
function point(offset = 0) {
const l = progress + offset >= 1 ? progress + offset : 0;
return path.el.getPointAtLength(l);
}
const svg = getParentSvg(path.el, path.svg)
const p = point();
const p0 = point(-1);
const p1 = point(+1);
const scaleX = isPathTargetInsideSVG ? 1 : svg.w / svg.vW;
const scaleY = isPathTargetInsideSVG ? 1 : svg.h / svg.vH;
switch (path.property) {
case 'x': return (p.x - svg.x) * scaleX;
case 'y': return (p.y - svg.y) * scaleY;
case 'angle': return Math.atan2(p1.y - p0.y, p1.x - p0.x) * 180 / Math.PI;
}
}
第三个函数调用发生在每一帧动画间,简单描述这个函数的作用就是:根据当前的动画进度,拿到当前目标位置的点,然后计算这个点到 “画布”左上角 的偏移量、角度,最后这些偏移量将用来对动画目标进行同样的平移、旋转,使其落在预期的位置上。
偏移问题的解析
现在回到我们最初的问题,我们可以看到第一二种问题场景是非常类似的,因此把他们放在一起来分析。
基础原因
官方的DEMO使用了一个半透明的“残影”来标记运动目标的初始位置,在这个DEMO中,我使用了同样的方法来标记。眼尖的同学可能在之前已经发现了,错误偏移的方块,它们的“残影”相对于正确的方块也发生了同样的偏移。
看到这里,我们已经可以猜到大概的原因了,错误的偏移与运动目标的初始位置有关。由于偏移量是根据路径上的点到 “画布”左上角 的距离计算的,所以我们把 “画布”左上角 看做是平移变换的 “基准点”。 想要让动画目标的几何中心正确地吸附在目标路径上运动,我们必须保证它的初始位置是与 “基准点” 相吻合的。
在这两个例子中,蓝色的方块虽然落在了基准点上,但是重合的位置是它的顶点而不是几何中心,所以最终结果是它的顶点吸附在路径上运动,显得十分诡异。 而另一个例子就更容易理解了,黄色方块的初始位置完全偏离了基准点,因此发生了非常大的偏移。
而这些初始位置是如何确定的呢?在路径动画中,动画目标可以是同一个svg文档内的图形,也可以是一个普通的HTML DOM,几乎所有影响定位的属性(DOM的绝对定位、svg图形的bouding box以及两者祖先元素的translate等等)都会决定初始位置。但是需要注意的是,动画目标自身的translate不会影响其初始位置,因为这个属性会被动画覆盖。
实际场景更为复杂
看到这里可能有很多人会质疑,把动画目标定位到左上角不是很自然的事情吗?我这是不是为了写文章而硬去造了一些不太可能出现的错误场景?确实在实际开发中,我们可能出现蓝色方块那种中心点定位错误的问题,但是很少会出现黄色方块的那种情况,除非故意。但是前面说过了,DEMO里面是用最简单的情况来描述根本原因,在实际的场景中,发生偏移的是路径,而不是动画目标。这里面关键的位置问题指的是动画目标和路径两者之间的相对位置,所以我认为这两种情况的原理是一样的,下面进行详细的解释。
在理想状态下,我们认为SVG路径是这样的:
<svg width="512" height="256" viewBox="0 0 512 256">
<path fill="none" stroke="#8452E3" stroke-width="1" d="..."></path>
</svg>
但是实际从设计师那里拿到的是这样的:
<svg width="512" height="256" viewBox="0 0 512 256">
...
<g transform="translate(100, 500)">
...
<g transform="translate(24, 8)">
...
<g transform="translate(24, 8)">
...
<g transform="translate(0, 5)">
...
<path fill="none" stroke="#8452E3" stroke-width="1" d="..."></path>
...
</g>
...
</g>
...
</g>
...
</g>
...
</svg>
在我们拿到的svg中,可能充斥着大量的装饰性的图形,并且路径动画的路径也不止一条,我们最终要操作的那条路径,在复杂的图层、分组结构下,被祖先元素叠加了多次偏移量。但是这还没有解释发生错误的原因,虽然路径发生了偏移,但是我们毕竟是用路径上的点到左上角的距离来计算偏移量,这应该不会影响结果的正确性啊?但事实是,这样计算真的会发生错误:
在这个例子中,我们选用那条红色路径作为动画路径,依旧把动画目标放置在“画布”左上角,但是紫色方块并没有按我们预期的沿着新的路径运动,而是依然吸附在那条紫色残影(仅供演示,实际不存在)上。
这是为什么呢?原因出在源码中使用的SVGGeometryElement.getPointAtLength()
这个方法上,这个方法拿到的只是路径上原始的点,而不会考虑其相对于“画布”的偏移量,也就是说,不管一条路径被施加了多少的偏移量,最后计算出的距离都是一样的。
这样看来,基准点不是一成不变的,在路径没有偏移的时候,它是最左上角的那个点,在路径发生偏移时,我们要对动画目标的初始位置施加同样的偏移量,才能对它正确定位。
路径变形问题的解析
这种问题初次发生时是最让人摸不着头脑的,但是原因和解决方案都是最简单的。
在这个场景中,红色方块的初始位置看起来是正确的,但是运动轨迹却非常扭曲,乍一看很难想到是什么原因,但是当把实际的动画目标显示出来之后,一切就很清晰了:
实际的运动目标是这个白色的大方块,方块运动的过程中旋转中心是白色方块的几何中心,这就是诡异轨迹的原因。
这种问题往往发生在设计师在同一个svg文件中绘制多个图形时(自己编写的HTML DOM一般不会出现这种问题),由于图形绘制过程中一些图层、分组的问题,会出现一些留白,而我们拿到svg之后又很难对其中的每个图形进行校对,最终的结果就是这样。
问题总结与建议
问题的总体原因分为两类 - 动画目标初始位置的偏差与动画目标图形的错误裁剪区域(留白问题)。这两类共三种问题可能单独出现,也可能同时出现,大家可以根据前文所述的这些特征进行问题的辨别与定位,然后选择恰当的方式分别解决。
对于后一种问题,最好的解决办法是与设计师进行足够的沟通,以保证拿到的图形是可以直接使用的。而对于前一种问题,我们可以做的事情就比较多了,下面有两种解决建议,大家可以根据实际情况选用:
方案1 - 把运动路径与动画目标组织在同一个父级“容器”中
如果条件允许的话,这是最简单的解决方案。在animejs中,SVG路径动画的目标可以是一个DOM,也可以是svg中的一个图形。在本文示例(或者说官方示例)中,图形的组织结构非常简单,只有一个路径动画,而且运动的路径在svg文档的最上层(根元素处),在这种场景中,没有多层的<g>
标签的嵌套,也就不会有多层transform=translate(...)
的叠加。所以动画目标不论是svg外部的DOM还是svg内部的一个/一组图形,我们都可以很容易地把它的几何中心定位到运动路径的最小矩形(边界框)的左上角,即前文所述的“基准点”。
而对于比较复杂的svg图形,设计稿中可能在同一幅图中同时存在多个路径动画,而且每个路径都有很深的嵌套层级。在目标路径存在多层位移的情况下,我们想要把动画目标正确定位到“基准点”是比较困难的。这时我们可以把动画目标与其对应的运动路径组织到同一个<g>
标签内,使它们拥有完全相同的父级“容器”,这样他们自然拥有了同样的多层位移。这里需要注意的是,如果路径元素本身也拥有translate属性,我们需要对动画目标的定位进行相应的调整,因为这个”末级位移“不是在父容器上,自然也不会对动画目标产生作用,所以我们需要手动修正这一偏差。
如果动画目标是一个HTML元素,我们可以通过foreignObject将其组织到svg中。
方案2 - 路径与动画目标完全分离
虽然第一种方案非常简单,但是有时候我们可能因为种种限制(svg结构过于复杂、代码可读性的考虑)无法采取第一种方式。这时我们需要获取到运动路径的最小矩形到svg左上角的最终位移数值并据此来设置动画目标的初始位置。在浅层的嵌套中,我们可以通过阅读svg的代码来计算偏移量,但是在深层嵌套时这会非常困难。在这里推荐大家使用Element.getBoundingClientRect()这个API分别获取运动路径和最外层svg到视口左上角的距离,然后把它们相减,即为路径相对于svg的偏移量。
最后的注意事项
无论采取哪种方式,切忌使用tranlsate设置动画目标的初始位置,因为这会在动画过程中被覆盖。