深入浅出JS动画

    xiaoxiao2021-08-19  200

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

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

    相关资源:Vue深入浅出(ppt)

    最新回复(0)