深入浅出JS动画

来源:互联网 发布:教学质量分析软件 编辑:程序博客网 时间:2024/05/16 13:07

原文地址


实现: 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) 称为对动画的 描述,它建立起了视图和时间的关联

业务场景

我们已经有了足够的概念,在业务中,我们实现一个动画:

  1. 抽象出一个动画描述
  2. 设定一个开始时间
  3. 不断进行观测
  4. 把观测结果写入视图

因为屏幕的刷新总是有一个频率,就好像是屏幕对视图的观测一样,过多的观测其实没有太大意义,*最好,能和屏幕的刷新率一致*(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 方法。

实现方法

  1. 在可视canvas上计算出一个扇形区域并裁切画布
  2. 把暂存在离屏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);});


总结与后续

  1. 时间动画总是能抽象为 View = f( easing(t) ) 的形式
  2. 通过在Animation上提供不同粒度的封装,可以满足不同层次的定制需求

本文只讲述了时间动画的一种抽象,但业务千千万万,还不够。

  1. 比如有些业务会需要在动画的过程中终止
  2. 有时终止后还会需要原路后退 (反向播放动画)
  3. 动画总是异步的,为了更好的开发体验,最好是可以封一套和Promise相关的Api,便于提升开发体验,异步管理,以及其他体系融合。

今天就到这里了,客官,下次再来哟 ~~


原文地址


原创粉丝点击