本篇文章介绍一种跑马灯动画,如下

上面的动画主要实现了两个功能:

  • 文字的无缝衔接
  • 动画的动态加减速

首先给出例子用到的 htmlcss 代码

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="stylesheet" href="index.css">
</head>
<body>
<div class="container">
<div class="nowrap">
<p class="txt">朝辞白帝彩云间,千里江陵一日还。两岸猿声啼不住,轻舟已过万重山。</p>
</div>
<button id="speedUp">加速</button><button id="slowDown">减速</button>
</div>

<script type="module">
import './index.js'
</script>
</body>
</html>
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}

.container {
width: 500px;
margin: 100px auto;
}

.nowrap {
width: 100%;
white-space: nowrap;
overflow: hidden;
}

制作动画的手段有很多种,使用 JavaScript 以及 CSS 均可实现,不过 CSS 的性能较好,所以本文使用 CSS 来实现动画。

动画的无缝衔接

一开始我都不明白什么叫动画的无缝衔接,后来我看到了不是无缝衔接的例子瞬间就明白了,如下

可以观察到第一次动画完毕以后非常突兀的来到了文字的开头,与上面无缝衔接的动画想比,可以感觉到无缝衔接动画非常的流畅,所以如何实现动画的无缝衔接呢? 答案就是将内容复制两份,这样当文字滚动在最后时出现的就不是空白,而是开头的文字。

因为我们使用的是 transform: translateX() 来制作动画,所以我们要获取复制前文本的长度,以确定最终变换的距离,以及为了实现动画的速度与文本的长短无关,那么动画运动的总时间是与文本的长度成正比的,这个比例系数自然就是速度了 $s = vt$。上面这一切都表明我们的动画的创建是与文本长度有关的,而文本的长度我们事前不知道,所以动画的创建也是动态的,我们通过向 DOM 插入一个 style 标签来动态创建动画。

创建动画相关的代码如下

// 动画的速度,实际测试大约为 1s 两个字
let speed = 36

// 动态插入 keyframe
const style = document.createElement('style');
document.getElementsByTagName('head')[0].appendChild(style)

const createKeyFrame = (width, name) => {
const keyframe = `
@keyframes ${name} {
0% {
transform: translateX(0px);
}
100% {
transform: translateX(-${width}px);
}
}
.${name} {
animation: ${name} ${(Math.abs(width)/speed).toFixed(2)}s linear infinite;
}
`
style.innerHTML = keyframe
}

上面 createKeyFrame 函数用以动态创建一个动画,该函数接收两个参数 width 表示文本的长度以及 name 表示动画的名称,除了创建一个动画,我们还创建一个名字与动画名相同的类,我们只要为元素添加这个类,这个类便会拥有这个动画,动画的执行次数为无限次。

准备好这些以后,我们便可以开始为文本应用动画了,在应用动画之前,我们首先需要获得文本的长度,然后将文本复制一遍插入到原文本后面,如下

// 包含文字的元素
const txt = document.getElementsByClassName('txt')[0]
// 文本的长度
let textWidth = 0
const initAnimation = () => {
// 获得文本长度,控制动画时间,以及 transform 的距离
textWidth = txt.scrollWidth

// 如果宽度足够显示所有的文字,则没有必要使用动画
if (textWidth <= 500) {
// 不仅不添加动画,而且要移除之前添加的动画
txt.classList.remove('slow', 'temp-slow', 'fast', 'temp-fast')
return
}

// 获得文本内容
const textContent = txt.textContent
// 在后面设置添加相同的文字,做到动画的无缝衔接
txt.textContent = textContent + textContent

// 动态制作动画
createKeyFrame(textWidth, 'slow')
// 应用动画
txt.classList.add('slow')
}
initAnimation()

上面的程序首先获得文本的长度,这里有一个小细节,如果文本的长度小于容器的长度的话,那么就没必要使用动画,在这里我们移除了一些类,这些类后面会用到,这里只要理解这个行为是移除所有动画即可。如果文本的长度大于容器的长度,我们接下来就来取得文本的内容,然后复制一份并且添加原文本的后面,接着添加动画,并且为元素应用动画,此时效果看起来是这样

此时我们已经实现了动画的无缝衔接,但是加减速功能还没有实现。

动画动态加减速

改变动画的持续时间

