JS 动画原理与实现
主讲人
- 吴亮
- 360前端技术专家
- 奇舞团
动画
动画的基本原理:
- 定时器改变对象的属性
- 根据新的属性重新渲染动画
function update(context) {
// 更新属性
}
const ticker = new Ticker();
ticker.tick(update, context);
动画的种类:
- JavaScript 动画
- 操作DOM
- Canvas
- CSS 动画
- transition
- animation
- SVG 动画
- SMIL
JS动画的优缺点:
优点:灵活度、可控性、性能
缺点:易用性
简单动画
let rotation = 0;
requestAnimationFrame(function update() {
block.style.transform = `rotate(${rotation++}deg)`;
requestAnimationFrame(update);
});
另一个版本
let rotation = 0;
let startTime = null;
const T = 2000;
requestAnimationFrame(function update() {
if(!startTime) startTime = Date.now();
const p = (Date.now() - startTime)/T;
block.style.transform = `rotate(${360 * p}deg)`;
requestAnimationFrame(update);
});
通用化
function update({target}, count) {
target.style.transform = `rotate(${count++}deg)`;
}
class Ticker {
tick(update, context) {
let count = 0;
requestAnimationFrame(function next() {
if(update(context, ++count) !== false) {
requestAnimationFrame(next);
}
});
}
}
const ticker = new Ticker();
ticker.tick(update, {target: block});
通用化2
function update({target}, {time}) {
target.style.transform = `rotate(${360 * time / 2000}deg)`;
}
class Ticker {
tick(update, context) {
let count = 0;
let startTime = Date.now();
requestAnimationFrame(function next() {
count++;
const time = Date.now() - startTime;
if(update(context, {count, time}) !== false) {
requestAnimationFrame(next);
}
});
}
}
const ticker = new Ticker();
ticker.tick(update, {target: block});
通用化3
function update({context}, {time}) {
context.clearRect(0, 0, 512, 512);
context.save();
context.translate(100, 100);
context.rotate(time * 0.005);
context.fillStyle = '#00f';
context.fillRect(-50, -50, 100, 100);
context.restore();
}
class Ticker {
tick(update, context) {
let count = 0;
let startTime = Date.now();
requestAnimationFrame(function next() {
count++;
const time = Date.now() - startTime;
if(update(context, {count, time}) !== false) {
requestAnimationFrame(next);
}
});
}
}
Timing
class Timing {
constructor({duration, easing} = {}) {
this.startTime = Date.now();
this.duration = duration;
this.easing = easing || function(p){return p};
}
get time() {
return Date.now() - this.startTime;
}
get p() {
return this.easing(Math.min(this.time / this.duration, 1.0));
}
}
class Ticker {
tick(update, context, timing) {
let count = 0;
timing = new Timing(timing);
requestAnimationFrame(function next() {
count++;
if(update(context, {count, timing}) !== false) {
requestAnimationFrame(next);
}
});
}
}
function update({target}, {timing}) {
target.style.transform = `translate(${200 * timing.p}px, 0)`;
}
const ticker = new Ticker();
ticker.tick(update,
{target: block},
{duration: 2000}
);
匀速运动
匀速运动:2s 内向右匀速运动 200px
function update({target}, {timing}) {
target.style.transform = `translate(${200 * timing.p}px, 0)`;
}
const ticker = new Ticker();
ticker.tick(update,
{target: block},
{duration: 2000}
);
自由落体运动
代码:
function update({target}, {timing}) {
target.style.transform = `translate(0, ${200 * timing.p}px)`;
}
const ticker = new Ticker();
ticker.tick(update, {target: block}, {
duration: 2000,
easing: p => p ** 2,
});
摩擦力:
代码:
function update({target}, {timing}) {
target.style.transform = `translate(${200 * timing.p}px, 0)`;
}
const ticker = new Ticker();
ticker.tick(update, {target: block}, {
duration: 2000,
easing: p => p * (2 - p),
});
平抛
class Timing {
constructor({duration, easing} = {}) {
this.startTime = Date.now();
this.duration = duration;
this.easing = easing || function(p){return p};
}
get time() {
return Date.now() - this.startTime;
}
get op() {
return Math.min(this.time / this.duration, 1.0);
}
get p() {
return this.easing(this.op);
}
}
function update({target}, {timing}) {
target.style.transform =
`translate(${200 * timing.op}px, ${200 * timing.p}px)`;
}
旋转+平抛
function update({target}, {timing}) {
target.style.transform = `
translate(${200 * timing.op}px, ${200 * timing.p}px)
rotate(${720 * timing.op}deg)
`;
}
贝塞尔轨迹
代码:
function bezierPath(x1, y1, x2, y2, p) {
const x = 3 * x1 * p * (1 - p) ** 2 + 3 * x2 * p ** 2 * (1 - p) + p ** 3;
const y = 3 * y1 * p * (1 - p) ** 2 + 3 * y2 * p ** 2 * (1 - p) + p ** 3;
return [x, y];
}
function update({target}, {timing}) {
const [px, py] = bezierPath(0.2, 0.6, 0.8, 0.2, timing.p);
target.style.transform = `translate(${100 * px}px, ${100 * py}px)`;
}
const ticker = new Ticker();
ticker.tick(update, {target: block}, {
duration: 2000,
easing: p => p * (2 - p),
});
bezier-easing
B(px) 作为输入, B(py) 作为输出
通过牛顿迭代,从B(px)求p,从p求B(py)
function update({target}, {timing}) {
target.style.transform = `translate(${100 * timing.p}px, 0)`;
}
const ticker = new Ticker();
ticker.tick(update, {target: block}, {
duration: 2000,
easing: BezierEasing(0.5, -1.5, 0.5, 2.5),
});
bezier-easing 轨迹
function update({target}, {timing}) {
target.style.transform =
`translate(${100 * timing.p}px, ${100 * timing.op}px)`;
}
const ticker = new Ticker();
ticker.tick(update, {target: block}, {
duration: 2000,
easing: BezierEasing(0.5, -1.5, 0.5, 2.5),
});
椭圆轨迹
周期运动
class Timing {
constructor({duration, easing, iterations = 1} = {}) {
this.startTime = Date.now();
this.duration = duration;
this.easing = easing || function(p){return p};
this.iterations = iterations;
}
get time() {
return Date.now() - this.startTime;
}
get finished() {
return this.time / this.duration >= 1.0 * this.iterations;
}
get op() {
let op = Math.min(this.time / this.duration, 1.0 * this.iterations);
if(op < 1.0) return op;
op -= Math.floor(op);
return op > 0 ? op : 1.0;
}
get p() {
return this.easing(this.op);
}
}
椭圆周期运动
function update({target}, {timing}) {
const x = 150 * Math.cos(Math.PI * 2 * timing.p);
const y = 100 * Math.sin(Math.PI * 2 * timing.p);
target.style.transform = `
translate(${x}px, ${y}px)
`;
}
const ticker = new Ticker();
ticker.tick(update, {target: block},
{duration: 2000, iterations: 10});
连续运动
class Ticker {
tick(update, context, timing) {
let count = 0;
timing = new Timing(timing);
return new Promise((resolve) => {
requestAnimationFrame(function next() {
count++;
if(update(context, {count, timing}) !== false && !timing.finished) {
requestAnimationFrame(next);
} else {
resolve(timing);
}
});
});
}
}
线性插值(lerp)
f(p) = from + (to - from) * p
f(p) = to * p + from * (1 - p)
function lerp(setter, from, to) {
return function({target}, {timing}) {
const p = timing.p;
const value = {};
for(let key in to) {
value[key] = to[key] * p + from[key] * (1 - p);
}
setter(target, value);
}
}
线性插值
function setValue(target, value) {
for(let key in value) {
target.style[key] = `${value[key]}px`;
}
}
const left = lerp(setValue, {left: 100}, {left: 300});
const down = lerp(setValue, {top: 100}, {top: 300});
const right = lerp(setValue, {left: 300}, {left: 100});
const up = lerp(setValue, {top: 300}, {top: 100});
(async function() {
const ticker = new Ticker();
await ticker.tick(left, {target: block},
{duration: 2000});
await ticker.tick(down, {target: block},
{duration: 2000});
await ticker.tick(right, {target: block},
{duration: 2000});
await ticker.tick(up, {target: block},
{duration: 2000});
})();
弹跳的小球
const down = lerp(setValue, {top: 100}, {top: 300});
const up = lerp(setValue, {top: 300}, {top: 100});
(async function() {
const ticker = new Ticker();
// noprotect
while(1) {
await ticker.tick(down, {target: block},
{duration: 2000, easing: p => p * p});
await ticker.tick(up, {target: block},
{duration: 2000, easing: p => p * (2 - p)});
}
})();
弹跳的小球2
(async function() {
const ticker = new Ticker();
let damping = 0.7,
duration = 2000,
height = 300;
// noprotect
while(height >= 1) {
let down = lerp(setValue, {top: 400 - height}, {top: 400});
await ticker.tick(down, {target: block},
{duration, easing: p => p * p});
height *= damping ** 2;
duration *= damping;
let up = lerp(setValue, {top: 400}, {top: 400 - height});
await ticker.tick(up, {target: block},
{duration, easing: p => p * (2 - p)});
}
})();
滚动
const roll = lerp((target, {left, rotate}) => {
target.style.left = `${left}px`;
target.style.transform = `rotate(${rotate}deg)`;
},
{left: 100, rotate: 0},
{left: 414, rotate: 720});
const ticker = new Ticker();
ticker.tick(roll, {target: block},
{duration: 2000, easing: p => p});
平稳变速
function forward(target, {y}) {
target.style.top = `${y}px`;
}
(async function() {
const ticker = new Ticker();
await ticker.tick(
lerp(forward, {y: 100}, {y: 200}),
{target: block},
{duration: 2000, easing: p => p * p});
await ticker.tick(
lerp(forward, {y: 200}, {y: 300}),
{target: block},
{duration: 1000, easing: p => p});
await ticker.tick(
lerp(forward, {y: 300}, {y: 350}),
{target: block},
{duration: 1000, easing: p => p * (2 - p)});
}());
甩球
function circle({target}, {timing}) {
const p = timing.p;
const rad = Math.PI * 2 * p;
const x = 200 + 100 * Math.cos(rad);
const y = 200 + 100 * Math.sin(rad);
target.style.left = `${x}px`;
target.style.top = `${y}px`;
}
function shoot({target}, {timing}) {
const p = timing.p;
const rad = Math.PI * 0.2;
const startX = 200 + 100 * Math.cos(rad);
const startY = 200 + 100 * Math.sin(rad);
const vX = -100 * Math.PI * 2 * Math.sin(rad);
const vY = 100 * Math.PI * 2 * Math.cos(rad);
const x = startX + vX * p;
const y = startY + vY * p;
target.style.left = `${x}px`;
target.style.top = `${y}px`;
}
(async function() {
const ticker = new Ticker();
await ticker.tick(circle, {target: block},
{duration: 2000, easing: p => p, iterations: 2.1});
await ticker.tick(shoot, {target: block},
{duration: 2000});
}());
逐帧动画
Web Animation API(Working Draft)
element.animate(keyframes, options);
target.animate([
{backgroundColor: '#00f', width: '100px', height: '100px', borderRadius: '0'},
{backgroundColor: '#0a0', width: '200px', height: '100px', borderRadius: '0'},
{backgroundColor: '#f0f', width: '200px', height: '200px', borderRadius: '100px'},
], {
duration: 5000,
fill: 'forwards',
});
代码:
function animate(target, keyframes, options) {
const anim = target.animate(keyframes, options);
return new Promise((resolve) => {
anim.onfinish = function() {
resolve(anim);
}
});
}
(async function() {
await animate(ball1, [
{top: '10px'},
{top: '150px'},
], {
duration: 2000,
easing: 'ease-in-out',
fill: 'forwards',
});
await animate(ball2, [
{top: '200px'},
{top: '350px'},
], {
duration: 2000,
easing: 'ease-in-out',
fill: 'forwards',
});
await animate(ball3, [
{top: '400px'},
{top: '550px'},
], {
duration: 2000,
easing: 'ease-in-out',
fill: 'forwards',
});
}());