处理任何内容的打字机动画

Avatar of Murtuzaali Surti
Murtuzaali Surti

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费积分!

我观看了 Kevin Powell 的视频,他在其中使用 CSS 重现了一个不错的打字机动画。它很巧妙,你绝对应该看看,因为里面有一些货真价实的 CSS 技巧。我相信你已经看过其他 CSS 对此的尝试,包括 本网站自己的代码片段

像 Kevin 一样,我决定重现这个动画,但将其扩展到 JavaScript。这样,我们就有了一些额外的工具,可以使打字感觉更自然,甚至更具活力。许多 CSS 解决方案依赖于基于文本长度的魔法数字,但使用 JavaScript,我们可以制作出能够处理我们抛给它的任何文本的东西。

所以,让我们来做吧。在本教程中,我将展示我们可以通过更改实际文本来自定义多个单词的动画。每次添加新单词时,无需修改代码,因为 JavaScript 会为您完成此操作!

从文本开始

让我们从文本开始。我们使用等宽字体来实现效果。为什么?因为在等宽字体中,每个字符或字母占据相同的水平空间,这在我们使用steps()动画化文本时会派上用场。当我们已经知道字符的确切宽度并且所有字符都共享相同的宽度时,事情会变得更容易预测。

我们在一个容器内放置了三个元素:一个用于实际文本的元素,一个用于隐藏文本的元素,以及一个用于动画化光标的元素。

<div class="container">
  <div class="text_hide"></div>
  <div class="text">Typing Animation</div>
  <div class="text_cursor"></div>
</div>

我们可以在这里使用::before::after伪元素,但它们对于 JavaScript 来说不是很好。伪元素不是 DOM 的一部分,而是用作 CSS 中为元素设置样式的额外钩子。使用真实元素会更好。

我们完全隐藏了.text_hide元素后面的文本。这是关键。它是一个空的 div,它扩展到文本的宽度并将其屏蔽,直到动画开始——那时我们开始看到文本从元素后面移出。

A light orange rectangle is on top of the words Hidden Text with an orange arrow blow it indicating that it moves from left to right to reveal the text.

为了覆盖整个文本元素,将.text_hide元素放置在文本元素的顶部,使其具有与文本元素相同的宽度和高度。请记住,将.text_hide元素的background-color设置为与文本周围背景完全相同,以便所有内容混合在一起。

.container {
  position: relative;
}
.text {
  font-family: 'Roboto Mono', monospace;
  font-size: 2rem;
}
.text_hide {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  background-color: white;
}

光标

接下来,让我们制作那个在文本输入时闪烁的小光标。我们稍后再处理闪烁部分,先专注于光标本身。

让我们创建一个名为.text_cursor的元素。属性将类似于.text_hide元素,但有一个细微的差别:我们不设置background-color,而是将background-color保持为transparent(因为它在技术上没有必要),然后在新的.text_cursor元素的左边缘添加一个边框。

.text_cursor{
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  background-color: transparent;
  border-left: 3px solid black;
}

现在我们得到了一些看起来像光标的东西,它可以随着文本的移动而移动。

The words hidden text behind a light orange rectangle that representing the element hiding the text. A cursor is on the left side of the hidden text.

JavaScript 动画

现在到了超级有趣的部分——让我们用 JavaScript 为这些东西制作动画!我们将从将所有内容包装在一个名为typing_animation()的函数中开始。

function typing_animation(){
  // code here
}
typing_animation();

下一个任务是使用split()方法将文本的每个字符都存储在一个数组中。这会将字符串划分为仅包含一个字符的子字符串,并返回包含所有子字符串的数组。

function typing_animation(){
  let text_element = document.querySelector(".text");
  let text_array = text_element.innerHTML.split("");
}

例如,如果我们将“Typing Animation”作为字符串,则输出为

(16) ["T", "y", "p", "i", "n", "g", " ", "A", "n", "i", "m", "a", "t", "i", "o", "n"]

