設計 Timeline 時間軸來更精確地控制動畫

語言: CN / TW / HK

Firefox 偷偷實現了一個 AnimationTimeline,用來為動畫提供時間軸。根據文檔,它是一個抽象類,被 DocumentTimeline 繼承。

由於是非標準的特性,MDN 的文檔裏面也沒有解釋的很清楚,只是説它用來讓多個動畫共享時間軸,但是具體該怎麼用,並沒有詳細的説明。

今天在這篇文章裏,我並不想解釋 Firefox 實現的這個 Timeline 該怎麼用,而是藉着這個 Timeline 的概念進行一些擴展,實現了一個全新的 Timeline 庫。讓我們看看如果為動畫或者其他依賴於時間的行為設計一個 Timeline,我們能做什麼。

在這裏,要説明動畫和 Timeline 的關係,我先給大家看一個直觀的例子:

例 1 - Timeline 與動畫

在一個場景裏有多個動畫同時播放,如果我現在想要讓所有的動畫全部暫停,該怎麼辦?

如果我們拿到所有的動畫實例一個一個暫停,那樣當然也是可以的,但是不方便。如果我還要支持快進、慢進又怎麼辦?總之處理起來會很麻煩。這個時候,我們的 Timeline 的作用就體現出來了。

Timeline,可以想象成虛擬世界裏的時間線,我們將世界分解成許多個相互疊加的平行宇宙,每個宇宙有自己獨立的時間線,一個宇宙裏的一切行為都基於當前宇宙的時間線。

對於上面的動畫來説,它們共享一個獨立的時間線,當我們需要讓動畫速度改變時,直接改變 timeline 的 playbackRate,控制時間的流逝速度即可。

如何做到?

舉一些更簡單的例子:

首先看不使用 Timeline 的一個簡單的圓周運動動畫:

例 2 - 不使用 Timeline

let startTime = Date.now(), T = 2000

requestAnimationFrame(function update(){
  let p = (Date.now() - startTime) / T

  ball.style.transform = `rotate(${360 * p}deg)`
  requestAnimationFrame(update)
})


複製代碼

上面這個例子很簡單,就是計算小球轉過的角度,然後繪製成圓周運動動畫。但是如果我們想要在不修改小球運動參數的情況下讓小球動畫加快一倍或者減慢為原先的一半速度,該怎麼辦呢?我們把小球運動想象成一個電影,我們希望修改播放器的播放速度,並不改變電影裏的實際時間。在這時候我們就需要引入時間軸啦:

例 3 - 原速

let timeline = new Timeline()
let startTime = timeline.currentTime, T = 2000

requestAnimationFrame(function update(){
  let p = (timeline.currentTime - startTime) / T

  ball.style.transform = `rotate(${360 * p}deg)`
  requestAnimationFrame(update)
})


複製代碼

上面的例 3 和之前例 2 非常相似,我們只是把 Date.now() 給換成了 timeline.currentTime,也就是用我們的 Timeline 取代了系統默認的時間。我們這麼做了之後,可以通過調整 timeline 的參數 playbackRate 來加速或者減速動畫!

例 4 - 2 倍速度

let timeline = new Timeline({playbackRate: 2.0})
let startTime = timeline.currentTime, T = 2000

requestAnimationFrame(function update(){
  let p = (timeline.currentTime - startTime) / T

  ball.style.transform = `rotate(${360 * p}deg)`
  requestAnimationFrame(update)
})


複製代碼

例 5 - 1/2 速度

let timeline = new Timeline({playbackRate: .5})
let startTime = timeline.currentTime, T = 2000

requestAnimationFrame(function update(){
  let p = (timeline.currentTime - startTime) / T

  ball.style.transform = `rotate(${360 * p}deg)`
  requestAnimationFrame(update)
})


複製代碼

例 6 - 2 倍速倒放

let timeline = new Timeline({playbackRate: -2.0})
let startTime = timeline.currentTime, T = 2000

requestAnimationFrame(function update(){
  let p = (timeline.currentTime - startTime) / T

  ball.style.transform = `rotate(${360 * p}deg)`
  requestAnimationFrame(update)
})


複製代碼

時間軸與 Timer

上面的例子可以看出,Timeline 所做的事情只不過是根據 playbackRate 獨立計算 currentTime,這樣我們所有需要獲取時間的地方直接用 timeline.currentTime 取代 Date.now() 即可。不過為了使用方便,我們的 Timeline 還提供了自己的 timer:

例 7 - 毫秒變秒

let timeline = new Timeline({playbackRate: 0.001})

timeline.setInterval(() => {
  ball.innerHTML = Math.round(timeline.currentTime)
}, 1)


複製代碼

