本文首发于饿了么大前端知乎专栏:使用缓动函数制作更自然的动画,这里在我的博客再发一遍 :stuck_out_tongue_closed_eyes:
自然界中的物体从起点移动到终点时,速度从来不是一成不变的。汽车启动时速度会由慢变快,停止时则由快变慢;篮球落地时会在地上来回反弹,并逐渐停止运动。大家都期待事物的呈现遵循一定的运动规律,所以,在网页中适当的使用动画能让用户得到更舒适的体验。
要制作出更加自然的动画,就需要理解什么是缓动函数。简单来说,缓动函数用于控制动画从初始值运动到最终值的速率。幸运的是,业界已经整理出了一些常用的缓动函数曲线,本文将向读者介绍如何在 CSS 与 JavaScript 里使用这些缓动函数。
在 CSS 中使用缓动函数
CSS 提供了四种基础的缓动函数:
linear表示线性动画,动画从开始到结束一直是同样的速度,看起来不是很自然。ease-in表示缓入动画,动画的速度先慢后快,就好像汽车启动时一样。缓入动画会在速度最快时停止,这会让动画结束得很突然,因为自然界中的运动总是慢慢减速后才停止的。ease-out表示缓出动画,与缓入动画正好相反,缓出动画的速度先快后慢,就好像汽车慢慢停止一样。ease-in-out表示缓入缓出动画,它的速度由慢变快,最后再变慢,就好像汽车启动、加速、然后停下来一样。
总的来说,线性动画与缓入动画不太符合自然运动规律;缓出动画初始速度很快,能给人一种快速反应的感觉;缓入缓出动画更符合自然界的运动规律,但是动画开始时速度很慢,会显得很迟钝,所以运行时间不宜过长,一般最好控制在 300 至 500 毫秒之间。
文章开头提到的常用缓动函数也是以这种规则命名的,所以很容易就能区分。虽然 CSS 不提供这些缓动函数,但可以通过贝塞尔曲线来定义这些缓动函数。例如,要让一个元素的高度用名为 easeInOutCubic 的缓动函数来变化,可以这样写:
div { transition: height 0.2s cubic-bezier(0.645, 0.045, 0.355, 1) }
cubic-bezier() 内定义的四个数字实际上是两个点的坐标,用这两个点就可以确定缓动函数的运动曲线了,由于篇幅有限,这里就不深入解释了。
除了使用这些预定义的缓动函数,你也可以使用 cubic-bezier.com 和 matthewlein.com/ceaser 这两个在线工具自定义运动曲线。值得一提的是,你可以在这些工具中把两个点拖动到坐标轴外面来让动画在运动过程中”超出”最终值——想象一下我们在上物理课时做过的关于弹簧的实验,弹簧的一端绑着一个铁球,铁球落到最低点时的水平位置会超过铁球最终停下时所处的水平位置。
在 JavaScript 中使用缓动函数
如果你仔细看过了前文提到的常用缓动函数的网站,你会发现有一些缓动函数没法在 CSS 中使用,只能用 jQuery 加上 jQuery Easing 插件实现,例如刚才提到的”弹簧”动画:
div.animate({ top: '-=100px' }, 600, 'easeOutElastic', function () { … })
仅仅为了制作一个动画就在项目里引入两个依赖太不值得了,所以这里介绍一下如何在不引入任何依赖的情况下使用缓动函数。
查看 jQuery Easing 插件的源码就会发现,JavaScript 里的缓动函数是真的”函数”,其中 easeOutElastic 函数的定义如下:
const c4 = (2 * Math.PI) / 3
function easeOutElastic (x) {
return x === 0 ? 0 : x === 1 ? 1 :
Math.pow(2, -10 * x) * Math.sin((x * 10 - 0.75) * c4) + 1
}
看不懂?没关系,我也看不懂。一般情况下,我们不需要深入了解每个缓动函数是如何实现的,只需要知道它接收一个参数 x,这个参数代表当前动画的运动时间点,并返回这个时间点动画所处的位置。
简单解释一下这里所说的动画的「运动时间点」与「运动位置」。
我们可以将一个动画的开始到结束理解为动画从 0% 的时间点运动到了 100% 的时间点(即缓动函数曲线的 X 轴),所以动画开始时,x 的值就是 0,运行到一半时,x 的值就是 0.5,结束时,x 的值就是 1,依此类推。当动画的时间点从 0% 运行到 100% 的时候,动画的位置也同样从 0% 运动到了 100%(即缓动函数曲线的 Y 轴),这两个轴从 0 出发运动到 1 时所形成的点就组成了「缓动函数曲线」。
这样理解的话,线性动画的缓动函数曲线是一条直线也就不难理解了——它的运动时间对应的运动位置总是相同的。
现在,假设我们需要使用 easeOutElastic 函数在两秒钟内将一个 div 的高度从 100px 运动到 400px,我们可以这样写(注释可能有一点啰嗦):
const div = document.getElementsByTagName('div')[0] // 要变化高度的 div
const startValue = 100 // div 的初始高度
const endValue = 400 // div 的最终高度
const changeValue = endValue - startValue // div 变化了这么多高度
const during = 2000 // 动画持续 2 秒钟
// 为了让动画足够流畅,我们需要达到 60 帧/每秒的动画速率,
// 即大约 17 毫秒更新一次动画的状态
const updateTime = 1000 / 60
// 计算出两秒内我们需要更新动画的状态多少次
const updateCount = during / updateTime
// 我们需要一个在下一帧更新动画状态的函数
const rAF = window.requestAnimationFrame || function(cb) { setTimeout(cb, updateTime) }
const startPosition = 0 // 动画的开始时间点是 0%
const endPosition = 1 // 动画的结束时间点是 100%
// 因为我们要在动画从 0% 运动到 100% 时更新 `updateCount` 次动画,
// 所以要计算出每次更新动画时动画经过的时间
const perUpdateDistance = endPosition / updateCount
let position = startPosition // 记录动画的当前时间点
function step () {
// 计算 div 在当前时间点的高度
const height = startValue + changeValue * easeOutElastic(position)
div.style.height = height + 'px' // 更新 div 的高度
position += perUpdateDistance
// 如果动画还没结束,则准备在下一帧更新动画
if (position < endPosition) {
rAF(step)
} else {
console.log('动画结束')
}
}
step() // 开始运行动画
你可以查看这段代码的运行效果。
理解了动画运行的过程之后,上面的代码很容易就可以封装成一个可以重复使用的 tween 函数:
const updateTime = 1000 / 60
const rAF = window.requestAnimationFrame || function(cb) { setTimeout(cb, updateTime) }
/**
* 简单的执行缓动函数的方法
* @param {number} startValue - 初始值
* @param {number} endValue - 最终值
* @param {number} during - 持续时间
* @param {function} easingFunc - 缓动函数
* @param {function} stepCb - 每次更新动画状态后执行的函数
* @return {Promise}
*/
function tween (startValue, endValue, during, easingFunc, stepCb) {
const changeValue = endValue - startValue
const updateCount = during / updateTime
const perUpdateDistance = 1 / updateCount
let position = 0
return new Promise(function(resolve) {
function step () {
const state = startValue + changeValue * easingFunc(position)
stepCb(state)
position += perUpdateDistance
if (position < 1) {
rAF(step)
} else {
resolve()
}
}
step()
})
}
使用 tween 函数实现同样的弹簧效果可以这样写:
const div = document.getElementsByTagName('div')[0]
tween(100, 400, 2000, easeOutElastic, height => div.style.height = height + 'px')
.then(() => console.log('动画结束'))
这样,你就可以代入 jQuery Easing 插件中所有的缓动函数并尝试不同的动画效果了。
写在最后
jQuery Easing 插件在 v1.3.2 及之前的版本实现的缓动函数有五个参数,但这种实现方式参杂了运动的过程所以无法细粒度的控制动画的进度,不建议使用。
另外,文中实现的 tween 函数比较粗糙,并且一次只能变化一个属性,在实际项目中可以使用开源社区的其它实现,例如 Shifty。
最后,希望这篇文章能帮助大家更好的理解和制作动画,谢谢。