深入淺出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);
});
改變時間 – 動畫的時間曲線與緩動效果
舉例來說,一個位移動畫,物件的軌跡可以形成一條位移曲線。而時間曲線就抽象了很多。
動畫的曲線
線性動畫
說到動畫曲線,那就不得不提到一個好玩的網站 – 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,便於提升開發體驗,非同步管理,以及其他體系融合。
今天就到這裡了,客官,下次再來喲 ~~
相關文章
- 深入淺出 CSS 動畫CSS動畫
- 深入淺出 Node.js ClusterNode.js
- 精讀《深入淺出Node.js》Node.js
- [譯] 漫畫:深入淺出 ES 模組
- 深入淺出FE(十四)深入淺出websocketWeb
- 深入淺出JS - 變數提升(函式宣告提升)JS變數函式
- 淺讀-《深入淺出Nodejs》NodeJS
- Android啟動過程剖析-深入淺出Android
- 深入淺出MyBatis:反射和動態代理MyBatis反射
- 深入淺出mongooseGo
- HTTP深入淺出HTTP
- 深入淺出WebpackWeb
- 深入淺出HTTPHTTP
- mysqldump 深入淺出MySql
- 深入淺出——MVCMVC
- 深入淺出IO
- 深入淺出decorator
- ArrayList 深入淺出
- 深入淺出 RabbitMQMQ
- 深入淺出PromisePromise
- 深入淺出 ZooKeeper
- 大咖說·圖書分享|深入淺出 Node.jsNode.js
- 深入淺出Tomcat/2 - Tomcat啟動和停止Tomcat
- Flutter | 深入淺出KeyFlutter
- 深入淺出 Laravel EchoLaravel
- 深入淺出理解ReduxRedux
- 深入淺出 Laravel MacroableLaravelMac
- flutter ScopedModel深入淺出Flutter
- 反射的深入淺出反射
- 《深入淺出webpack》有感Web
- 深入淺出Spring MVCSpringMVC
- 深入淺出Tomcat系列Tomcat
- [譯] 深入淺出 SVGSVG
- SSL/TLS 深入淺出TLS
- Tomcat深入淺出(一)Tomcat
- 深入淺出:HTTP/2HTTP
- 深入淺出Spark JoinSpark
- 深入淺出redux學習Redux
- 深入淺出redux-middlewareRedux