web前端 - 使用css动画实现跑马灯

6,382 阅读8分钟

前言

最近公司有个需求,需要实现一个简单的跑马灯功能,就是如下图的功能。

跑马灯示例1.gif

原本觉得没什么,就是一个 div 从右往左移动就可以了,但是没想到真正动手起来,却发现很多细节问题,大概知识生疏了,一时也没想到如何解决,后来才想到解决方案。

这篇文章的跑马灯基本是使用 css 进行实现的,但有用到少量的 js。

跑马灯的基本代码

先贴上跑马灯的基本代码(不含动画),下面的示例均以这些基本代码为基础进行修改。

css

* {
  margin: 0;
  padding: 0;
}
.marquee-container {
  /* position: relative; */
  width: 100%;
  height: 50px;
  line-height: 50px;
  background-color: cadetblue;
  overflow: hidden;
}
.marquee-box {
  /* position: absolute; */
  display: inline-block;
  color: #fff;
  white-space: nowrap;
  animation: marquee 3s linear infinite; /* 跑马灯动画 */
}

html

<div class="marquee-container">
    <div class="marquee-box">
      <p>123456789</p>
    </div>
</div>

跑马灯的错误实现

我看到很多博客都说使用 css 实现跑马灯的方法有两种,但我发现这两种方法都存在一定的问题。

错误方法1

.marquee-container {
    position: relative;
}
.marquee-box {
    position: absolute;
}
@keyframes marquee {
  0% {
    left: 100%;
  }
  100% {
    left: -100%;
  }
}

虽然跑马灯动画可以动起来,但是只要仔细观察,就不难发现当内容体移出容器的最左边边界时,内容体并没有立刻重新出现在右边边界,也即是在某个时间段,内容体并不会展示出来,如下图所示

跑马灯示例2.gif

这是因为 100% 动画帧的 left: -100% 导致的,left: -100% 并不是指内容体相对于自身宽度的 -100% 的水平位移,而是指内容体相对于容器的宽度的 -100% 的水平位移,这意味着内容体并非移出容器外后立刻出现在起点。

image.png

即使你能够接受“内容体不会立刻重新出现右边边界”的情况,这种写法依然是错误的,只要你的跑马灯内容宽度足够长时,跑马灯动画结束的地方可能落在容器内的某个位置,而不是边界或边界以外的地方,如下图所示。

第一次循环动画播放时,内容体还没有完全移出到容器左边界,就立刻进入到第二次循环,重新开始播放动画了。

跑马灯示例3.gif

错误方法2

@keyframes marquee {
  0% {
    transform: translateX(100%);
  }
  100% {
    transform: translateX(-100%);
  }
}

这段动画和错误方法1的动画的错误比较类似。首先当内容过短时,0% 动画帧的 translateX(100%) 根本就不能以容器的右边界做为起点。这是因为 translateX 里的 100% 是相对内容体自己的宽度的 100% 的水平位移。

跑马灯示例4.gif

其次当内容宽度过长时,起点会离容器的右边界太远。

总结

由此可见,上面的两种错误方法主要有两个缺陷

  • 受到内容体的宽度影响,内容体过长或过短时,可能会引发 bug
  • 无法精准定位内容体移动的起点或终点

跑马灯的正确实现

改进1:不再受内容宽度影响,并且在特定场景精准定位内容体移动的起点和终点

事实上,错误方法2的使用 translateX 的思路是正确的,只是 0% 动画帧的 translateX(100%) 需要改一下。我们只需要改为以下的代码就基本正确了。

@keyframes marquee {
  0% {
    transform: translateX(100vw); /* 100% 改为 100vw */
  }
  100% {
    transform: translateX(-100%);
  }
}

100vw 即视口的宽度,在这个场景里,容器的 width 为 100%,等同于视口宽度,也即是说内容体 translateX(100vw) 后肯定刚好落在容器的右边边界。

这个方法解决了两个问题,一个是不受内容体的长短影响,第二个是能够精准定位内容体移动的起点和终点。

但是,这个方法也存在一定缺陷,只适用于跑马灯容器的宽度等于视口宽度的情况。

原因是当容器宽度不等于视口宽度时,内容体 translateX(100vw) 后,它的移动起点永远落在视口的右边界外,不会再精准落在容器的右边界。

image.png

改进2:自适应不同的跑马灯容器宽度

这个方法的实现比较巧妙,使用 left 和 translate 组合进行实现动画,无论你的跑马灯容器宽度如何,都可以进行自适应。

.marquee-container {
    position: relative;
}
.marquee-box {
    position: absolute;
}

