本文源自我的公众号: 实践requestAnimationFrame平滑动画
背景
前端领域实现动画效果通常有这么几种方式: css animation、setTimeout(setInterval)、Lottie。
css animation 动画是通过关键帧(@keyframes) 来实现的,优点是写法比较简单,缺点就是难以获取动画的开始和结束事件。
setTimeout 一般通过callback 来控制元素的变化实现动画的,但是定时器动画一直存在两个问题,第一个就是动画的循时间环间隔不好确定,设置长了动画显得不够平滑流畅,设置短了浏览器的重绘频率会达到瓶颈,推荐的最佳循环间隔是17ms(大多数电脑的显示器刷新频率是60Hz,1000ms/60);第二个问题是定时器第二个时间参数只是指定了多久后将动画任务添加到浏览器的UI线程队列中,如果UI线程处于忙碌状态,那么动画不会立刻执行。
lottie 一般是使用 canvas 或者 svg 方式来实现动画的, 通过引入配置文件来实现。但是复杂动画会导致配置文件过大,进而导致webpack打包体积过于庞大。
本次要实现能量球的 轨迹运动 动画 及 总能量缩放 动画,以上的方案实践下来均不能实现平滑的效果。后来了解到 H5 中加入了 requestAnimationFrame,该方法是根据浏览器的刷新频率来执行回调方法,可以很好的控制动画的开始和结束事件。
尴尬,简书上传不了视频,如果看效果,请移步到公众号文章地址查看。 https://mp.weixin.qq.com/s/yTex4ewF0cbG1tluaqxKzw
说明
window.requestAnimationFrame()
告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
注意:若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用
window.requestAnimationFrame()
当你准备更新动画时你应该调用此方法。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数(即你的回调函数)。回调函数执行次数通常是每秒60次,但在大多数遵循W3C建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。为了提高性能和电池寿命,因此在大多数浏览器里,当requestAnimationFrame()
运行在后台标签页或者隐藏的iframe
里时,requestAnimationFrame()
会被暂停调用以提升性能和电池寿命。
回调函数会被传入DOMHighResTimeStamp
参数,DOMHighResTimeStamp
指示当前被 requestAnimationFrame()
排序的回调函数被触发的时间。在同一个帧中的多个回调函数,它们每一个都会接受到一个相同的时间戳,即使在计算上一个回调函数的工作负载期间已经消耗了一些时间。该时间戳是一个十进制数,单位毫秒,最小精度为1ms(1000μs)。
请确保总是使用第一个参数(或其它获得当前时间的方法)计算每次调用之间的时间间隔,否则动画在高刷新率的屏幕中会运行得更快。
语法
window.requestAnimationFrame(callback);
参数
callback
下一次重绘之前更新动画帧所调用的函数(即上面所说的回调函数)。该回调函数会被传入
DOMHighResTimeStamp
参数,该参数与performance.now()
的返回值相同,它表示requestAnimationFrame()
开始去执行回调函数的时刻。
返回值
一个 long
整数,请求 ID ,是回调列表中唯一的标识。是个非零值,没别的意义。你可以传这个值给 window.cancelAnimationFrame()
以取消回调函数。
范例
以下代码根据回调的时间戳传参(说明:这里是timestamp 表示为从time origin之后到当前调用时经过的时间),确保不同刷新频率的屏幕 都可以在 两秒内停止动画。
const element = document.getElementById('some-element-you-want-to-animate');
let start;
function step(timestamp) {
if (start === undefined)
start = timestamp;
const elapsed = timestamp - start;
//这里使用`Math.min()`确保元素刚好停在200px的位置。
element.style.transform = 'translateX(' + Math.min(0.1 * elapsed, 200) + 'px)';
if (elapsed < 2000) { // 在两秒后停止动画
window.requestAnimationFrame(step);
}
}
window.requestAnimationFrame(step);
实践
本次实践收能量平滑动画不考虑高刷新频率的屏幕,暂定为用户使用的都是 一秒钟 60 刷新频次的屏幕。动画分三部分:向下动画、轨迹动画、缩放动画。
- 下滑动画
收取能量球开始有个向下的动画,每次下滑2px,能量球下滑到 60 px 的时候(下滑动画执行了30次,按照一秒钟刷新60次,动画大约执行0.5秒),则执行向右上角的轨迹动画,用 requestAnimationFrame 可以这么实现:
// 点击能量球事件
clickBall() {
this.dropY = 0;
this.perY = 2;
requestAnimationFrame(this.dropBall.bind(this));
}
// 下滑轨迹动画
dropBall() {
if(this.dropY < 60) {
this.dropY += this.perY;
this.ballRef.current.style.top = `${this.props.style.top + this.dropY}px`;
requestAnimationFrame(this.dropBall.bind(this));
} else {
// 轨迹动画
this.parabola();
}
}
- 轨迹动画
轨迹动画,则通过 getBoundingClientRect
方法 分别获取 开始元素(能量球当前位置)坐标 和 结束元素(总能量位置)坐标的位置, 进而计算出需要运动的距离。根据需要的时间和每秒60次的刷新频率,计算出 每次 translate 的距离即可。(可以参照张鑫旭实践的轨迹动画,此处不列举实现方式)。
- 缩放动画
最后是总能量的缩放动画(这里实现的缩放是 从1.0 放大到1.3, 然后再缩回到1.0):
// 总能量收取事件 及 触发动画效果
collectPower({energyNum}) {
this.totalPower += energyNum;
this.powerRef.current.innerText = this.totalPower;
this.minuteFlag = false;
this.scale = 1.0;
this.perAdd = 0.02;
requestAnimationFrame(this.scalePower.bind(this));
}
// 动画效果
scalePower() {
if (this.scale < 1.3 && !this.minuteFlag) {
this.scale += this.perAdd;
this.powerRef.current.style.transform = `scale(${this.scale})`;
requestAnimationFrame(this.scalePower.bind(this));
} else if (this.scale > 1) {
this.minuteFlag = true;
this.scale -= this.perAdd;
this.powerRef.current.style.transform = `scale(${this.scale})`;
requestAnimationFrame(this.scalePower.bind(this));
} else {
this.powerRef.current.style.transform = `scale(1)`;
}
}