我们还可以确定字符串中的字符总数。为了只获取字符串中的单词,我们将split("")替换为split(" ")。请注意,两者之间存在差异。这里," "充当分隔符。每当我们遇到一个空格时,它将终止子字符串并将其存储为数组元素。然后,该过程对整个字符串继续进行。

function typing_animation(){
  let text_element = document.querySelector(".text");
  let text_array = text_element.innerHTML.split("");
  let all_words = text_element.innerHTML.split(" ");
}

例如,对于字符串“Typing Animation”,输出将为:

(2") ["Typing", "Animation"]

现在,让我们计算整个字符串的长度以及每个单词的长度。

function typing_animation() {
  let text_element = document.querySelector(".text");
  let text_array = text_element.innerHTML.split("");
  let all_words = text_element.innerHTML.split(" ");
  let text_len = text_array.length;

  const word_len = all_words.map((word) => {
    return word.length;
  });
}

要获取整个字符串的长度,我们必须访问包含所有字符作为单个元素的数组的长度。如果我们说的是单个单词的长度,那么我们可以使用map()方法,它一次访问all_words数组中的一个单词,然后将单词的长度存储到一个名为word_len的新数组中。这两个数组具有相同数量的元素,但一个包含实际单词作为元素,另一个包含单词的长度作为元素。

现在我们可以制作动画了!我们正在使用 Web Animation API,因为我们在这里使用纯 JavaScript——在本例中,我们不使用 CSS 动画。

首先,让我们为光标制作动画。它需要无限地闪烁。我们需要关键帧和动画属性,这两者都将存储在自己的 JavaScript 对象中。以下是关键帧

document.querySelector(".text_cursor").animate([
  {
    opacity: 0
  },
  {
    opacity: 0, offset: 0.7
  },
  {
    opacity: 1
  }
], cursor_timings);

我们已经定义了三个关键帧作为对象,并将其存储在一个数组中。术语offset: 0.7仅表示在动画完成 70% 后,不透明度将从 0 转换为 1。

现在,我们必须定义动画属性。为此,让我们创建一个 JavaScript 对象来将它们放在一起

let cursor_timings = {
  duration: 700, // milliseconds (0.7 seconds)
  iterations: Infinity, // number of times the animation will work
  easing: 'cubic-bezier(0,.26,.44,.93)' // timing-function
}

我们可以像这样给动画命名

let animation = document.querySelector(".text_cursor").animate([
  // keyframes
], //properties);

以下是我们到目前为止所做工作的演示

太好了!现在,让我们为.text_hide元素制作动画,它忠于其名称,隐藏了文本。我们为该元素定义动画属性

let timings = {
  easing: `steps(${Number(word_len[0])}, end)`,
  delay: 2000, // milliseconds
  duration: 2000, // milliseconds
  fill: 'forwards'
}

easing属性定义了动画速率随时间变化的方式。这里,我们使用了steps()时间函数。这会使元素以离散段而不是平滑连续的动画进行动画——你知道,为了获得更自然的打字运动。例如,动画持续时间为两秒,因此steps()函数以9个步骤(“Animation”中的每个字符一个步骤)为两秒钟对元素进行动画处理,其中每个步骤的持续时间为2/9 = 0.22秒。

end参数使元素保持其初始状态,直到第一个步骤的持续时间完成。此参数是可选的,其默认值为end。如果您想深入了解steps(),则可以参考 Joni Trythall 的这篇很棒的 文章

fill属性与 CSS 中的animation-fill-mode属性相同。通过将其值设置为forwards,动画完成后,元素将停留在最后一个关键帧定义的位置。

接下来,我们将定义关键帧。

let reveal_animation_1 = document.querySelector(".text_hide").animate([
  { left: '0%' },
  { left: `${(100 / text_len) * (word_len[0])}%` }
], timings);

现在我们只对一个单词进行动画处理。稍后,我们将了解如何对多个单词进行动画处理。

最后一个关键帧至关重要。假设我们要为“Animation”这个词制作动画。它的长度为9(因为有九个字符),并且由于我们的typing_animation()函数,我们知道它被存储为一个变量。声明100/text_len得到100/9,即 11.11%,这是“Animation”这个词中每个字符的宽度。这意味着每个字符的宽度是整个单词宽度的 11.11%。如果我们将此值乘以第一个单词的长度(在本例中为9),则得到 100%。是的,我们可以直接写 100% 而不是做所有这些事情。但是,当我们为多个单词制作动画时,此逻辑将对我们有所帮助。

所有这些的结果是.text_hide元素从left: 0%动画到left: 100%。换句话说,随着它的移动,此元素的宽度从 100% 减少到 0%。

我们还必须将相同的动画添加到.text_cursor元素,因为我们希望它与.text_hide元素一起从左到右过渡。

耶!我们为单个单词制作了动画。如果我们想为多个单词制作动画怎么办?接下来让我们这样做。

为多个单词制作动画

假设我们有两个我们想要打出的单词,可能是“Typing Animation”。我们通过遵循上次相同的步骤为第一个单词制作动画。但是,这次我们正在更改动画属性中的缓动函数值。

let timings = {
  easing: `steps(${Number(word_len[0] + 1)}, end)`,
  delay: 2000,
  duration: 2000,
  fill: 'forwards'
}

我们将数字增加了一个步长。为什么?好吧,单词后面的空格怎么办?我们必须考虑这一点。但是,如果一个句子中只有一个单词怎么办?为此,我们将编写一个if条件,如果单词数等于 1,则为steps(${Number(word_len[0])}, end)。如果单词数不等于 1,则为steps(${Number(word_len[0] + 1)}, end)

function typing_animation() {
  let text_element = document.querySelector(".text");
  let text_array = text_element.innerHTML.split("");
  let all_words = text_element.innerHTML.split(" ");
  let text_len = text_array.length;
  const word_len = all_words.map((word) => {
    return word.length;
  })
  let timings = {
    easing: `steps(${Number(word_len[0])}, end)`,
    delay: 2000,
    duration: 2000,
    fill: 'forwards'
  }
  let cursor_timings = {
    duration: 700,
    iterations: Infinity,
    easing: 'cubic-bezier(0,.26,.44,.93)'
  }
  document.querySelector(".text_cursor").animate([
    {
      opacity: 0
    },
    {
      opacity: 0, offset: 0.7
    },
    {
      opacity: 1
    }
  ], cursor_timings);
  if (all_words.length == 1) {
    timings.easing = `steps(${Number(word_len[0])}, end)`;
    let reveal_animation_1 = document.querySelector(".text_hide").animate([
      { left: '0%' },
      { left: `${(100 / text_len) * (word_len[0])}%` }
    ], timings);
    document.querySelector(".text_cursor").animate([
      { left: '0%' },
      { left: `${(100 / text_len) * (word_len[0])}%` }
    ], timings);
  } else {
    document.querySelector(".text_hide").animate([
      { left: '0%' },
      { left: `${(100 / text_len) * (word_len[0] + 1)}%` }
    ], timings);
    document.querySelector(".text_cursor").animate([
      { left: '0%' },
      { left: `${(100 / text_len) * (word_len[0] + 1)}%` }
  ], timings);
  }
}
typing_animation();

对于多个单词,我们使用一个for循环来迭代并为第一个单词之后的每个单词设置动画。

for(let i = 1; i < all_words.length; i++){
  // code
}

为什么我们将i设为1?因为当这个for循环执行时,第一个单词的动画已经完成了。

接下来,我们将访问相应单词的长度。

for(let i = 1; i < all_words.length; i++){
  const single_word_len = word_len[i];
}

让我们也为第一个单词之后的每个单词定义动画属性。

// the following code goes inside the for loop
let timings_2 = {
  easing: `steps(${Number(single_word_len + 1)}, end)`,
  delay: (2 * (i + 1) + (2 * i)) * (1000),
  duration: 2000,
  fill: 'forwards'
}

这里最重要的事情是delay属性。如你所知,对于第一个单词,我们只需将delay属性设置为两秒;但现在我们必须以动态的方式增加后续单词的延迟。

第一个单词的延迟为两秒。其动画持续时间也为两秒,总共四秒。但在第一个和第二个单词的动画之间应该有一些间隔,使动画更逼真。我们可以做的是在每个单词之间增加两秒的延迟而不是一秒。这使得第二个单词的总延迟为2 + 2 + 2,即六秒。类似地,第三个单词的总延迟为 10 秒,依此类推。

此模式的函数如下所示

(2 * (i + 1) + (2 * i)) * (1000)

…我们将乘以 1000 将秒转换为毫秒。

单词的长度一个字符动画所需的时间
62/6 = 0.33 秒
82/8 = 0.25 秒
92/9 = 0.22 秒
122/12 = 0.17 秒
* 总持续时间为 2 秒

单词越长,显示速度越快。为什么?因为无论单词有多长,持续时间都保持不变。尝试调整持续时间和延迟属性以获得最佳效果。

还记得我们通过考虑单词后一个空格来更改steps()值吗?同样,句子中的最后一个单词后面没有空格,因此我们应该在另一个if语句中考虑这一点。

// the following code goes inside the for loop
if (i == (all_words.length - 1)) {
  timings_2.easing = `steps(${Number(single_word_len)}, end)`;
  let reveal_animation_2 = document.querySelector(".text_hide").animate([
    { left: `${left_instance}%` },
    { left: `${left_instance + ((100 / text_len) * (word_len[i]))}%` }
  ], timings_2);
  document.querySelector(".text_cursor").animate([
    { left: `${left_instance}%` },
    { left: `${left_instance + ((100 / text_len) * (word_len[i]))}%` }
  ], timings_2);
} else {
  document.querySelector(".text_hide").animate([
    { left: `${left_instance}%` },
    { left: `${left_instance + ((100 / text_len) * (word_len[i] + 1))}%` }
  ], timings_2);
  document.querySelector(".text_cursor").animate([
    { left: `${left_instance}%` },
    { left: `${left_instance + ((100 / text_len) * (word_len[i] + 1))}%` }
  ], timings_2);
}

left_instance变量是什么?我们还没有讨论过它,但它是我们正在做的事情中最关键的部分。让我解释一下。

0%是第一个单词left属性的初始值。但是,第二个单词的初始值应等于第一个单词的最终left属性值。

if (i == 1) {
  var left_instance = (100 / text_len) * (word_len[i - 1] + 1);
}

word_len[i - 1] + 1指的是前一个单词的长度(包括一个空格)。

我们有两个单词,“Typing Animation”。这使得text_len等于16,这意味着每个字符占完整宽度的 6.25%(100/text_len = 100/16),乘以第一个单词的长度7。所有这些计算得到43.75,实际上是第一个单词的宽度。换句话说,第一个单词的宽度是整个字符串宽度的43.75%。这意味着第二个单词从第一个单词结束的地方开始动画。

最后,在for循环的末尾更新left_instance变量。

left_instance = left_instance + ((100 / text_len) * (word_len[i] + 1));

你现在可以在 HTML 中输入任意多个单词,动画就会正常工作

额外内容

你有没有注意到动画只运行一次?如果我们想无限循环它怎么办?这是可能的。


就是这样:一个更强大的打字动画的 JavaScript 版本。CSS 也有方法(甚至多种方法)来做同样的事情,这真的很酷。在某些情况下,CSS 甚至可能是更好的方法。但是,当我们需要超出 CSS 处理范围的功能增强时,添加一些 JavaScript 就能很好地解决问题。在这种情况下,我们为所有单词添加了支持,无论它们包含多少个字符,以及动画多个单词的功能。并且,通过在单词之间添加一个小额的额外延迟,我们得到了一个非常自然的动画效果。

就是这样,希望你发现这很有趣!就此结束。