说明
【跟月影学可视化】学习笔记。
动画的三种形式
固定帧动画:预先准备好要播放的静态图像,然后将这些图依次播放,实现起来最简单,只需要为每一帧准备一张图片,然后循环播放即可。
增量动画:就是在每帧给元素的相关属性增加一定的量,但也很好操作,就是不好精确控制动画细节。
时序动画:使用时间和动画函数来计算每一帧中的关键属性值,然后更新这些属性,这种方法能够非常精确地控制动画的细节,所以它能实现的动画效果更丰富,应用最广泛。
实现固定帧动画
3个静态图像如下:
<!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>
实现增量动画
实现橙红色方块旋转的动画:给这个方块的每一帧增加一个 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>
实现时序动画
以上面的方块旋转为例,首先定义初始时间和周期,然后在 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>
虽然时序动画实现起来比增量动画写法更复杂,但我们可以更直观、精确地控制旋转动画的周期(速度)、起始角度等参数。
定义标准动画模型
定义一个类 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>
插值与缓动函数
用 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>
下面加入缓动函数,抽象出一个映射函数专门处理 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>
贝塞尔曲线缓动
三次贝塞尔曲线的参数方程:
贝塞尔缓动函数:
可以使用 牛顿迭代法(Newton’s method) 把三次贝塞尔曲线参数方程变换成贝塞尔曲线缓动函数。
我们可以使用现成的 JavaScript 库 bezier-easing 来生成贝塞尔缓动函数。
更多的贝塞尔缓动函数可以查看https://easings.net/#
下面我使用这个效果对比一下https://easings.net/#easeInOutExpo
<!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>