前端常见动画实现原理
开发H5活动页面是前端开发中一种比较常见的需求,这种页面业务逻辑较轻,重交互和展示,是一类非常有趣的需求。本文将整理H5开发中常见Web动画的基本原理和简单实现。
逐帧动画
参考
- CSS3逐帧动画
- Twitter点赞动画,一个使用逐帧动画实现的点赞效果
逐帧动画也叫定格动画,其原理是将每帧不同的图像连续播放,从而产生动画效果
最常见的逐帧动画大概就是gif图了,在Mac上使用“预览”工具打开一张gif图片,可以在左侧看见多张静态图片。
除了直接使用gif图片之外,前端也可以使用JS或者CSS来实现逐帧动画,大致步骤为
- 提前做好每一帧的静态图片,为了减少网络请求,一般会使用精灵图,然后通过
background-postion
来控制动画的展示 - 按顺序播放每一帧,形成动画效果
逐帧动画的缺点在于:需要UI提供每一帧的图片,效率、扩展性和网络性能都比较差。
CSS可以通过@keyframes
来定义关键帧动画,每个关键帧有一个百分比值作为名称,代表在动画进行中,在哪个阶段触发这个帧所包含的样式。
同时CSS提供了animation-timing-function,用来控制两帧之前样式的改变速度,可以分为
- cubic-bezier() 定时函数
- steps() 函数
下面的例子展示了一个 flappy bird 的小鸟飞行动画,其核心实现依赖@keyframes
和step()
准备下面这张模糊到不行的精灵图
定义三帧对应的css属性,主要是找到每一帧的背景偏移量
.bird {
position: relative;
width: 46px;
height: 31px;
background-image: url("./bird.png");
background-repeat: no-repeat;
}
.bird1 {
background-position: 0 center;
}
.bird2 {
background-position: -47px center;
}
.bird3 {
background-position: -94px center;
}
- 把上面的三帧放在动画中
@keyframes fly-bird {
0% {
background-position: 0;
}
33.3333% {
background-position: -47px;
}
66.6666% {
background-position: -94px;
}
}
- 运行动画
.bird-fly {
animation: fly-bird 0.4s steps(1) infinite;
}
实现的效果为
需要理解的是animation-timing-function
是作用于两个 keyframes 之间,而非整个动画,因此这里steps(1)
的含义,指的是0~33.33%
阶段只执行一次跳跃,而不是整个动画0~100%
之间只执行一次跳跃,所以这里也可以换一种更简单的写法
@keyframes fly-bird2 {
100% {
background-position: -141px;
}
}
.bird-fly {
// 指定3帧,由step自己计算每一帧的偏移
animation: fly-bird2 0.4s steps(3, end) infinite;
}
SVG描边动画
SVG描边动画时SVG中最常用的一种动画形式,其核心原理是与两个属性有关
- stroke-dasharray,主要用来绘制虚线,其值为
x,y
的形式,其中x表示实线的长度dash,y表示两根实线之间的间隔gap - stroke-dashoffset,用来定义dash线条开始的位置
描边动画的实现思路是,设置一个较大的dash和gap,然后通过设置较大的stroke-dashoffset
在初始状态下隐藏整个实线,只看见空白部分,然后通过缩小stroke-dashoffset
将实现部分逐渐展示,就形成了“描边”的动画效果。
需要注意的是:绘制的虚线是按照path路径进行绘制的,因此理论上可以是任意形状!
那么具体stroke-dashoffset
和stroke-dasharray
的初始值究竟应该为多少呢?只要设置成path的长度就可以了。
path的路径并不一定是规则的,所幸的是JavaScript提供了获取path路径长度的接口
let path = document.querySelector('path');
let length = path.getTotalLength(); // 将这个值作为dashoffset和dasharray的取值
下面展示了一个使用描边动画实现的对话框
@keyframes stroke {
100% {
stroke-dashoffset: 0;
}
}
.chat_corner {
animation: stroke 0.6s linear 0.3s forwards;
}
.chat-path {
width: 110px;
margin: -5px auto 0;
}
.chat-path .chat_corner {
transform-origin: 50% 50%;
stroke-dasharray: 641;
stroke-dashoffset: 641;
}
实现的效果为
canvas动画
canvas动画可以理解成JS版的逐帧动画,主要原理是在每一帧手动计算元素的状态并将其绘制到画布上。
基本动画
下面的代码展示了使用canvas绘制一个一个运动的小球
let canvas = document.getElementById("myCanvas");
let ctx = canvas.getContext("2d");
let ball = {
x: 0,
y: 100,
r: 10,
dx: 2,
rgbaColorArr: [111, 123, 222, 1],
draw() {
const {x, y, r, rgbaColorArr} = this
ctx.beginPath()
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.closePath()
ctx.fillStyle = `rgba(${rgbaColorArr.join(',')})`
ctx.fill();
},
move() {
this.x += this.dx
this.draw()
},
animate() {
const d = 1000 // 动画时长 1000ms
const update = (t) => {
// 清除画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 移动小球并重新绘制
this.x += this.dx
this.draw()
if (t < d) {
requestAnimationFrame(update);
}
}
requestAnimationFrame(update);
}
}
ball.animate()
代码比较简单,具体实现为
- 通过
requestAnimationFrame
清除画布并绘制每一帧的内容 - 在每一帧中,修改
this.x
横坐标的位置,然后调用ball.braw
方法重新绘制小球
这样,肉眼就观察到了小球运动的画面。
从上面的代码可以看出,小球运动的关键是
this.x += this.dx
假设每一帧的间隔时间是相同的,那么我们看见的小球就会做匀速运动,
- 第一帧小球的横坐标为
x + dx
- 第二帧小球的横坐标为
x + 2*dx
- ...
换个思路,我们只要控制了x变量的变化,就可以实现非匀速的动画效果,比如大名鼎鼎的tween.js
大部分缓动方法都包含四个参数
// t: 当前时间
// b: 开始的状态
// c: 变化的状态
// d: 从b变成b+c需要的时间
function linear(t, b, c, d) {
return c * t / d + b
}
function easeIn(t, b, c, d) {
return c * (t /= d) * t + b;
}
比如linear(1000, 10, 100, 2000)
,表示的含义为:一个从起始状态10到结束状态10+100、耗时2000ms的linear线性动画,在1000ms时对应的状态。需要注意t
和d
只要保证单位一致就行,不需要强制将其转换成秒或者毫秒
因此修改一下ball.animate
方法中关于this.x
的计算方式
let ball = {
// ... 其他属性
animate() {
const d = 1000 // 运动时间1000ms
const target = canvas.width - this.r * 2 // 目标位置
const update = (t) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// this.x += this.dx
// this.x = linear(t, 0, target, d) 等价于上面的this.x += this.dx
this.x = easeIn(t, 0, target, d) // 缓动函数easeIn
this.draw()
if (t < d) {
requestAnimationFrame(update);
}
}
requestAnimationFrame(update);
}
}
就可以看见不同的运动效果了。牢记一点:我们改变的并不是动画运行的时间,而是根据时间函数计算小球在某一个时刻的状态,然后将它更新到画布上,这是很多动画的基础。
除了缓动函数之外,还可以使用贝塞尔曲线来计算元素的状态
参考
粒子动画
粒子可以理解为画布的最小组成单位——像素,也可以某些面积比较小的点。单个粒子很难看出展示效果,一般会使用大量的粒子来描述一些不规则的物体,常用在游戏系统中,如火焰、烟花等。
参考
实现粒子动画大致可以分为下面步骤
- 定义粒子,一个粒子的基本属性包括:位置、尺寸、颜色、速度、停留时长等
- 生成粒子,可以直接遍历动态生成,也可以提前准备好粒子(如使用
getImageData
获取画布上某个区域的像素点) - 发射粒子,将粒子渲染在每一帧的画布上,这一步跟上面单个小球的动画展示很像,只是我们现在需要处理成千上万个粒子
一个粒子的基本属性包括:初始位置、终止位置、颜色和形状,此外还粒子往往还包括持续时间、延迟等等属性
基础效果
下面展示了一个将从画布上获取像素点并展示基本的粒子效果
其核心代码为
const pos = {x: 50, y: 50, w: 100, h: 100}
const originX = pos.x + pos.w / 2
const originY = pos.y + pos.h + 200
// 使用一个数组保存生成的粒子
let particles = []
const reductionFactor = 5
for (let x = 0; x < pos.w; x += reductionFactor) {
for (let y = 0; y < pos.h; y += reductionFactor) {
let particle = {
// 初始位置
x0: originX,
y0: originY,
// 终止位置
x1: x + pos.x,
y1: y + pos.y,
// 样式属性
rgbaColorArr: randomColor(),
currTime: 0, // 粒子已经运行的时间
duration: 3000,
};
particles.push(particle);
}
}
然后遍历列表,依次绘制每个粒子动画,
function easeInOutExpo(e, a, g, f) {
return g * (-Math.pow(2, -10 * e / f) + 1) + a
}
let requestId
function renderParticles(t) {
// 最后一个粒子
const last = particles[particles.length - 1]
if (last.duration < last.currTime) {
cancelAnimationFrame(requestId)
return
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let p of particles) {
const {duration, currTime} = p
ctx.fillStyle = `rgba(${p.rgbaColorArr.join(',')})`
if (currTime < duration) {
let x = easeInOutExpo(currTime, p.x0, p.x1 - p.x0, duration);
let y = easeInOutExpo(currTime, p.y0, p.y1 - p.y0, duration);
ctx.fillRect(x, y, 1, 1)
} else {
ctx.fillRect(p.x1, p.y1, 1, 1)
}
p.currTime = t
}
requestId = requestAnimationFrame(renderParticles)
}
const animate = () => {
requestId = requestAnimationFrame(renderParticles)
}
animate()
从动画效果可以看见,对单个粒子而言,其动画实现与上面“面积比较大的”小球并没有什么区别。但就整体而言,整个动画显得非常生硬。
让粒子动画更真实
如果要让粒子动画显得真实,就不能让动画效果过于整体,要让每个粒子间隔启动,并交替执行动画,有两种实现间隔的方式
- 让每一行粒子启动时间有规律错开
- 每一行粒子之间启动时间随机的错开
粒子独立的颗粒感与整体的层次感是保证粒子动画真实的主要原因。
因此对代码稍作修改,添加一些随机参数控制
const frameTime = 16.66 // 假设一帧为16.66ms
for (let x = 0; x < pos.w; x += reductionFactor) {
for (let y = 0; y < pos.h; y += reductionFactor) {
let particle = {
// ... 其他参数
delay: y / 20 * frameTime, // 延迟启动的时间,按行来区分,突出层次感
interval: parseInt(Math.random() * 10 * frameTime), // 每个粒子的启动间隔时间,随机1到10帧,突出粒子的颗粒感
}
particles.push(particle);
}
}
然后再绘制粒子时,加入对delay
和interval
的处理
for (let p of particles) {
// 未到启动时间,则直接跳过
if (p.currTime < p.delay) {
p.currTime = t
continue
}
const {duration, currTime, interval} = p
ctx.fillStyle = `rgba(${p.rgbaColorArr.join(',')})`
if (currTime < duration + interval) {
// 引入interval控制每个粒子的启动间隔
if (currTime >= interval) {
let x = easeInOutExpo(currTime - interval, p.x0, p.x1 - p.x0, duration)
let y = easeInOutExpo(currTime - interval, p.y0, p.y1 - p.y0, duration)
ctx.fillRect(x, y, 1, 1)
}
} else {
ctx.fillRect(p.x1, p.y1, 1, 1)
}
p.currTime = t
}
物理效果
除了使用缓动函数和贝塞尔曲线来控制每一帧元素的状态之外,我们还可以模拟更真实的物理效果,如抛物线小球、自由落体、物体碰撞等
这里需要掌握更多的物理和数学知识,比如向量、数学矢量等,这里不再展开
FLIP实现DOM切换动画
FLIP stands for First, Last, Invert, Play.
参考
FLIP是一种实现DOM状态切换动画的方案
- First: 元素的初始状态
- Last: 元素的终止状态
- Invert: 这是整个动画实现的核心思想,比如一个元素初始x位置是0,向右移动到了90px的位置,为了实现动画效果,我们可以将元素的初始状态设置成
transform: translate
- Play: 播放动画,即动画回到他本来的位置
由于DOM元素属性的变化会被浏览器收集起来并延迟到下一帧进行渲染,因此在某个时间段上,元素的DOM信息发生了变化、但浏览器还没有渲染,这样我们就能拿到元素初始和终止的两个状态,从而实现Invert
下面是在Vue中使用FLIP实现数组乱排的动画效果,核心代码是
function getPositionList(domList) {
return domList.map((dom) => {
const {left, top} = dom.getBoundingClientRect()
return {left, top}
})
}
this.list = shuffle(this.list)
const cellList = this.$refs.cell.slice() // 保存快照
// 获取初始状态
const originPositions = getPositionList(cellList)
this.$nextTick(() => {
// 获取节点新的状态
// 需要保证变化前后的DOM节点一致,因此v-for的key不能使用index
const currentPositions = getPositionList(cellList)
cellList.forEach((cell, index) => {
let cur = currentPositions[index]
let origin = originPositions[index]
const invert = {
left: origin.left - cur.left,
top: origin.top - cur.top,
}
const keyframes = [
{transform: `translate(${invert.left}px, ${invert.top}px)`},
{transform: "translate(0)"},
]
const options = {
duration: 300,
easing: "linear",
}
cell.animate(keyframes, options)
})
})
在React中可以使用useLayoutEffect
在DOM更新后执行逻辑。需要注意的是,FLIP的核心是获取到元素改变前后的状态,其前提是针对同一个元素,所以对于vnode而言,其key值要唯一,从而保证比较的是同一个DOM节点,而不是“就地复用”
lottie:抱住设计大腿
参考
- lottie官网
- lottie files,提供了大量lottie动画,可以去上面下载相关JSON文件
- lottie editor,支持对于lottie动画的简单编辑
Lottie是基于CALayer的动画, 所有的路径预先在AE中计算好, 转换为Json文件, 然后自动转换为Layer的动画,
因此我们只需要抱住设计的大腿就好了,对开发来说工作量很少~
开发接入步骤:
- 导出
lottie-web
库,导入动画JSON文件 lottie.loadAnimation
运行文件
lottie可以运行在iOS、Android甚至于Flutter等多个平台,当需要一些比较复杂的动画时,去求求设计大佬吧
小结
本文整理了几种常见的web动画开发思路
- 逐帧动画,最常见的动画形式
- 描边动画,使用SVG实现的path动画效果
- 粒子动画,使用canvas实现酷炫的粒子效果
- FLIP动画,主要用于DOM切换等效果
实现动画,除了解其原理之外,还需要调试动画的运行时间、切换速度等参数,这需要大量的经验和经历,大概这也算做是前端的一个乐趣吧。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。