实现: JavaScript
最近业务需要,做了好多交互动画和过渡动画。有Canvas的,有Dom的,也有CSS的,封装的起点都不一样,五花八门。
而静下来仔细想想,其实不管怎么实现,本质都是一样。可以抽象一下。
View = f(s)
其中s
指某些状态,大多数情况下都是时间。
到底什么是动画?
动画的本(du)质(yin)
大家来跟我一起念 : 动 ~ 画 ~
对对对,就是动起来的画面。
不知道大家小时候玩过下面这个没有...
小本本一翻起来,画面快速的变化,看起来就像在动一样,当时感觉超级神奇。
当然现在大家都明白了这是视觉暂留,先驱依据这个造出了显示器,也造就了我们现在的动画模式。
所以,动画就是一组不连续的画面快速播放,利用脑补形成的动起来的错觉。
动画原理 : 一次次的观测
现在大家脑补一个 真空中匀速直线运动的 小球
然后掏出一个相机,对它一顿疯狂拍摄。在下手手法不佳,拍的一点也不均匀。
我把每一次拍照的行为称为一次 观测
- 例子里的小球的运动只受到时间的影响
- 不论观测的次数有多少,都不会影响小球的运动过程
- 每次的观测都会产生一个画面(
View
)
把每次观测的时间t
和小球的位置x
记录下来。
就可以得出
(x - xStart) = v * (t - tStart)
=> x = v * (t - tStart) + xStart
这样就得到了一个 View = f(t)
的具体表现
我把 f(t)
称为对动画的 描述,它建立起了视图和时间的关联
业务场景
我们已经有了足够的概念,在业务中,我们实现一个动画:
- 抽象出一个动画描述
- 设定一个开始时间
- 不断进行观测
- 把观测结果写入视图
因为屏幕的刷新总是有一个频率,就好像是屏幕对视图的观测一样,过多的观测其实没有太大意义,最好,能和屏幕的刷新率一致(requestAnimationFrame
)。
伪代码实现
function f(t){
return v * (t - tStart) + xStart
}
while(t < tEnd){
t = now()
x = f(t)
changeView(x)
...wait...
直到下次屏幕刷新
}
纯粹的实现 - 一个数字动画
talk is cheap
定义
为了贴合浏览器的刷新频率,我们使用 requestAnimationFrame 方法。
这个方法可以在下一次屏幕刷新前注册一个回调。
/* 我们先引入屏幕刷新的回调 requestAnimationFrame
名字太长我接受不了 */
import {raf} from 'asset/util';
//我们先定义一个 Animation 类
class Animation {
duration = 0; //持续时间
Sts = null; //开始时刻(时间戳)
fn = null; //描述函数
}
接下来我们先定一个小目标,实现一个从小球从0移动到1的动画 (归一化)
持续时间为 duration
显然 f(t) = (t - tStart) / duration
;
来定义一下行为
class Animation {
//...
//初始化需要提供 持续时间 , 描述函数
constructor( duration , fn ){
this.duration = duration;
this.fn = fn;
this.Sts = Date.now();
//立即进行一次渲染
this.render();
}
render(){
const ts = Date.now(); //获取当前时间
const dt = ts - this.Sts; //计算时间差
const p = dt / this.duration; //计算小球位置
//若更新时间还在 持续时间(duration) 内
if( p < 1 ){
fn( p ); //执行传入的描述函数
raf( this.render.bind(this) ) //注册下一次屏幕刷新时的动作
//若当前时间超出 持续时间(duration) , 则直接以 1 来执行
} else {
fn( 1 );
}
}
}
好,一个基本的 Animation 类就完成了,我们来使用一下。
const setBallPosition = x => {
//... 实现略
};
new Animation( 500 , setBallPosition );
0 -> 1,1像素的动画没法看,我就不搁demo了,徐徐图之。
数字动画
上文实现了0到1的动画,现在我们来实现一个数字从10变成99的dom动画。
为了便于抽象,我们把 [ xStart , xEnd ] 映射到 [ 0 , 1 ] ,这一过程被称为归一化
我把其中的p
称为 进度
现在需要提供 [ 0 , 1 ] -> [ xStart , xEnd ] 的映射,我叫它复原过程
我们用 x = fu(p)
来表示这一过程。
什么?单词复原不是fu开头?没学过拼音吗?
比如这里的 [ 0 , 1 ] -> [ 10 , 99 ]
就是 x = fu(p) = 10 + p * ( 99 - 10 )
const el = document.getElementById('d');
el.innerText = 10;
function fu(p) {
return 10 + p * ( 99 - 10 );
}
function fn(p) {
const x = fu(p);
el.innerText = Math.floor(x);
}
window.addEventListener('touchstart', () => {
new Animation(500, fn);
});
改变时间 - 动画的时间曲线与缓动效果
举例来说,一个位移动画,物件的轨迹可以形成一条位移曲线。而时间曲线就抽象了很多。
动画的曲线
线性动画
说到动画曲线,那就不得不提到一个好玩的网站 - http://cubic-bezier.com/ 。 每次搬砖太多的时候,我都要去这个网站上拨弄几下调节一下自己。
从前文的例子中,我们的动画叫做线性动画,就像是“匀速直线运动”的小球一样,运动的进程始终如一。
想象我们在每一帧渲染的时候,都对p
进行一定的处理 q = easing(p)
,那线性动画就是 easing(p) = p
如果要用例子来描述的话,大概就是这样。
缓动动画
现在我们要模拟开始逐渐加速的场景,差不多就是下图的样子
http://cubic-bezier.com/#1,0,1,1
也就是 easing(p) = p*p
;
好,修改一下前面的demo
const el = document.getElementById('d');
el.style.width = '10px';
el.style.height = '10px';
el.style.position = 'relative';
el.style.backgroundColor = '#28c5f2';
function fu(p) {
return p * 300;
}
function easing(p) {
return p * p;
}
function fn(p) {
p = easing(p);
const x = fu(p);
el.style.left = `${Math.floor(x)}px`;
}
//为了更直观的展现区别,增加top的动画来做对比
function fn_2(p) {
const x = fu(p);
el.style.top = `${Math.floor(x)}px`;
}
window.addEventListener('touchstart', () => {
new Animation(500, fn);
new Animation(500, fn_2);
});
业务需要的封装 - 一个扇形动画作为例子
好的,上面都是玩具,接下来让我们来做一点 大人的事情吧
正好,我手上有个大饼。
UED表示:你不能直接把这个饼放到页面上。
要!加!特!技!
吓得我赶紧new了一个Image
const img = new Promise(resolve => {
const I = new Image();
I.crossOrigin = '*';
I.onload = () => resolve(I);
I.src = 'https://gw.alicdn.com/tfs/TB1Ru5vSVXXXXceXpXXXXXXXXXX-1125-750.png';
});
准备一个canvas,洗净,晾干,备用。
img.then(img => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
canvas.style.width = `${img.width / 2}px`;
canvas.style.height = `${img.height / 2}px`;
document.body.appendChild(canvas);
});
根据我多年的经验,要在整个canvas上搞事,一般会拿一个离屏canvas来提供一些内容。然后直接把离屏canvas Draw在可视canvas上。
这一步我们封在 Animation 类上
/**
* 创建一个标准的Canvas时间动画
* ------------------------------
* @param canvas 可视Canvas
* @param duration 持续时间
* @param drawingFn 绘制函数
*
* @return {Animation}
*/
Animation.createCanvasAnimation = (canvas, duration, drawingFn) => {
//创建离屏Canvas
const vc = document.createElement('CANVAS');
const {width, height} = canvas;
vc.width = width;
vc.height = height;
const vctx = vc.getContext('2d');
const ctx = canvas.getContext('2d');
//拷贝图样到离屏Canvas
vctx.drawImage(canvas, 0, 0, width, height);
return new Animation(duration, p => drawingFn(ctx, vc, p));
};
这样做的话,我们就可以在此基础上封装各种需要,像什么百叶窗动画,扇形动画,中心放射动画之类的,只需要提供一个带绘制函数的柯里化即可。
正如上面所说,我们在此基础上封装一个 wavec 方法。
实现方法
- 在可视canvas上计算出一个扇形区域并裁切画布
- 把暂存在离屏Canvas的内容转印到可视Canvas上
const PI = times => Math.PI * times;
/**
* 在目标Canvas上创建一个扇形展开动画
* ---------------------
* @param canvas 目标Canvas
* @param duration 持续时间
* @param easing 缓动函数
*
* @return {Animation}
*/
Animation.wavec = (canvas, duration, easing = p=>p) => {
return Animation.createCanvasAnimation(canvas, duration, (ctx, img, p) => {
const {width, height} = ctx.canvas;
const r = ( width + height) / 2; //最大尺寸 计算简便,懒得开方
//获取中心点
const cx = width / 2;
const cy = height / 2;
//缓动生效
p = easing(p);
//存储画布
ctx.save();
ctx.clearRect(0, 0, width, height);
//裁剪出一个扇形来
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, -PI(0.5), PI(2 * p - 0.5));
ctx.closePath();
ctx.clip();
//绘制图片(的一部分)
ctx.drawImage(img, 0, 0, width, height);
//恢复画布
ctx.restore();
});
};
这一步提供了一个默认的 easing = p=>p ,即线性动画作为默认值。
这样我们就设计了一个API Animation.wavec = function( canvas , duration , easing )
只要简单的提供 canvas , 持续时长 ,就可以完成一个扇形动画了。
把刚才洗净的 canvas 和 img 重新捡回来。
//绘制图片
canvas.getContext('2d').drawImage(img, 0, 0);
//触发动画
window.addEventListener('touchstart', () => {
Animation.wavec(canvas, 500);
});
总结与后续
- 时间动画总是能抽象为 View = f( easing(t) ) 的形式
- 通过在Animation上提供不同粒度的封装,可以满足不同层次的定制需求
本文只讲述了时间动画的一种抽象,但业务千千万万,还不够。
- 比如有些业务会需要在动画的过程中终止
- 有时终止后还会需要原路后退 (反向播放动画)
- 动画总是异步的,为了更好的开发体验,最好是可以封一套和Promise相关的Api,便于提升开发体验,异步管理,以及其他体系融合。
今天就到这里了,客官,下次再来哟 ~~