深入浅出JS动画

实现: 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,便于提升开发体验,异步管理,以及其他体系融合。

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值