css animation应该是我们非常熟悉的css特性之一了,也诞生了例如animate.css在内的许多方便我们使用css动画的工具。而通过JS来创建动画的Web Animations API也早早进入了Working Draft阶段,不过目前的兼容性还比较一般。
而在此之前,例如jQuery的animate方法和TweenMax.js等则是前端开发者常用的动画工具。
虽然对于前端动画而言已经有了非常丰富的工具库,不过自己动手写一个js动画库任然不失为一个有趣的小练习。
首先我们在页面上显示画一个灰色的小盒子:
<style type="text/css"> .container { position: absolute; left: 100px; top: 100px; width: 1100px; height: 100px; border: 1px solid #eee; } .box { position: absolute; left: 0; top: 0; width: 100px; height: 100px; background: #666; } </style> <div class="container"> <div class="box"></div> </div> 复制代码
然后写一段js代码:
const $box = document.querySelector('.box'); let left = 0; const step = function() { left += 10; if (left < 1000) { requestAnimationFrame(step); } $box.style.left = `${left}px`; }; requestAnimationFrame(step); 复制代码
这样我们的小盒子就会从左往右移动。
但是这里有两个问题。第一,上面的代码不能使得小盒匀速移动。第二,上面的代码也无法确定动画的时长。
接下来改造代码:
const animate = function($el, obj, { duration }) { const current = 0; const keys = Object.keys(obj); const step = function() { const now = +new Date; const ratio = (now >= end) ? 1 : (now - start) / duration; keys.forEach(key => { const [from, to] = obj[key]; $el.style[key] = `${from + (to - from) * ratio}px`; }) if (ratio < 1) { requestAnimationFrame(step); } }; const start = +new Date; const end = start + duration; requestAnimationFrame(step); }; animate($box, { left: [0, 1000] }, { duration: 1500 }); 复制代码
改造时候,使用者可以以“在1.5内将小盒的left从0移到1000”这样比较符合常规思维的方式来使用了。
不过显然$el.style[key] =
${from + (to - from) * ratio}px;
这样的操作并不适用于所有属性,我们需要为不同属性提供不同处理:
const handlerMap = { left: function($el, [from, to], ratio) { $el.style.left = `${from + (to - from) * ratio}px`; }, backgroundColor: function($el, [from, to], ratio) { const fr = parseInt(from.substr(0, 2), 16); const fg = parseInt(from.substr(2, 2), 16); const fb = parseInt(from.substr(4, 2), 16); const tr = parseInt(to.substr(0, 2), 16); const tg = parseInt(to.substr(2, 2), 16); const tb = parseInt(to.substr(4, 2), 16); const color = [ Math.floor((fr + (tr - fr) * ratio)).toString(16), Math.floor((fg + (tg - fg) * ratio)).toString(16), Math.floor((fb + (tb - fb) * ratio)).toString(16), ].join(''); $el.style.backgroundColor = `#${color}`; }, }; const animate = function($el, obj, { duration, cb }) { ... const step = function() { ... keys.forEach(key => { const handler = handlerMap[key]; if (handler) handler($el, obj[key], ratio); }) cb && cb(ratio); ... }; ... }; animate($box, { left: [0, 1000], backgroundColor: ['ee3366', '99ee33'] }, { duration: 1500, cb: (r) => $box.innerText = r.toFixed(2) }); 复制代码
经过改造,我们的小盒子在移动的同时背景色也发生了渐变,同时还展示了当前动画的进度。
虽然我们的小盒子动起来了,但如果只能线性移动那未免有些枯燥。因此我们需要引入缓动动画。
什么是缓动(easing)呢?缓动就是一些列将时间进度与动画进度进行映射的函数。
这里,时间进度和动画进度的值域均为[0, 1]:
以上两点是不言而喻的。如果我们以时间进度为很轴,动画进度为纵轴,就可以画出缓动函数对应的图像。
例如上面的线性移动,对应的函数就是y = x
。
再例如二次函数y = x * x
,同样是经过[0, 0]与[1, 1]两点的,这个函数在缓动中被称为easeInQuad
,这是一种先慢后快的函数;而相对应的easeOutQuad
则是先快后慢,对应的方程是1 - (1 - x) * (1 - x)
。
对于更多的常用缓动类型,可以参考easings.net,包括类似easeOutBounce
这样的分段函数。
于是我们添加对缓动的支持:
const Easings = { linear(x) { return x; }, easeInQuad(x) { return x * x; }, easeOutQuad(x) { return 1 - (1 - x) * (1 - x); }, }; const animate = function($el, obj, { duration, cb, easing = Easings.linear }) { ... const step = function() { const now = +new Date; const timeRatio = (now >= end) ? 1 : (now - start) / duration; const ratio = easing(timeRatio); ... }; cb && cb(ratio, timeRatio); if (timeRatio < 1) requestAnimationFrame(step); ... }; animate($box, { left: [0, 1000], backgroundColor: ['ee3366', '99ee33'] }, { easing: Easings.easeInQuad, duration: 1500, cb: (r) => $box.innerText = r.toFixed(2) }); 复制代码
贝塞尔曲线(Bezier curve)由控制点定义。贝塞尔曲线由大于等于2个的控制点构成。由3个控制点定义的贝塞尔曲线被称为二次(quadratic)贝塞尔曲线,由4个控制点定义的贝塞尔曲线则称为三次(cubic)贝塞尔曲线。这两种是最常见的贝塞尔曲线。
贝塞尔曲线有一个特性是,曲线会完全包含在由控制点组成的凸包内。因此在检测两条贝塞尔曲线是否相交时可以尝试先检测凸包是否相交。
迪卡斯特利奥算法描述了贝塞尔曲线是如何由控制点生成的。
首先是由两个控制点a、b的贝塞尔曲线。连接a、b两点,设置表示绘制进度的变量t,取t从0至1(100%),例如当t=0.3,则对应的点是线段ab从a起30%的点。
当t从0至1变化的过程中,绘制的对应点显然地,就是线段ab。
接下来是二次贝塞尔曲线。分别连接ab、bc两点,分别在ab、bc上取ad / ab = be / bc = t
,连接de,再取dp / de = t
,按照这样的规则绘制的点连成的曲线,就是由控制点a、b、c所确定的二次贝塞尔曲线。
三次贝塞尔曲线以此类推。对于点abcd,做点efg使得ae / ab = ... = t
,连接ef、fg,做点h、i使得eh / ef = fi / fg = t
,最后连接hi,并取点p使得hp / hi = t
,这里的点p就是t所对应的点。
贝塞尔曲线可以用以下公式表示:
P = (1-t) * P1 + t * P2
P = (1−t)^2 * P1 + 2(1−t) * t * P2 + t^2 * P3
P = (1−t)^3 * P1 + 3(1−t)^2 * t * P2 +3(1−t) * t^2 * P3 + t^3 * P4
这里的P是向量。对应到三次贝塞尔曲线,也就是在css动画中常用的Cubic Bezier:
x = (1−t)^3 * x1 + 3(1−t)^2 * t * x2 +3(1−t) * t^2 * x3 + t^3 * x4
y = (1−t)^3 * y1 + 3(1−t)^2 * t * y2 +3(1−t) * t^2 * y3 + t^3 * y4
根据我们的使用场景,x1 = y1 = 0
且x4 = y4 = 1
。
如果我们的目标是在二维平面绘制三次贝塞尔曲线,那故事就到此为止了。
然而对于缓动动画而言,我们需要建立的是x(时间进度)与y(动画进度)之间的关系,而非它们各自与t的关系。
一种可行但消耗性能的做法是,对于给定的x,通过迭代的方式去近似地解出对应的t,再来算出y。
具体的计算方法可以参考这里。这边就直接用这里的代码啦:
const Easings = { ... cubicBezier(x1, y1, x2, y2) { const easing = BezierEasing(x1, y1, x2, y2); return function(x) { return easing(x); }; }, }; animate($box, { left: [0, 1000], backgroundColor: ['ee3366', '99ee33'] }, { easing: Easings.cubicBezier(0.5, 0, 0.5, 1), duration: 3000, cb: (r) => $box.innerText = r.toFixed(2) }); 复制代码
最后,给我们的动画库加上一点控制逻辑。
第一步,是用类重写我们的小工具:
class Animation { constructor($el, obj, { duration, cb, easing = Easings.linear }) { this.$el = $el; this.obj = obj; this.keys = Object.keys(obj); this.duration = duration; this.cb = cb; this.easing = easing; this.aniId = 0; } play() { const { duration } = this; this.start = +new Date - this.passedTime; this.end = this.start + duration; cancelAnimationFrame(this.aniId); this.aniId = requestAnimationFrame(() => this.step()); } step() { const { duration, start, end } = this; const now = +new Date; const passedTime = (now - start); const timeRatio = (now >= end) ? 1 : passedTime / duration; this.render(timeRatio); if (timeRatio < 1) { this.aniId = requestAnimationFrame(() => this.step()); } } render(timeRatio) { const { $el, obj, keys, cb, easing } = this; const ratio = easing(timeRatio); keys.forEach(key => { const handler = handlerMap[key]; if (handler) handler($el, obj[key], ratio); }); cb && cb(ratio, timeRatio); } } const animate = function($el, obj, opts) { return new Animation($el, obj, opts); }; const ani = animate(...); ani.play(); 复制代码
现在来添加几个方法:
为此,给animation实例添加了两个属性:isPlaying和passedTime。前者用来标明当前播放状态,后者用来在resume时从正确的位置继续播放:
class Animation { constructor($el, obj, { duration, cb, easing = Easings.linear }) { ... this.isPlaying = false; this.passedTime = 0; } play(reset = true) { ... if (reset) this.passedTime = 0; this.isPlaying = true; this.start = +new Date - this.passedTime; ... } step() { ... const passedTime = Math.min((now - start), duration); const timeRatio = passedTime / duration; ... this.passedTime = passedTime; if (this.isPlaying && timeRatio < 1) { ... } else { this.isPlaying = false; } } pause() { if (!this.isPlaying) return false; this.isPlaying = false; return true; } resume() { if (this.isPlaying) return false; this.play(false); } stop() { this.pause(); cancelAnimationFrame(this.aniId); this.render(0); } } 复制代码
play方法添加了表示是否从头重来的参数reset,默认为true,resume则会调用this.play(false)
从而从当前位置继续播放。
为了实现倒放功能,添加了isReversed属性。同时修改render方法,如果当前在倒放中,则当前时间进度比例为1 - timeRatio
。同时,在reverse方法中,要倒转passedTime为duration - passedTime
:
class Animation { constructor($el, obj, { duration, cb, easing = Easings.linear }) { ... this.isReversed = false; } play(reset = true) { ... if (reset) { this.passedTime = 0; this.isReversed = false; } ... } ... render(timeRatio) { const { $el, obj, keys, cb, easing, isReversed } = this; if (isReversed) timeRatio = 1 - timeRatio; ... } pause() { if (!this.isPlaying) return false; if (this.isReversed) this.reverseData(); cancelAnimationFrame(this.aniId); this.isPlaying = false; return true; } resume() { if (this.isPlaying) return false; if (this.isReversed) this.reverseData(); this.play(false); } ... reverseData() { this.passedTime = this.duration - this.passedTime; this.isReversed = true; } reverse() { if (this.isReversed) return false; this.reverseData(); cancelAnimationFrame(this.aniId); this.play(false); } 复制代码
这样倒放就实现了。
作为一个小练习,介绍了自己动手写一个缓动库的一些相关内容,当然如果想要发展为一个完整的工具,还有很多的东西要完善,例如全面的属性处理、动画连播功能、播放事件订阅等等。