JavaScript:文本动画实现教程

280 阅读10分钟

我看了Kevin Powell的视频,他用CSS重新制作了一个漂亮的类似打字机的动画。这很好,你一定要看一看,因为里面有真正的CSS技巧。我相信你已经看到了其他的CSS尝试,包括这个网站自己的片段

像凯文一样,我决定重新制作这个动画,但把它开放给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("");
}

例如,如果我们把 "打字动画 "作为一个字符串,那么输出的结果是:

(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 。这两个数组的元素数量相同,但是一个数组包含_实际的单词_,另一个数组包含_单词的长度_。

现在我们可以做动画了!我们使用网络动画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 的步骤("动画 "中每个字符一个步骤)使元素成为动画,持续两秒,其中每个步骤的持续时间为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 元素一起从左到右过渡。

为多个词制作动画

假设我们有两个想要打出来的字,也许是 "打字动画"。我们按照上次的程序为第一个词制作动画。然而这一次,我们要改变动画属性中的缓和函数值。

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 ,即6秒。同样地,为第三个词制作动画的总延迟为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 是指前一个词的长度(包括一个空白)。

我们有两个词,"打字动画"。这使得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就能很好地解决这个问题。在这种情况下,我们增加了对所有单词的支持,无论它们包含多少个字符,以及对多个单词进行动画的能力。而且,在单词之间有一个小的额外延迟,我们得到一个超级自然的动画。

就这样了,希望你觉得这很有趣签字。