深入淺出JS動畫

inahoo發表於2017-08-18

實現: 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,便於提升開發體驗,非同步管理,以及其他體系融合。

今天就到這裡了,客官,下次再來喲 ~~


相關文章