说明

【跟月影学可视化】学习笔记。

动画的三种形式

  • 固定帧动画:预先准备好要播放的静态图像,然后将这些图依次播放,实现起来最简单,只需要为每一帧准备一张图片,然后循环播放即可。
  • 增量动画:就是在每帧给元素的相关属性增加一定的量,但也很好操作,就是不好精确控制动画细节。
  • 时序动画:使用时间和动画函数来计算每一帧中的关键属性值,然后更新这些属性,这种方法能够非常精确地控制动画的细节,所以它能实现的动画效果更丰富,应用最广泛。

实现固定帧动画

3个静态图像如下:

【视觉高级篇】18 # 如何生成简单动画让图形动起来?_时序动画

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>实现固定帧动画</title>
<style>
.bird {
position: absolute;
left: 100px;
top: 100px;
width:86px;
height:60px;
zoom: 0.5;
background-repeat: no-repeat;
background-image: url(./assets/img/bird.png);
background-position: -178px -2px;
animation: flappy .5s step-end infinite;
}

@keyframes flappy {
0% {background-position: -178px -2px;}
33% {background-position: -90px -2px;}
66% {background-position: -2px -2px;}
}
</style>
</head>
<body>
<div class="bird"></div>
</body>
</html>

【视觉高级篇】18 # 如何生成简单动画让图形动起来?_缓动函数_02

实现增量动画

实现橙红色方块旋转的动画:给这个方块的每一帧增加一个 rotate 角度。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>实现增量动画</title>
<style>
.block {
width: 100px;
height: 100px;
top: 100px;
left: 100px;
transform-origin: 50% 50%;
position: absolute;
background: salmon;
}
</style>
</head>
<body>
<div class="block"></div>
<script>
const block = document.querySelector(".block");
let rotation = 0;
function update() {
block.style.transform = `rotate(${rotation++}deg)`;
requestAnimationFrame(update);
}
update();
</script>
</body>
</html>

【视觉高级篇】18 # 如何生成简单动画让图形动起来?_时序动画_03

实现时序动画

以上面的方块旋转为例,首先定义初始时间和周期,然后在 update 中计算当前经过时间和进度 p,最后通过 p 来更新动画元素的属性。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>实现时序动画</title>
<style>
.block {
width: 100px;
height: 100px;
top: 100px;
left: 100px;
transform-origin: 50% 50%;
position: absolute;
background: salmon;
}
</style>
</head>
<body>
<div class="block"></div>
<script>
const block = document.querySelector(".block");
const startAngle = 0; // 起始旋转角度
const T = 2000; // 旋转周期
let startTime = null; // 初始旋转的时间
function update() {
startTime = startTime == null ? Date.now() : startTime;
const p = (Date.now() - startTime) / T; // 旋转进度 = 当前经过的时间 / 旋转周期
const angle = startAngle + p * 360; // 当前角度
block.style.transform = `rotate(${angle}deg)`;
requestAnimationFrame(update);
}
update();
</script>
</body>
</html>

【视觉高级篇】18 # 如何生成简单动画让图形动起来?_贝塞尔曲线_04

虽然时序动画实现起来比增量动画写法更复杂,但我们可以更直观、精确地控制旋转动画的周期(速度)、起始角度等参数。

定义标准动画模型

定义一个类 Timing 用来处理时间:

// 类 Timing 用来处理时间
export class Timing {
constructor({ duration, iterations = 1 } = {}) {
this.startTime = Date.now();
this.duration = duration;
this.iterations = iterations;
}

get time() {
return Date.now() - this.startTime;
}

get p() {
const progress = Math.min(this.time / this.duration, this.iterations);
return this.isFinished ? 1 : progress % 1;
}

get isFinished() {
return this.time / this.duration >= this.iterations;
}
}

实现一个 Animator 类,用来真正控制动画过程:

import { Timing } from './timing.js';
// Animator 类,用来真正控制动画过程
export class Animator {
constructor({ duration, iterations }) {
this.timing = { duration, iterations };
}

animate(target, update) {
let frameIndex = 0;
const timing = new Timing(this.timing);

return new Promise((resolve) => {
function next() {
if (update({ target, frameIndex, timing }) !== false && !timing.isFinished) {
requestAnimationFrame(next);
} else {
resolve(timing);
}
frameIndex++;
}
next();
});
}
}

用 Animator 实现四个方块的轮换转动:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>定义标准动画模型</title>
<style>
.container {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
width: 300px;
}
.block {
width: 100px;
height: 100px;
margin: 20px;
flex-shrink: 0;
transform-origin: 50% 50%;
}
.block:nth-child(1) {
background: salmon;
}
.block:nth-child(2) {
background: slateblue;
}
.block:nth-child(3) {
background: seagreen;
}
.block:nth-child(4) {
background: sandybrown;
}
</style>
</head>
<body>
<div class="container">
<div class="block"></div>
<div class="block"></div>
<div class="block"></div>
<div class="block"></div>
</div>
<script type="module">
import { Animator } from "./common/lib/animator/index.js";
const blocks = document.querySelectorAll(".block");
const animator = new Animator({
duration: 1000,
iterations: 1.5
});
(async function () {
let i = 0;
while (true) {
await animator.animate(
blocks[i++ % 4],
({ target, timing }) => {
target.style.transform = `rotate(${timing.p * 360}deg)`;
}
);
}
})();
</script>
</body>
</html>

【视觉高级篇】18 # 如何生成简单动画让图形动起来?_缓动函数_05

插值与缓动函数