那么如何实现动画的动态加减速呢,首先自然而然的一个想法就是直接改变动画的总时间,我们先以加速为例

// 是否处于加速状态
let speeded = false

const upBtn = document.getElementById('speedUp')
const speedUp = () => {
// 如果已经处于加速状态,则直接返回
if (speeded) {
return
}

// 设置为加速状态
speeded = true

// 速度变为原来四倍
speed = speed * 4
// 改变动画的运行时间
txt.style.animationDuration = textWidth / speed + 's'
}
upBtn.addEventListener('click', speedUp)

我们为加速按钮添加了一个点击事件,为了防止多次触发加速,我们使用一个变量 speeded 来记录元素是否处于加速状态,如果已经处于加速状态,则函数直接返回,否则我们将速度变为原来的四倍,当然不要忘了将元素设置为处于加速状态,然后设置动画的执行时间为 textWidth / speed,我们来看看实际效果

可以观察到,当我们点击加速的那个瞬间,动画似乎不对劲,但是哪里不对劲,因为速度太快我没有观察处理,不过我们可以使用数据来告诉我们答案,我们打印改变动画持续时间前后元素的 transfrom 的值,首先我们定义一个函数来获得元素的 translateX 的值

const getTranslateX = ele => {
return +window.getComputedStyle(ele).transform.split(',')[4]
}

上面的函数是怎么得到?

首先我们通过 window.getComputedStyle(ele).transform 来获取元素的 transform 属性,它是一个形如下面的字符串

matrix(1, 0, 0, 1, -97.8686, 0)

倒数第二个数字 -97.8686 表示的就是 translateX (针对二维的情况,没有 translateZ),所以我们通过 , 分割字符串,然后获得第五个即下标为 4 的字符串即为 translateX 的值,因为是字符串,所以我在前方加了一元运算符 + 将字符串转换为数字。

我们在改变动画运行时间前后打印此时的 translateX 的值

const speedUp = () => {
if (speeded) {
return
}

speeded = true
speed = speed * 4

console.log('加速前', getTranslateX(txt))
txt.style.animationDuration = textWidth / speed + 's'
console.log('加速后', getTranslateX(txt))
}

我们可以观察到点击前后 translateX 的值发生了相当大的突变,这里我给出一个解释,假设你一开始以 2m/s 的速度进行奔跑,跑了 2s,此时你应该处于 4m 的位置,这时你的速度变为 4m/s,所以浏览器认为你的位置应该处于 8m 的位置,在这个时刻,你的位置发生了突变。

那怎么解决这个问题呢? 我的第一个想法是,既然是因为突然加速太快导致严重的突变,那么我慢慢的改变速度(即动画的运行时间)是否就没有这么严重的卡顿问题呢,咱们来试一试

const speedUp = () => {
if (speeded) {
return
}

// 获得当前的动画持续时间
let duration = window.getComputedStyle(txt).animationDuration
// 每次动画持续时间减少 0.1s
duration = parseFloat(duration).toFixed(1) - 0.1
txt.style.animationDuration = Math.max(duration, textWidth/(speed * 4)) + 's'

// 没有达到指定的动画时间时,就持续调用加速动画
if(duration > textWidth/speed/4) {
setTimeout(speedUp, 10)
} else {
speeded = true
}
}

我们首先获得当前动画的持续时间,然后将该动画的持续时间减少 0.1s,如果当前的动画的持续时间没到达到预期的运行时间 textWidth/(speed * 4),那么就会持续的调用加速函数,时间间隔为 10ms,直至达到希望的时间,我们来看看效果

我们可以看到文本没有发生突变,并且动画逐渐的加速起来了,从图中看的效果比较好,如果能够接受逐渐加速的解决方案,似乎已经满足了我们的需求。但是实际的效果比动图所示的效果差,由于录制 GIF 软件帧率的原因,导致效果很好,从我实际观察看,有两个瑕疵:

  • 速度越来越快,甚至加速过程中的速度超过了最后加速后的速度
  • 有卡顿的效果

对此我比较苦恼,开始从数学上是分析原因,这里不给出数学上的推导,给出我得到的两个结论:

  • 加速过程中越来越快的问题可以通过改变加速函数的运行时间间隔来解决,当速度较慢时,时间间隔可以较短,此时的突变距离短,看不出来,当速度较快时,需要加长时间间隔,以减缓速度,但是会导致加速时间过长
  • 卡段的原因是因为突变,也可以通过改变时间间隔减缓症状,但是消除卡顿的效果会导致加速时间很长(突变是一定有的,不过从效果上感觉不到)

