前言
这段时间一直在研究 Canvas
的动画,本文将带大家基于 Canvas
封装的 ZRender
库,了解ZRender
库中提供的 animate
绘制动画的方法,并且使用 animate
方法实现一个带有箭头流动效果的连线。
效果
ZRender
在介绍 ZRender
的动画之前,先弄清楚 ZRender
是什么?
ZRender是二维绘图引擎,提提供Canvas、SVG、VML等多种渲染方式。ZRender也是ECharts的渲染器。
本文重点介绍的是基于 Canvas
模式的渲染方式。
使用起来非常简单:
引入ZRender资源包
- 通过
npm install
的形式进行安装
$ npm install zrender
- 通过HTML中加载对应的
JavaScript
资源
<script src="./dist/zrender.js"></script>
初始化ZRender
在使用 ZRender
前需要初始化实例,具体方式是传入一个 DOM
容器:
const zr = zrender.init(document.getElementById('canvas'));
创建出的这个实例对应文档中 zrender 实例部分的方法和属性。
在场景中添加元素
ZRender
提供了将近 20 种图形类型,可以在文档 zrender.Displayable 下找到。
以创建一个圆为例:
const circle = new zrender.Circle({
shape: {
cx: 150,
cy: 50,
r: 40
},
style: {
fill: 'red',
stroke: '#F00'
}
});
zr.add(circle);
让这个圆动起来
ZRender
提供了 zrender.Animatable.animate(path, loop)方法可以创建一个动画对象。
还是以刚刚创建的圆为例,我们让这个圆动起来:
circle.animate('shape', true)
.when(10000, { cx: 800})
.during((obj, i) =>{
console.log(i);
})
.start();
效果:
接下来我们看下各个方法的作用:
- animate(path, loop): 创建一个动画对象。
path 参数表示对该对象的哪个元素执行动画,如 xxx.animate('a.b', true)
表示对 xxx.a.b
(可能是一个 Object
类型)执行动画。
loop 参数表示是否循环动画,是个布尔值,默认为 false
。
- when(time, props):定义关键帧,即动画在某个时刻的属性。
time 参数表示关键帧时刻,单位为毫秒。props 参数表示关键帧的属性,应为 Animatable
对象的属性,此处表示关键帧的时刻为 10秒,当动画在此关键帧的时候,cx 值为 800。
这里涉及到一个名词:关键帧。在传统的动画制作过程中,一般都是先定义一系列的关键帧动画,然后在关键帧动画之间添加一些中间片段让动画看起来更流畅,更自然。
- during(callback):为关键帧添加回调函数,在关键帧运行后执行。
由于人眼的视觉残留特性,要骗过我们的眼睛,理论上达到二十四分之一秒即 24帧 这个速度切换图片就能达到动画的效果,速度越快,这个动画就越细腻流畅。
一般来说,在浏览器上一秒钟会执行60次回调函数,也就是 60帧(60fps),但浏览器会尽可能保持帧率的稳定,也就是有可能会降低到其他的帧率,比如页面性能差时浏览器可能会选择降到 30fps,当浏览器的上下文不可见时会降到 4fps 左右甚至更低。
为了验证上面这个结论,可以通过下面代码进行验证:
let count = 0;
circle.animate('shape', false)
.when(1000, { cx: 800})
.during((obj, i) =>{
count += 1;
console.log('count:', count)
})
.start();
上面这段程序 count
每次打印出来都不固定,多跑几次平均值为60。
during
的 callback
回调函数有两个参数,obj
表示浏览器执行到当前帧时,动画对象执行到当前帧时的值(animate中设置的动画属性),i是一个介于0到1之间的数值,用来表示从开始到 when
中指定的关键帧,浏览器已经执行的帧数占总帧数的比例。
线性插值
现在我们尝试实现开头的动画例子,先考虑将一个箭头沿着一条直线进行运动。
绘制一条直线和一个箭头:
// 直线
const line = new zrender.Polyline({
shape: {
points: [
[334, 374],
[463, 374]
]
},
style: {
stroke: '#FF6EBE'
}
});
// 三角形
const triangle = new zrender.Polygon({
shape: {
points: [
[0, -5],
[5, 0],
[-5, 0],
],
},
style: {
fill: 'blue',
},
z: 2,
});
// ZRender以逆时针为正
triangle.rotation = -Math.PI / 2;
triangle.position = [334, 374];
zr.add(line);
zr.add(triangle);
三角形的坐标位置通过 position
属性进行设置,通过 rotation
属性对三角形进行旋转,设置箭头的朝向。
效果:
让箭头动起来:
triangle.__t = 0;
triangle.animate('', true)
.when(3000, {__t: 1})
.during((obj, i) => {
triangle.position = [334 + 129 * i, 374]
})
.start();
zr.add(line);
zr.add(triangle);
给 triangle
上设置了一个 __t
属性,when方法定义关键帧,当3秒的时候,__t
属性值为 1。在during的回调函数中计算0-3秒之间每一帧triangle的位置,并通过 position 属性实时修改 triangle 的坐标。
triangle.position = [334 + 129 * i, 374]
其中 334 是起始横坐标,129 是从 A 运动到 B 点之间的总距离,374 是纵坐标,i 表示运行到当前关键帧的比例。
效果:
上述公式也可以使用线性插值公式
替换。
线性插值函数,常称为 lerp
,一般是这样定义的:
function lerp(min, max, fraction) {
return (max - min ) * fraction + min;
}
fraction
是一个介于 0 到 1 之间的数,当 fraction
取 0,lerp
返回 min(最小值),当fraction
取 1 时,lerp
返回 max
(最大值),当 fraction
取 0.5 时,取最大值和最小值之间的一半。
利用线性插值函数的特性,可以完美应用到两点之间的运动轨迹的计算。ZRender库内置了对lerp函数的支持,函数签名如下:
/**
* 插值两个点
*/
zrender.vector.lerp(输出值, 起点坐标, 终点坐标, 系数);
注意,输入值、起点坐标和终点坐标是用向量数组的形式来表达。
改造后的结果如下:
triangle.animate('', true)
.when(3000, {__t: 1})
.during((obj, i) => {
zrender.vector.lerp(
triangle.position,
[334, 374],
[463, 374],
i
);
})
.start();
了解了直线上箭头的运动原理,现在我们开始回到开头的示例,实现折线上箭头运动。
定义一个变量,用来存储折线的路径:
const points = [
[334, 374],
[463, 374],
[463, 346],
[541, 346],
[541, 361]
];
// 直线
const line = new zrender.Polyline({
shape: {
points
},
style: {
stroke: '#FF6EBE'
}
});
计算每个坐标带点到起始点之间的距离之和:
// [0, 129, 157, 235, 250]
let accLenList = [0];
for (let i =1; i< points.length; i++) {
const p1 = points[i-1];
const p2 = points[i];
const dist = zrender.vector.dist(p1, p2);
accLenList.push(accLenList[i-1] + dist);
}
zrender.vector.dist
是 zrender
提供的计算向量之间距离的方法。
计算运动到每个点时,所占总运动距离的比例:
// [0, 0.516, 0.628, 0.94, 1]
let percentList = accLenList.map((acc) => {
return acc / accLenList[accLenList.length-1];
});
设置箭头的初始位置:
triangle.position = [points[0][0], points[0][1]];
在during回调函数里面判断当前帧是在哪段曲线内,并计算当前线段内的运动轨迹
let frame = 1;
triangle.animate('', true)
.when(3000, {__t: 1})
.during((obj, i) => {
for(let j = 1; j< percentList.length; j++) {
if (i > percentList[j-1] && i < percentList[j]) {
frame = j;
break;
}
}
zrender.vector.lerp(
triangle.position,
points[frame - 1],
points[frame],
(i-percentList[frame-1])/(percentList[frame]-percentList[frame-1])
)
})
.start();
效果如下:
现在还有个小问题,就是箭头的方向没有随着线段的弯曲进行调整,我们接着修改代码:
let frame = 1;
triangle.animate('', true)
.when(3000, {__t: 1})
.during((obj, i) => {
for(let j = 1; j< percentList.length; j++) {
if (i > percentList[j-1] && i < percentList[j]) {
frame = j;
break;
}
}
const angle =- Math.atan2(
points[frame][1] - points[frame - 1][1],
points[frame][0] - points[frame - 1][0],
);
triangle.rotation = angle - Math.PI / 2;
zrender.vector.lerp(
triangle.position,
points[frame - 1],
points[frame],
(i-percentList[frame-1])/(percentList[frame]-percentList[frame-1])
)
})
.start();
通过 Math.atan2
函数计算折线之间的拐角度数,zrender的旋转角度和canvas的旋转角度是相反的,zrender是逆时针方向为正的,canvas以顺时针方向为正的。
更多精彩文章,欢迎关注我的公众号:前端架构师笔记
参考资料
- 理解动画中的线性插值
- Canvas动画🔥上——动画原理及匀速、变速运动(大量示例及代码)