深入淺出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)
稱為對動畫的 描述,它建立起了視圖和時間的關聯
業務場景
我們已經有了足夠的概念,在業務中,我們實現一個動畫:
- 抽象出一個動畫描述
- 設定一個開始時間
- 不斷進行觀測
- 把觀測結果寫入視圖
因為屏幕的刷新總是有一個頻率,就好像是屏幕對視圖的觀測一樣,過多的觀測其實沒有太大意義,*最好,能和屏幕的刷新率一致*(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);
});
改變時間 - 動畫的時間曲線與緩動效果
舉例來說,一個位移動畫,物件的軌跡可以形成一條位移曲線。而時間曲線就抽象了很多。
動畫的曲線
線性動畫
說到動畫曲線,那就不得不提到一個好玩的網站 - https://cubic-bezier.com/ 。 每次搬磚太多的時候,我都要去這個網站上撥弄幾下調節一下自己。
從前文的例子中,我們的動畫叫做線性動畫,就像是“勻速直線運動”的小球一樣,運動的進程始終如一。
想象我們在每一幀渲染的時候,都對p
進行一定的處理 q = easing(p)
,那線性動畫就是 easing(p) = p
如果要用例子來描述的話,大概就是這樣。
緩動動畫
現在我們要模擬開始逐漸加速的場景,差不多就是下圖的樣子
https://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,便於提升開發體驗,異步管理,以及其他體係融合。
今天就到這裏了,客官,下次再來喲 ~~
最後更新:2017-08-18 10:32:24