timeline 提供 setInterval、setTimeout、clearInterval、clearTimeout 四個方法,分別對應 window 的四個相應方法,只不過時間流逝是按照 timeline 的 playbackRate 來的。

currentTime 與 entropy

因為 Timeline 的 playbackRate 是動態的,所以它的 currentTime 也是動態的,結果就是會影響到它的 timer,例如:

例 8 - 時間倒流?

let timeline = new Timeline({originTime: -100, playbackRate: -0.001})

timeline.setInterval(() => {
  ball.innerHTML = Math.round(timeline.currentTime)
}, 1)


複製代碼

這個例子我們讓時間倒流,數字每一秒鐘減小,看似沒有問題,但是,換一種方式看看:

例 9 - 時間倒流的 bug

let timeline = new Timeline({originTime: -100, playbackRate: -0.001})

let count = 100;
timeline.setInterval(() => {
  ball.innerHTML = count--
}, 1)


複製代碼

我們發現定時器其實並沒有如我們所期望的那樣每一秒鐘執行一次。這是因為我們把 playbackRate 設置為負數,改變了時間箭頭的方向。也就是説歷史和未來顛倒了,所以 setInterval 並沒有在 1 秒之後觸發,而是立即觸發,因為對於 timer 來説,“未來” 是負時間,而 “1 秒之後” 已經是過去了!

我們做一下修改:

例 10 - 負向 timer

let timeline = new Timeline({originTime: -100, playbackRate: -0.001})

let count = 100;
timeline.setInterval(() => {
  ball.innerHTML = count--
}, -1)


複製代碼

所以 playbackRate 如果為負數,那麼 timer 的時間也得相應設置為負數。這個很麻煩,容易出錯。而且有時候我們不能保證 timer 一定被觸發,比如我們週期性改變 playbackRate 方向,很有可能限制時間在一個範圍內,那麼 timer 可能永遠也不會被觸發。

有時候我們需要明確讓 timer 在 timeline 等待某個時間之後觸發,而不管時間箭頭是向前還是向後,那麼我們就可以使用 entropy 這個屬性。

entropy 是熵的意思,不管 playbackRate 是正還是負,entropy 只能增加不能減少。不過 entropy 同樣會受到 playbackRate 影響。也就是説 entropy 只和 playbackRate 的絕對值有關,和它的符號無關

所以我們也可以這麼寫:

例 11 - 熵與 timer

let timeline = new Timeline({originTime: -100, playbackRate: -0.001})

let count = 100;
timeline.setInterval(() => {
  ball.innerHTML = count--
}, {entropy: 1})


複製代碼

entropy 在動態改變 playbackRate 的場景很有用,它提供了一個單向的時間衡量指標,方便我們控制動畫的速度和流向,例如:

例 12 - 熵控制動畫

const T = 2000
let timeline = new Timeline()

timeline.setInterval(function update() {
  ball.innerHTML = Math.round(timeline.currentTime / 100)
  if(timeline.playbackRate < 0){
    ball.style.backgroundColor = 'green'
  } else {
    ball.style.backgroundColor = 'red'
  }
}, {entropy: 100})

speedUp.onclick = function(){
  if(timeline) timeline.playbackRate += 0.2
  rate.innerHTML = timeline.playbackRate.toFixed(1)
}

slowDown.onclick = function(){
  if(timeline) timeline.playbackRate -= 0.2
  rate.innerHTML = timeline.playbackRate.toFixed(1)
}

reverse.onclick = function(){
  if(timeline) timeline.playbackRate = -timeline.playbackRate
  rate.innerHTML = timeline.playbackRate.toFixed(1)
}

pause.onclick = function(){
  if(timeline) timeline.playbackRate = 0
  rate.innerHTML = timeline.playbackRate.toFixed(1)
}


複製代碼

時間軸的繼承 —— fork

有意思的是,我們還可以根據當前時間軸創建出相對於當前時間軸的新時間軸,這樣的話,我們可以通過控制父級時間軸來影響所有 fork 出來的子時間軸,也可以控制單個時間軸,這就提供了極大的靈活性。

例 13 - timelien fork

let timeline = new Timeline()

function count(el, timeline, p = Infinity) {
  timeline.setInterval(() => {
    el.innerHTML = Math.round(timeline.currentTime / 1000) % p
  },  {entropy: 1000})
}

count(ball0, timeline)
count(ball1, timeline.fork({playbackRate: 10}), 10)
count(ball2, timeline.fork({playbackRate: 100}), 10)


複製代碼

總結

Timeline 是一個可以大大增強對動畫控制的輔助類,通過控制動畫的時間流速和方向來改變動畫進程。要使用功能強大的 Timeline,可以從 GitHub repo 下載。

有任何問題,歡迎討論~~