设计 Timeline 时间轴来更精确地控制动画

5,522 阅读5分钟

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 下载。

有任何问题,欢迎讨论~~