基于上述结论,我得到一个简单的时间间隔加速公式(公式没有数学推导)

setTimeout(speedUp, -5 * (duration - textWidth/(speed * 4)) + 75)

这是一个线性函数,当持续时间越接近于最终持续时间,间隔就越长,效果有所改善,但是还是有卡顿效果。

创建多个新动画

那有没有更好的解决办法,似乎我已经做到了极致,经过我苦思冥想,最终还是想出来了。既然我改变动画持续时间会导致突变,那么我可不可以同时改变两个值,比如动画名称以及动画持续时间,我脑中开始浮现一种思路,抛弃原来的动画,创建一个新动画,不过创建一个新的动画会导致突然回到原点,我们只要记录原先旧动画 translateX 的位置,将其设置为新动画的起点,那新旧动画不就衔接上了吗? 我们的 createKeyFrame 函数还不支持设置动画原点的功能,我们将其加上

const createKeyFrame = (width, name, startPosition = 0) => {
const keyframe = `
@keyframes ${name} {
0% {
transform: translateX(${startPosition}px);
}
100% {
transform: translateX(-${width}px);
}
}
.${name} {
animation: ${name} ${(Math.abs(width + startPosition)/speed).toFixed(2)}s linear infinite
}
`
style.innerHTML = keyframe
}

我们为函数添加了一个新的参数 startPosition,它的默认值为 0 (这意味原先的代码不必修改),然后我们修改了两处:

  1. 动画的起始设置为了 translateX(${startPosition}px)
  2. 动画的运行时间设置为 ${(Math.abs(width + startPosition)/speed).toFixed(2)}s,因为 startPosition 为负数,所以这里是 width + startPosition

现在我们修改加速函数的逻辑

const speedUp = () => {
if (speeded) {
return
}

speeded = true
speed = speed * 4

// 获得当前的 translateX
const translateX = getTranslateX(txt)

// 移除之前添加的慢动画
txt.classList.remove('slow')
// 创建一个名为 temp-fast 的新动画
createKeyFrame(textWidth, 'temp-fast', translateX)
// 添加新动画
txt.classList.add('temp-fast')
}

上面的代码非常的简单,不多解释了,我们来看看效果

我们点击加速按钮的时候,可以观察到动画十分的流畅,没有丝毫的突变,就是有一个缺点就是新动画的开头不是文本的开头,我们接下来解决这个问题,思路很简单,当当前这个动画执行完毕时,我们再次创建一个动画起始位置为文本原点的新动画,然后移除原来的动画,添加新动画。

我最开始的想法是使用 setTimeout 不断读取当前的 translateX,当 translateX 与文本的长度很接近时(具体有多接近才叫接近,通过观察控制台,我发现大约在 3px 以内),可以认为这个动画执行完毕了,这时我们创建这个新动画,该新动画的的起点为文本的原点。

const speedUp = () => {
if (speeded) {
return
}

speeded = true
speed = speed * 4

// 获得当前的 translateX
const translateX = getTranslateX(txt)

// 移除之前添加的慢动画
txt.classList.remove('slow')
// 创建一个名为 temp-fast 的新动画
createKeyFrame(textWidth, 'temp-fast', translateX)
// 添加新动画
txt.classList.add('temp-fast')

const reset = () => {
const x = getTranslateX(txt)
if ((textWidth + x) < 3) {
// 移除临时的快动画
txt.classList.remove('temp-fast')
// 创建起始位置为文本原点的动画
createKeyFrame(textWidth, 'fast')
// 添加新动画
txt.classList.add('fast')
} else {
setTimeout(reset, 1)
}
}
reset()
}

我们来看看实际的效果如何

可见加速过程十分的流畅,没有任何的卡顿,以及动画也能回到文本的原点了。

但是使用定时器耗费性能比较严重,后来经过高建师兄指点,我们不必使用定时器,我们只要监听添加的临时动画执行完毕的事件即可,这样既准确,效率又高。那么我们创建的临时动画的执行次数不能是无限次的,而是一次,我们的 createKeyFrame 函数不支持,我们再次改造,为了不修改原来的代码,我们默认执行次数为无限次,如下