用 Animator 实现一个方块,让它从 0px 处匀速运动到 400px 处。

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>插值与缓动函数</title>
<style>
.block {
position: relative;
width: 100px;
height: 100px;
background: salmon;
}
</style>
</head>
<body>
<div class="block"></div>
<script type="module">
import { Animator } from "./common/lib/animator/index.js";
const block = document.querySelector(".block");
const animator = new Animator({ duration: 3000 });
document.addEventListener('click', () => {
animator.animate({
el: block,
start: 0,
end: 400
}, ({
target: { el, start, end },
timing: { p }
}) => {
// 线性插值方法
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
});
</script>
</body>
</html>

【视觉高级篇】18 # 如何生成简单动画让图形动起来?_贝塞尔曲线_06

下面加入缓动函数,抽象出一个映射函数专门处理 p 的映射,这个函数叫做缓动函数(Easing Function)

// 类 Timing 用来处理时间
export class Timing {
constructor({ duration, iterations = 1, easing = p => p } = {}) {
this.startTime = Date.now();
this.duration = duration;
this.iterations = iterations;
this.easing = easing;
}

get time() {
return Date.now() - this.startTime;
}

get p() {
const progress = Math.min(this.time / this.duration, this.iterations);
return this.isFinished ? 1 : this.easing(progress % 1);
}

get isFinished() {
return this.time / this.duration >= this.iterations;
}
}
export class Animator {
constructor({ duration, iterations, easing }) {
this.timing = { duration, iterations, easing };
}
}

下面对比一下加了缓动函数 ​​easing: p => p ** 2​

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>插值与缓动函数2</title>
<style>
.block {
position: relative;
width: 100px;
height: 100px;
background: salmon;
}
.block2 {
position: relative;
width: 100px;
height: 100px;
background: seagreen;
}
</style>
</head>
<body>
<h2>匀速</h2>
<div class="block"></div>
<h2>加了缓动函数,匀加速</h2>
<div class="block2"></div>
<script type="module">
import { Animator } from "./common/lib/animator/index.js";
const block = document.querySelector(".block");
const animator = new Animator({
duration: 3000
});
const block2 = document.querySelector(".block2");
const animator2 = new Animator({
duration: 3000,
easing: p => p ** 2
});
document.addEventListener('click', () => {
animator.animate({
el: block,
start: 0,
end: 400
}, ({
target: { el, start, end },
timing: { p }
}) => {
// 线性插值方法
console.log("animator--->p", p)
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
animator2.animate({
el: block2,
start: 0,
end: 400
}, ({
target: { el, start, end },
timing: { p }
}) => {
// 线性插值方法
console.log("animator2--->p", p)
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
});
</script>
</body>
</html>

【视觉高级篇】18 # 如何生成简单动画让图形动起来?_增量动画_07

贝塞尔曲线缓动

三次贝塞尔曲线的参数方程:

【视觉高级篇】18 # 如何生成简单动画让图形动起来?_贝塞尔曲线_08

贝塞尔缓动函数:

【视觉高级篇】18 # 如何生成简单动画让图形动起来?_贝塞尔曲线_09

可以使用 牛顿迭代法(Newton’s method) 把三次贝塞尔曲线参数方程变换成贝塞尔曲线缓动函数。

我们可以使用现成的 JavaScript 库 ​​bezier-easing​​ 来生成贝塞尔缓动函数。

更多的贝塞尔缓动函数可以查看​​https://easings.net/#​

下面我使用这个效果对比一下​​https://easings.net/#easeInOutExpo​

【视觉高级篇】18 # 如何生成简单动画让图形动起来?_固定帧动画_10

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>贝塞尔曲线缓动</title>
<style>
.block {
position: relative;
width: 100px;
height: 100px;
background: salmon;
}
.block2 {
position: relative;
width: 100px;
height: 100px;
background: seagreen;
}
.block3 {
position: relative;
width: 100px;
height: 100px;
background: slateblue;
}
</style>
</head>
<body>
<h2>匀速</h2>
<div class="block"></div>
<h2>加了缓动函数,匀加速</h2>
<div class="block2"></div>
<h2>贝塞尔曲线缓动</h2>
<div class="block3"></div>
<script src="./common/lib/animator/bezier-easing.js"></script>
<script type="module">
import { Animator } from "./common/lib/animator/index.js";
const block = document.querySelector(".block");
const animator = new Animator({
duration: 3000
});
const block2 = document.querySelector(".block2");
const animator2 = new Animator({
duration: 3000,
easing: p => p ** 2
});
const block3 = document.querySelector(".block3");
// 使用 easeInOutExpo (0.87, 0, 0.13, 1) 效果 https://easings.net/#easeInOutExpo
const animator3 = new Animator({
duration: 3000,
easing: BezierEasing(0.87, 0, 0.13, 1)
});
document.addEventListener('click', () => {
animator.animate({
el: block,
start: 0,
end: 400
}, ({
target: { el, start, end },
timing: { p }
}) => {
// 线性插值方法
console.log("animator--->p", p)
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
animator2.animate({
el: block2,
start: 0,
end: 400
}, ({
target: { el, start, end },
timing: { p }
}) => {
// 线性插值方法
console.log("animator2--->p", p)
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
animator3.animate({
el: block3,
start: 0,
end: 400
}, ({
target: { el, start, end },
timing: { p }
}) => {
// 线性插值方法
console.log("animator3--->p", p)
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
});
</script>
</body>
</html>

【视觉高级篇】18 # 如何生成简单动画让图形动起来?_贝塞尔曲线_11