@keyframes marquee {
  0% {
    left: 100%;
  }
  100% {
    left: 0%;
    transform: translateX(-100%);
  }
}

其实很简单,就是使用 absolute + left 进行实现最基本的移动,当元素从 left:100% 移动到 left:0% 时,内容体刚好位于容器的最左侧,但没有移出容器的左边界外,这里再加一个 transform: translateX(-100%),向左移动内容体 100% 的宽度,即可将内容体刚好移出跑马灯容器位置。

这里还存在一个问题,通常来说,跑马灯的内容都是从接口请求得来的,内容是动态变化,这说明内容宽度是不一致。不同宽度的内容在相同的播放动画时间里,它们的移动速度是不一致的,过长的内容会移动得相对较快,过短的内容会移动得相对较慢。

所以,不论内容宽度如何,我们要求动画都要有一个恒定的移动速度,而不是恒定的动画时间。

改进3:动态改变动画时间,得到恒定的速度

只有一个字的内容和有几百个字的内容在相同的动画时间里,肯定是前者速度极快,后者速度极慢,因为前者的位移量比后者的位移量要短很多,因此需要根据内容去动态改变动画时间。

首先,先复习下初中物理的简单的位移公式:s=vt,s为位移,v为平均速度,t为时间。

我们现在要根据不同的内容宽度求一个动态时间,可得时间公式:t=s/v

我们先看下速度v,速度v其实就是我们要定义一个的恒定速度(以 px 单位,因为下面位移公式也是以 px 单位),我们可以假设定义为:150px/s。

然后我们看下位移s怎么求。

在此之前,我先说一下为什么我在该篇文章一直称跑马灯的“文字内容长度”为“内容宽度”,而不是“内容长度”。

因为如果我用了“内容长度”这个词语,我猜某些读者的思维可能会被引导到,求位移就是获取跑马灯的内容字符串,然后使用“字符串.length”进行去获取字符串长度,再根据字符串长度进行计算位移。但是这个所谓的字符串长度其实是字数,并非文字所在的 dom 实际宽度。

这里要用 dom 的实际宽度去求解位移的两个原因:

  • 在等同的 font-size 中,一个数字与一个中文在实际的 dom 上所占的实际宽度是不一致的,但它们都可归为一个字,因此用字数去代表位移,肯定有误差。
  • 没有人说过跑马灯内容只能显示纯文字,如果要在某个关键字里添加一个 a 链接,那是不是计算字数的时候要排除这个 a 标签。而且如果这里还可以添加 emoji 表情和图片呢?那是不是又要额外处理?

好了,回到正题。

位移s = 跑马灯内容宽度 * 2 + 跑马灯容器宽度,如下图所示。

image.png

我们可以很简单的得到以下的动态计算动画时间的 js 代码。

<script>
    let containerWidth = document.querySelector('.marquee-container').offsetWidth // 跑马灯容器宽度
    let boxWidth = document.querySelector('.marquee-container .marquee-box').offsetWidth  // 跑马灯内容宽度
    let duration = (boxWidth * 2 + containerWidth) / 150 + 's' // 动画时间,这里我没有四舍五入,你可以进行四舍五入
    document.querySelector('.marquee-container .marquee-box').style.cssText = 'animation-duration:' + duration
</script>

不过,这段动画看起来似乎有一丢丢的抖动掉帧的感觉,我朋友也是这么说的,目前还没有解决的头绪。

完整代码

下面给出目前最优方案的完整代码。

加强版跑马灯,无缝循环(头尾连接)如何使用 css 实现?

这个问题问得好,我暂时也没有头绪(其实根本就没有想过怎么实现😄),如果你有实现思路,可以在评论区告知我。

参考

虽然上述参考的文章的实现有一些缺陷,如:translateX(100vw) 并不能解决跑马灯容器宽度不等同布局宽度的场景问题,以及动画时间的公式:动画时间 = 文字长度 * 0.2(时间系数,可自行改) + 5(时间基数,可自行改) + "s",并不能正确让不同长短的内容都具有一个恒定的移动速度,首先不应该用文字长度,而应该用 dom 的宽度,其次是这条公式好像是没有什么求解依据。

但很感谢上述文章提供的思路,本文的“改进1”中的“transform: translateX(100vw)”的思路是参考(照搬😏)该文章的思路,以及本文的“改进3”的“通过动态改变动画时间来求得恒定速度”的基础思路来源于该文章。

如果各位大佬有更加好的实现方案,可以在评论区告知我,不管你的实现是使用 js 亦或者是第三方动画库。