const createKeyFrame = (width, name, startPosition = 0, iterateCount = 'infinite') => {
const keyframe = `
@keyframes ${name} {
0% {
transform: translateX(${startPosition}px);
}
100% {
transform: translateX(-${width}px);
}
}
.${name} {
animation: ${name} ${(Math.abs(width + startPosition)/speed).toFixed(2)}s linear ${iterateCount}
}
`
style.innerHTML = keyframe
}

接着我们修改加速函数的逻辑,如下

const speedUp = () => {
if (speeded) {
return
}

speeded = true
speed = speed * 4

// 获得当前的 translateX
const translateX = getTranslateX(txt)

// 移除之前添加的慢动画
txt.classList.remove('slow')
// 创建一个名为 temp-fast 的新动画,执行一次
createKeyFrame(textWidth, 'temp-fast', translateX, 1)
// 添加新动画
txt.classList.add('temp-fast')

txt.onanimationend = () => {
txt.classList.remove('temp-fast')
// 创建起始位置为文本原点的动画
createKeyFrame(textWidth, 'fast')
// 添加新动画
txt.classList.add('fast')
}

}

上面的代码修改有两处,第一处是创建临时动画时传入了动画的执行次数

createKeyFrame(textWidth, 'temp-fast', translateX, 1)

第二处就是将定时器的逻辑改为了监听事件

txt.onanimationend = () => {
txt.classList.remove('temp-fast')
// 创建起始位置为文本原点的动画
createKeyFrame(textWidth, 'fast')
// 添加新动画
txt.classList.add('fast')
}

这次我们来看效果如下

这个动画真的非常的丝滑,比德芙还丝滑。

同样的我们加上减速的逻辑,其思路同加速一模一样,我们直接呈上代码

const downBtn = document.getElementById('slowDown')
const slowDown = () => {
if (!speeded) {
return
}
speeded = false
speed = speed / 4

const translateX = getTranslateX(txt)

txt.classList.remove('fast', 'temp-fast')
// 创建临时慢动画
createKeyFrame(textWidth, translateX, 'temp-slow', 1)
txt.classList.add('temp-slow')

txt.onanimationend = () => {
txt.classList.remove('temp-slow')
// 创建起始位置为文本原点的动画
createKeyFrame(textWidth, 'slow')
// 添加新动画
txt.classList.add('slow')
}
}
downBtn.addEventListener('click', slowDown)

逻辑可以说同加速一模一样了,不过有一个地方不同,因为当我们点击减速时,动画可能处于临时加速状态,也可能处于真正的加速状态,所以创建临时慢动画之前,两个动画都要移除

txt.classList.remove('fast', 'temp-fast')

同理点击加速按钮时也是同理,在创建临时快动画之前,我们要移除两个慢动画

txt.classList.remove('slow', 'temp-slow')

在这里呼应一下前文,我们说当文本的长度小于容器的长度时(我们的实际文本是可以动态改变的,所以之前的文本可能处于动画状态,当改变文本后其长度就可能小于容器长度),我们不添加动画,并且移除所有动画

if (textWidth <= 500) {
// 不仅不添加动画,而且要移除之前添加的动画
txt.classList.remove('slow', 'temp-slow', 'fast', 'temp-fast')
return
}

现在是不是感觉豁然开朗呢!

总结

本文探讨了跑马灯动画,一个是跑马灯动画的无缝衔接,解决办法是复制相同的文本,如果你已经理解了该动画的原理,你可以马上明白,不需要复制所有的文本,只需要复制的文本长度能够覆盖容器的长度即可。

另一个我们探讨了动画的加减速,我们首先阐述了我们遇到的问题,那就是 translateX 的突变,针对这个问题我首先提出缓慢的改变动画的持续时间,以减少急剧的突变,该种方法某种程度上的确减缓突变的症状,但是会有一段加速的时间,以及突变不可避免,只是轻重而已,从效果上看有卡顿效果;除此之外我又提出了另外一种解决办法,那就是在点击加速时,记录此时的 translateX,并且抛弃原来的动画,另起炉灶创建一个临时新动画,为了不产生突变,临时新动画的起点设置为此时的 translateX,然后监听该临时动画的动画完毕事件,在事件处理函数中我们添加一个动画起点位置为文本原点的真正的新动画,从效果上看真的是十分的丝滑。

另外本文的动画全部使用 CSS 实现,所以它的性能十分的好。