动画

本章概要

  • 使用关键帧动画实现页面复杂动效
  • 页面加载时播放动画的方法
  • 使用旋转动画提供反馈
  • 吸引用户关注保存按钮,提醒用户保存信息

在前两章中,我们构建了几种过渡特效,实现了将元素从一个状态平滑变切换另一个状态的效果。这为页面增添了动态效果,用户体验方面也增加了视觉趣味性,但有时候仅仅使用过渡是不够的。

过渡是直接从一个地方变换到另一个地方;相比之下,我们偶尔也希望某个元素的变化过程是迂回的路径(roundabout path);又或者,我们可能想让元素在动画结束后再回到起始位置。而过渡特效是无法满足这类需求的。为了对页面变化有更加精细的把控,CSS 提供了关键帧动画(keyframe animation)技术。

关键帧(keyframe) 是指动画过程中的某个特定时刻。人们先定义出一些关键帧,浏览器再负责填充(或插值计算)补全这些关键帧之间的帧图像(如图 17.1 所示)。这些插值产生的帧值将在关键帧之间连续变化,因此画面会从一个关键帧平滑地过渡到下一个关键帧。

【图 17.1 定义关键帧后,浏览器会对其间的所有帧图像作插值处理】

从原理上看,过渡其实和关键帧动画类似:我们定义出第一帧(起点)和最后一帧(终点),浏览器算出所有的中间值,使得元素可以在这些值之间平滑过渡。但在关键帧动画中,我们不再局限于只定义两个点,而是想加多少就加多少。浏览器负责填充一个个点与点之间的各类取值,直到最后一个关键帧,最终产生一系列无缝衔接的过渡效果。

最后这一章将介绍如何创建关键帧动画。我们会在前一章创建的示例页中添加一些动画效果,然后探索它们的一些其他使用方式。动画并非只能让页面变得生动有趣,还能向用户传达有意义的反馈。

17.1 关键帧 Keyframes
CSS 中的动画包括两部分:用于定义动画的 @keyframe 规则,以及给某个元素添加动画效果的 animation 属性(property)。

不妨实现一个简单的动画效果来熟悉一下相关语法。这个动画包含三个关键帧,如图 17.2 所示。在第一帧中,元素是红色的;第二帧中元素是浅蓝色的,并且向右移动了 100px;而在最后一帧中,元素则是淡紫色的,并且回到了左侧的初始位置。

【图 17.2 分别对元素的颜色和位置添加了动画的三个关键帧】

该动画对 background-color 和 transform 这两个属性做了一些调整,对应的关键帧规则样式如代码清单 17.1 所示。新建样式表 style.css 并添加下列代码:

代码清单 17.1 定义关键帧的@规则样式写法

@keyframes over-and-back { /* 给动画命名 */
  0% { /* 第一个关键帧声明 */
    background-color: hsl(0, 50%, 50%);
    transform: translate(0);
  }
  50% { /* 第二个关键帧出现在动画进行到一半时 */
    transform: translate(50px);
  }

  100% { /* 最后一个关键帧 */
    background-color: hsl(270, 50%, 90%);
    transform: translate(0);
  }
}

关键帧动画必须命名。本例定义了一个名为 over-and-back 的动画。该动画使用百分比又定义了三个关键帧。这些百分比分别代表每个关键帧在动画中出现的某个时刻:一个在动画的初始时刻(0%),一个在中间(50%),一个在结束时(100%)。每个关键帧代码块内的样式声明则分别定义了当前关键帧的具体样式。

在本例中,我们同时为两个属性添加了动画;但同时也要注意,我们并没有对每个关键帧都设置了这两个属性。transform 将元素从初始位置平移到右侧,然后再移回原位;而 background-color 在 50% 的关键帧中并没有设定。也就是说元素会由红色(即 0% 处)过渡到淡紫色(即 100% 处);到 50% 时,元素的背景色恰好是这两种颜色的中间值。

下面我们把这些代码添加到页面中看看实际效果。新建一个 HTML 文档,然后根据代码清单 17.2 中的内容同步更新文档。

代码清单 17.2 仅包含一个带动画效果盒子的示例页 HTML 标记

<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="styles.css">
  </head>
  <body>
    <div class="box"></div><!-- 待添加动画的元素 -->
  </body>
</html>

接下来,在样式表中给 box 元素设计样式并添加动画特效。复制以下代码即可。

代码清单 17.3 给元素添加动画特效的示例样式代码

.box {
  width: 100px; /* 给演示元素指定宽度 */
  height: 100px; /* 给演示元素指定高度 */
  background-color: green;
  animation: over-and-back 1.5s linear 3; /* 给元素设置动画特效 */
}

在浏览器中打开页面,会看到示例动画重复执行了三遍方才停止。

animation 属性是好几个 CSS 属性的简写形式,在本例中,我们其实设置了以下四个样式属性:

animation-name(即 over-and-back)—— 代表动画名称,正如 @keyframes 规则定义的那样。
animation-duration(即 1.5s)—— 代表动画持续的时长,在本例中,即持续 1.5s。
animation-timing-function(即 linear)—— 代表定时函数,用于描述动画的加速与(或)减速过程。其属性值既可以是贝赛尔曲线,也可以是某个关键字,正如 CSS 过渡用到的定时函数那样(ease-in、ease-out 等等)。
animation-iteration-count(即 3)—— 代表动画重复的次数。其初始值默认为 1。
重新加载页面查看动画效果,观察动画执行过程中的这几个地方:

第一,颜色从 0% 的红色平滑过渡到 100% 的浅紫色,但是接下来动画重复的时候立即变回红色。如果需要重复某个动画并希望整体衔接流畅,需要确保结束值与初始值相匹配。

提示

您也可以使用关键字 from 和 to 来分别代替起始和结束关键帧的 0% 与 100%。稍后还会演示一个该写法的案例(详见代码清单 17.12)。

其次,在最后一次重复动画结束后,背景色变为绿色,即原样式规则中指定的值。但注意观察,在动画持续过程中,该样式声明被 @keyframes 中的规则覆盖了。如果出现样式层叠,那么动画中设置的规则将会比其他声明拥有更高的优先级。

回顾一下第一章(详见第 1.1.1 节)介绍的知识,层叠规则的第一部分就介绍了样式表的来源。作者样式之所以优先级高于用户代理样式,是因为作者样式具有比较高的优先级来源;而动画中应用的声明还拥有比它更高的优先级来源。这样,在为某个属性添加动画时,动画样式就会覆盖样式表中其他位置指定的样式。这就确保了关键帧中所有的声明可以相互配合完成动画,而不用关注动画之外该元素都设置了哪些样式。

17.2 3D 变换下的动画设置

Animating 3D transforms
接下来,我们继续对上一章留下的示例页添加动画特效。在完成代码清单 17.11 后,我们就有了一个蓝色背景的页面,页面左侧为导航菜单。我们将用几张内容卡片来填充页面剩余的部分。先来完成整个效果图的页面布局,然后再设置动画。

17.2.1 添加动画前页面布局的构建

Building the layout without animations

在这个演示中,我们会在页面主区域添加一些卡片(如图 17.3 所示)。然后再使用 3D 变换添加动画,让卡片具有飞入效果。

【图 17.3 页面主区域添加的内容卡片效果图】

代码清单 17.4 展示了这部分内容的 HTML 标记。将这些代码添加到页面的 <nav> 元素后面(为了节省空间,代码中卡片内的文字有删减。如果想要更加接近图 17.3 所示的效果,可以随意添加更多内容)。

代码清单 17.4 创建 flyin-grid 部分以及另几张卡片的 HTML 标记

<main class="flyin-grid"><!-- 网格容器 -->
  <div class="flyin-grid__item card"><!-- 内容卡片,同时也是网格元素 -->
    <img src="images/chicken1.jpg" alt="a chicken"/>
    <h4>Mrs. Featherstone</h4>
    <p>
      She may be a bit frumpy, but Mrs. Featherstone gets
      the job done. She lays her largish cream-colored
      eggs on a daily basis. She is gregarious to a fault.
    </p>
  </div>
  <div class="flyin-grid__item card"><!-- 内容卡片,同时也是网格元素 -->
    <img src="images/chicken2.jpg" alt="a chicken"/>
    <h4>Hen Solo</h4>
    <p>
      Though the most recent addition to our flock, Hen
      Solo is a fast favorite among our laying brood.
    </p>
  </div>
  <div class="flyin-grid__item card"><!-- 内容卡片,同时也是网格元素 -->
    <img src="images/chicken3.jpg" alt="a chicken"/>
    <h4>Cluck Norris</h4>
    <p>
      Every brood has its brawler. Cluck Norris is our
      feistiest hen, frequently picking fights with other
      hens about laying territory and foraging space.
    </p>
  </div>
  <div class="flyin-grid__item card"><!-- 内容卡片,同时也是网格元素 -->
    <img src="images/chicken4.jpg" alt="a chicken"/>
    <h4>Peggy Schuyler</h4>
    <p>
      Peggy was our first and friendliest hen. She is the
      most likely to greet visitors to the yard, and
      frequently to be found nesting in the coop.
    </p>
  </div>
</main>

这部分页面由两个模块组成。外层模块是飞入网格(flyin-grid),为网格内的元素提供了布局,同时还包含了稍后即将介绍的 3D 飞入效果。每个网格元素都是内层卡片模块的一个实例。卡片模块则提供了外观样式,包括白色背景、内边距和字体颜色。

这种页面的布局首推网格布局,接下来就会用到。我们将同样率先实现移动端布局,然后为更大尺寸的视口添加基于网格布局的页面样式。

移动端布局的页面效果如图 17.4 所示。对于小尺寸屏,内容卡片将填满屏幕整个宽度,只留出一点左右外边距。

【图 17.4 在移动端布局中,卡片会填满屏幕宽度,依次叠放在导航菜单下方】

请将代码清单 17.5 所示的移动端样式代码,添加到本地样式表的 modules 模块图层中。

代码清单 17.5 内容卡片的移动端示例样式代码

@layer modules {
  .flyin-grid {
    margin-inline: 1rem; /* 在容器左右两侧添加一段很小的外边距 */
  }

  .card {
    margin-block-end: 1em;
    /* 为卡片添加颜色和其他细节样式: */
    padding: 0.5em;
    background-color: white;
    color: oklch(32% 0.02 248deg);
    box-shadow: 3px 8px 15px rgb(0 0 0 / 0.3);
  }
  .card > img {
    width: 100%; /* 令图片占满内容卡片的全部宽度 */
  }
}

在这样的屏幕尺寸下,flyin-grid 部分很轻松就能实现,因为网格元素只需要像常规块级元素那样正确堆叠即可。每张卡片都设置了白色背景和简单的外观样式。稍后我们就会利用媒体查询设置更复杂的布局。

接下来需要对屏幕尺寸更大的断点设置网格布局。设置完毕后的页面效果看起来会和最终设计图非常接近(如图 17.3 所示)。请根据代码清单 17.6 同步更新本地样式表。

代码清单 17.6 网格布局的示例样式代码

.flyin-grid {
  margin-inline: 1rem;
}

@media (min-width: 30em) {
  .flyin-grid {
    margin-inline: 5rem;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); /* 定义列宽 */
    gap: 2em;
  }
}

上述代码中,网格列(grid columns)将确保所有的网格元素宽度相同。使用 repeat() 和 auto-fit 关键字可以让网格自行决定当前视口宽度下最合适的列数是多少;而在小尺寸视口中仍将显示更为简单的移动端布局。

17.2.2 为布局添加动画

Adding animation to the layout

既然页面的设计与布局工作已经完成,接下来就该添加动画了。页面加载时,卡片是带飞入特效的,如图 17.5 所示。卡片会以围绕纵轴旋转 90 度的初始状态,从页面远端出现。然后它们会飞向观众,并在动画行将结束时旋转到直接朝向观众。图 17.5 展示了定义该动画特效的三个关键帧。

【图 17.5 使用 3D 变换实现从远端飞入内容卡片的效果图】

这段动画包含两个 CSS 变换:translateZ() 用于让卡片从远端飞回页面,而 rotateY() 负责旋转卡片。代码清单 17.7 列出了相关样式代码,其中包括对 flyin-grid 容器的透视距离的设置、关键帧的定义,还为每个 flyin-grid 元素指定了动画特效。此外,我们还添加了不透明度 opacity 的相关设置,这样元素飞入时就会带有过渡特效。

代码清单 17.7 添加飞入动画的示例样式代码

.flyin-grid {
  margin-inline: 1rem;
  perspective: 500px; /* 在容器上设置统一的透视距离 */
}
.flyin-grid__item {
  animation: fly-in 600ms ease-in; /* 为每个元素添加动画特效 */
}

@keyframes fly-in {
  0% {
    transform: translateZ(-800px) rotateY(90deg); /* 旋转后,从远处开始动画 */
    opacity: 0;
  }
  56% {
    transform: translateZ(-160px) rotateY(87deg); /* 已经很接近了,但卡片大部分仍是旋转状态 */
    opacity: 1;
  }
  100% {
    transform: translateZ(0) rotateY(0); /* 在正常位置结束动画 */
  }
}

上述 CSS 样式在容器上设置了透视距离,这样所有的元素都会处于相同的透视场景中;同时还为每个元素设置了动画特效。重新加载页面查看动画效果。

动画开始时将旋转了 90 度的元素放回远处。在起始关键帧和中间关键帧之间,元素沿 z 轴一路向前快速推进(从 800px 来到 160px),并且从透明状态逐渐过渡到完全不透明。从中间关键帧到结束关键帧,最后一小段推进结束,同时大部分的旋转动画发生在这段时间内。

17.3 动画延迟与填充模式

Animation delay and fill mode

动画特效可以使用 animation-delay 属性进行延迟,该属性的行为与 transition-delay 属性类似。我们可以利用它来让动画交错发生,这和上一章中导航菜单交错产生过渡效果的方式差不多。通过让每个元素的动画延迟不同的时间,就能让这些元素一个接一个地飞入,如图 17.6 所示。

图 17.6 元素利用交错的动画特效飞入屏幕的页面效果
图 17.6 元素利用交错的动画特效飞入屏幕的页面效果

给这四个网格元素添加动画延迟效果的样式代码如代码清单 17.8 所示。不过这段代码并没有完全按照我们预期的方式运行。先把它们同步到本地样式表,再来研究一下问题出在哪儿、又该怎么解决。

代码清单 17.8 错开动画特效开始时间的示例样式代码

.flyin-grid__item {
  animation: fly-in 600ms ease-in;
}

.flyin-grid__item:nth-child(2) {
  animation-delay: 0.15s; /* 让每个元素的动画开始时间都比前一个略晚一些 */
}
.flyin-grid__item:nth-child(3) {
  animation-delay: 0.3s; /* 让每个元素的动画开始时间都比前一个略晚一些 */
}
.flyin-grid__item:nth-child(4) {
  animation-delay: 0.45s; /* 让每个元素的动画开始时间都比前一个略晚一些 */
}

要是在浏览器中加载示例页面,估计就会发现问题了。虽然动画确实是在预期的时间点开始播放的,但有些元素提前渲染到了页面上,随即立刻消失,然后又开始播放动画(如图 17.7 所示)。这就不太合理了,看起来也不是我们想要的效果。我们希望所有的元素一开始都是不可见的,只有在各自的动画开始播放的时候才会出现。

图 17.7 后面的元素在动画尚未开始播放时就提前出现在了最终位置
图 17.7 后面的元素在动画尚未开始播放时就提前出现在了最终位置

出现这样的问题,原因就在于设置在 transformopacity 属性的样式只在动画播放时才会生效。动画开始之前,网格元素在页面上是可见的,并且位于各自的正常位置;而当动画一开始播放,它们就瞬间变为 0% 关键帧上设置的样式效果。我们希望动画能在时间上延迟生效( apply backward in time),就像一直在第一帧位置暂停,直到该它播放的时候才正式开始。这样的效果可以通过 animation-fill-mode 属性来实现(如图 17.8 所示)。

图 17.8 利用 animation-fill-mode 属性,动画样式可以在动画播放的前后位置生效
图 17.8 利用 animation-fill-mode 属性,动画样式可以在动画播放的前后位置生效

上图中的深色方框表示动画特效的持续时间。属性 animation-fill-mode 的初始值为 none,其含义是:动画样式无论是在动画播放以前、还是在动画结束之后,都不会在对应元素上生效;若声明 animation-fill-mode: backwards,浏览器则会取出动画中第一帧的样式值,并在动画播放之前将其应用到对应元素上;若属性值为 forward,则会在动画结束后让最后一帧的样式值在对应元素生效;若值为 both,则会同时向动画前后沿用相应的样式。

要修复动画开始时的元素跳动问题,需要设置动画填充模式为后置填充(即 backwards)。根据代码清单 17.0 同步更新本地样式表。

代码清单 17.9 动画填充模式设为后置填充的示例样式代码

.flyin-grid__item {
  animation: fly-in 600ms ease-in;
  animation-fill-mode: backwards; /* 在动画开始前,应用第一帧上的动画样式 */
}

这样就能让动画一开始就暂停在第一帧位置,等待动画的播放。此时,在动画开始之前,网格元素向后平移至 800px 位置、并旋转了 90 度角、且不透明度为 0,时刻准备迎接动画的播放。

因为动画结束时元素就停在它们本来的位置上,所以无需设置前置填充(即 forward);内容卡片就这样圆满地从动画的最后一帧稳稳过渡到了元素的静止位置。

不妨再留一些时间驻足欣赏一下我们构建出的示例页吧。我们几乎将整本书中的 CSS 技术都汇聚在了这一个页面上,例如:Flexbox 弹性盒布局与 Grid 网格布局、元素的定位、响应式设计、自定义字体、颜色的选取、适当的阴影与渐变效果、以及 CSS 过渡与动画特效的结合等等。代码本身也根据不同的用途组织为相应的图层、以及轻量且便于理解的模块了。

17.4 通过动画传递意图

Conveying meaning through animation

人们对动画有个普遍误解,认为它们只是用来增强页面的趣味性,并没有什么实际用处。不得不说有时候确实如此(正如上一个案例),但也不总是这样。效果最好的动画特效往往并不是最后才加上的,而是融入到开发过程中。它们往往向用户传递着页面内容的某些特殊含义。

17.4.1 对用户交互的反馈

Responding to user interaction

动画可以向用户表明某个按钮被点击了,或者某个消息已被接收。如果您曾经提交过表单,回想一下是否经常记不清自己点没点过注册按钮,就知道这有多重要了。

我们不妨在一个新页面上构建一个包含提交按钮的小表单,然后添加一个旋转的指示器,让用户知道表单正在发送,浏览器正在等待服务器响应。该表单如图 17.9 所示,由一个文本标签、一个文本域(text area)和一个按钮组成。

图 17.9 一个带 “Save” 字样(保存)的简化表单
图 17.9 一个带 “Save” 字样(保存)的简化表单

我们来新建一个页面和空白样式表来实现上面这个表单。将代码清单 17.10 中的示例 HTML 添加到示例页。

代码清单 17.10 带保存按钮的表单 HTML 标记

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <form>
      <label for="trip">Tell us about your first trip to the zoo:</label>
      <textarea id="trip" name="about-my-trip" rows="5"></textarea><!-- 文本域 -->
      <button type="submit" id="submit-button">Save</button><!-- 提交按钮 -->
    </form>
  </body>
</html>

先实现一些基础样式,让页面具有合适的布局和外观;之后在加入一些有意义的动画特效来增强用户体验。根据代码清单 17.11 同步更新本地样式表。

代码清单 17.11 调整表单布局并设置基础样式

body {
  font-family: Helvetica, Arial, sans-serif;
}

form {
  max-width: 500px; /* 限定表单的最大宽度 */
}

label,
textarea {
  display: block;
  margin-bottom: 1em;
}

textarea {
  width: 100%;
  font-size: inherit;
}

button {
  padding: 0.6em 1em;
  border: 0;
  font: inherit;
  color: white; /* 白色字体 */
  background-color: oklch(54% 0.14 260deg); /* 蓝色背景 */
  transition: background-color 0.3s linear;
}
button:hover {
  background-color: oklch(46% 0.11 260deg); /* 鼠标悬停时按钮颜色加深 */
}

假设该表单时某个大型 Web 应用中的一部分。当用户点击 “Save” 字样的保存按钮时,该表单就会将数据发送到服务器,没准还会在收到响应后再页面上添加一些新内容。但是等待网络响应需要时间,如果在这期间能有一些形象的指示信息(visual indication)告诉他们数据以及提交了,并且很快就会有反馈,那么用户心里就会比较踏实。而动画正是提供此类指示信息的通用解决方案。

我们可以修改 “Save” 按钮,并增加一种 “加载中”(“is-loading”)的状态。此时按钮标签将被隐藏,取而代之的是一个旋转图标(如图 17.10 所示)。用户提交表单时,我们使用 JavaScript 给按钮添加样式类 is-loading,从而实现动画效果。

图 17.10 用户点击保存时,按钮会出现一个旋转图标
图 17.10 用户点击保存时,按钮会出现一个旋转图标

我们可以采用不同的方式设计出图中的旋转图标。这是我比较喜欢的一种设计:一个旋转的月牙形状,外观简约小巧但效果还不错。添加旋转图标需要对 CSS 做两处改动:先用边框和圆角半径制作出月牙形状,然后设置动画让它旋转起来。为此,还需要少量的 JavaScript 脚本,在按钮被点击时添加 is-loading 样式类。

相关的 CSS 代码如代码清单 17.12 所示。该示例代码会在按钮的一个设置了绝对定位的伪元素上添加动画特效。将它们同步更新到本地样式表。

代码清单 17.12 定义旋转动画以及 is-loading 状态的示例样式代码

button.is-loading {
  position: relative;
  color: transparent; /* 隐藏按钮文字 */
}
button.is-loading::after {
  position: absolute;
  content: "";
  display: block;
  width: 1.4em;
  height: 1.4em;
  top: 50%; /* 将伪元素定位到按钮中心位置 */
  left: 50%; /* 将伪元素定位到按钮中心位置 */
  margin-left: -0.7em; /* 将伪元素定位到按钮中心位置 */
  margin-top: -0.7em; /* 将伪元素定位到按钮中心位置 */
  border-top: 2px solid white;
  border-radius: 50%;
  animation: spin 0.5s linear infinite; /* 循环播放旋转动画 */
}

@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg); /* 令每次循环都旋转一周 */
  }
}

这样按钮的 is-loading 状态就定义好了。一旦触发,按钮的文字将在 color: transparent 的作用下变为不可见,并且伪元素通过绝对定位放在了按钮的中心位置。

这里的定位稍微有点麻烦。topleft 属性分别将伪元素下移了按钮高度的一半、并向右平移了按钮宽度的一半,这样伪元素的 左上角(top-left corner 恰好位于按钮的中心点。然后,负的外边距又将伪元素分别向上、向左拉回了 0.7em,而 0.7em 正好是按钮宽高的一半。这四个属性作用在一起,就能让伪元素在按钮的水平和垂直方向上同时居中对齐。您可以临时添加样式类 is-loading,并在浏览器的开发者工具 DevTools 中分别调整这些尺寸,体会一下它们是怎样让伪元素居中的。

伪元素定位后,再来看看动画的实现。这里用到了一个新的关键字来限定动画重复次数:infinite。也就是说,只要按钮添加了样式类 is-loading,动画便会一直重复。动画设置了从 0 度到 360 度的旋转变换,从而让伪元素可以完成一整圈旋转。由于单次动画结束时元素恰好停在了初始位置,使得每次动画的重复播放都能做到无缝衔接。

将代码清单 17.13 中的 script 标签添加到示例页面。这样借助 JavaScript 脚本,就能在单击按钮时添加 is-loading 类。记得把这段代码放到 <body> 标签的开头。

代码清单 17.13 点击按钮时添加 is-loading 类的示例 JavaScript 代码

<script type="module">
  var input = document.getElementById('trip');
  var button = document.getElementById('submit-button');

  button.addEventListener('click', (event) => {
    event.preventDefault();  // 组织表单提交
    button.classList.add('is-loading');  // 显示“加载中”旋转图标
    button.disabled = true;
    input.disabled = true;

    /* 此处代码用于 JavaScript 提交表单数据 */
  });
</script>

点击保存按钮时,preventDefault() 阻止了正常的表单提交。这样,在用户利用应用程序中的 JavaScript 提交表单数据时,就会停留在当前页面,而不是跳转离开。在此期间,输入框时禁用状态,并且按钮也添加了 is-loading 样式类,旋转图标也会渲染出来。加载页面并点击按钮,看看旋转指示器的最终效果。

我们在这里并没有真正提交表单数据,因为没有可供提交数据的服务器。而在实际的应用开发过程中,一旦服务器响应,我们就可以重新启用表单并移除 is-loading 类。为方便演示,本例只要刷新页面就能重置表单并移除样式类。

17.4.2 吸引用户注意力

Drawing the user’s attention

动画也可以用来吸引用户的注意力。如果预见到用户可能会在文本域中输入较多的内容,我们可以提醒用户在输入时及时保存;使用动画快速晃动按钮,就可以提示用户保存他们输入的内容(如图 17.11 所示)。

图 17.11 通过快速左右移动按钮来产生摇晃效果

【图 17.11 通过快速左右移动按钮来产生摇晃效果】

多次快速左右变换元素,就可以产生类似的晃动效果。我们可以定义一个关键帧动画,并使用 shake 类将动画应用到按钮元素上。根据代码清单 17.14 同步更新样式表。

代码清单 17.14 定义摇晃动画效果的示例样式代码

.shake {
  animation: shake 0.7s linear;
}

@keyframes shake {
  0%,
  100% { /* 动画运行期间,在多个位置使用相同的关键帧定义 */
    transform: translateX(0);
  }
  10%,
  30%,
  50%,
  70% {
    transform: translateX(-0.4em); /* 元素左移 */
  }
  20%,
  40%,
  60% {
    transform: translateX(0.4em); /* 元素右移 */
  }
  80% {
    transform: translateX(0.3em); /* 最后一次晃动减小平移量 */
  }
  90% {
    transform: translateX(-0.3em); /* 最后一次晃动减小平移量 */
  }
}

我们在这个动画中做了一些新的尝试:在整个动画过程中多次应用相同的关键帧定义。

在开始(0%)和结束(100%)关键帧,元素位于默认位置。因为这两个关键帧使用了相同的值,所以可以只定义一次属性值,并用逗号分隔。10%30%50%70% 处的关键帧也一样,都是把元素向左平移;而 20%40%60% 处的关键帧则是把按钮向右平移。80%90% 这两个关键帧分别向右侧和左侧平移,只不过幅度要小一些。

动画一共摇动了按钮四次,其中第四次幅度有所降低,用来模拟运动即将结束时按钮的减速过程。您也可以暂时把 shake 类添加到按钮上,这样页面加载时就能直接看到动画效果。

注意

样式表中的动画可以多次反复调用,因此动画的定义无需和最终使用动画的样式模块放在一起。我喜欢把所有的 @keyframes 定义聚集在一块儿,放到样式表的末尾。

最后,在我们认为用户可能需要保存输入内容的时候,使用 JavaScript 播放动画。这可以使用 keyup 时间监听器和超时函数来实现。当用户向文本域输入字符的时候,我们可以设置一个一秒的超时函数,向按钮添加 shake 类。如果用户在一秒结束之前输入了其他内容,我们就清除计时,并重新设置一个新的。试根据代码清单 17.15 同步更新本地示例页中的 script 标签内容。

代码清单 17.15 一秒延迟后添加 shake 样式类的示例 JavaScript 脚本

<script type="module">
  var input = document.getElementById('trip');
  var button = document.getElementById('submit-button');

  var timeout = null; // 定义一个引用超时设置的变量

  button.addEventListener('click', function(event) {
    event.preventDefault();
    clearTimeout(timeout); // 取消等待中的超时函数(如果有的话)
    button.classList.add('is-loading');
    button.disabled = true;
    input.disabled = true;
  });

  input.addEventListener('keyup', function() {
    clearTimeout(timeout); // 取消等待中的超时函数(如果有的话)
    // 等待一秒后添加 shake 样式类:
    timeout = setTimeout(function() {
      button.classList.add('shake');
    }, 1000);
  });
  // 动画结束后移除 shake 样式类
  button.addEventListener('animationend', function() {
    button.classList.remove('shake');
  });
</script>

现在加载页面,并在文本域中输入一些文字内容。等待一秒后,保存按钮将出现晃动。只要我们持续输入内容,计时器就会不断重置,摇晃动画便不会触发,直至下一次停止输入且超过一秒以后。这样晃动的按钮不会总去打断用户,只在用户中途停下来时才会提醒。

我们还用到了 JavaScript 中的 animationend 事件。摇晃动画播放结束时才会触发这个事件。事件触发后,shake 类会从按钮上移除,这样在用户下次输入并停下来时便于重新添加 shake 类,接着再次执行动画。

像这样使用 JavaScript 添加和移除样式类可能是操作动画最简单的方式了,但如果您特别熟悉这门语言,JavaScript 还提供了一整套处理 CSS 动画交互的 API,其中包含暂停、取消以及逆向播放动画等功能。想要了解更多信息,可以查看 MDN 的线上文档 Animation

无论是加载标识还是保存按钮的晃动特效,这些动画效果都想用户传达了很多信息。用户无需阅读任何说明就能知晓一切。这些效果能立马传递各自携带的信息,让用户界面不那么突兀(less obtrusive)。

我们在开发 Web 应用时,应该经常思考是否可以使用动画向用户提供有价值的反馈,即便是微不足道的动画特效。可能是在发送邮件时,可以飞出屏幕边缘的某个文本域;或者是删除草稿时,让草稿缩小并消失的某个动画特效。动画无需多么显眼或者炫酷(flamboyant),只需要给用户适当的反馈,让他们知道自己的操作确实是在按预期进行就行了。

想要使用一组出色的预定义关键帧动画集,推荐一个优秀的网站:animista。该网站提供了一组数量庞大且功能丰富的动画库供人们选用,例如像果冻一样的弹跳、滚动、摇晃等动画特效。

17.5 基于页面滚动的动画时间线设置

Scroll-based timelines

动画特效常常以线性方式从头播到尾,但它们也可以与页面滚动(scrolling)绑定到一起。当用户向下滚动页面时,可以让动画随页面滚动同步播放;如果向上滚动,动画则相应反向进行。要实现这类动效往往需要借助 JavaScript 脚本;但 CSS 提供的一个全新属性,却能在不依赖任何 JavaScript 的情况下实现同样的效果。

要用纯 CSS 实现同样的页面效果,只需在动画元素上声明 animation-timeline: scroll() 即可。然而,截至 2024 年年中,该特性还尚未得到 FirefoxSafari 浏览器的支持。因此目前并不推荐在生产环境中启用该写法,除非是在页面功能业已丰富齐全的情况下、仅仅出于锦上添花的需求来引入该特性。查看该特性最新的浏览器支持情况,详见 Can I Use 官网:https://mng.bz/qOvr

鉴于此,本节将向您演示一个能在 ChromeEdge 浏览器中运行的简单示例。在一个新建的样式表中,添加如代码清单 17.16 所示的样式代码。该代码会在页面的右上角定位一个蓝色的指示标记块,并在上面定义一个基础的旋转动画效果。

代码清单 17.16 基于页面滚动的动画时间线示例样式代码

body {
  margin: unset;
  height: 200lvh; /* 令页面强制滚动 */
}

.indicator {
  position: fixed;
  top: 15px;
  right: 15px;
  height: 50px;
  width: 50px;
  background-color: oklch(74% 0.08 260deg);
  animation-name: spin;
  animation-timeline: scroll(); /* 将动画与滚动位置相绑定 */
}

@keyframes spin {
  from {
    rotate: 0deg;
  }

  to {
    rotate: 360deg;
  }
}

上述代码中的指示标记块会随着用户页面的滚动而旋转,并且标记块在动画中的位置与用户滚动的页面位置息息相关;若页面向下滚动到 25% 位置处停止,则动画特效也会沿着第一帧到最后一帧的方向,在 25% 的位置处同步停止。

该动效最好放到交互式环境中进行查看。将上述样式表关联到一个包含 <div class="indicator"> 元素的示例页中,如代码清单 17.17 所示。这样就能在浏览器中进行查看了。

代码清单 17.17 页面滚动动画特效对应的示例 HTML 标记

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link href="styles.css" rel="stylesheet">
  </head>
  <body>
    <div class="indicator"></div><!-- 带旋转特效的指示标记元素 -->
  </body>
</html>

默认情况下,scroll() 函数会把元素的动画特效绑定到 DOM 树中具有滚动条的最近的那个祖先级元素。该函数还可以通过下列参数值限定不同的行为模式:

此外,您也可以手动指定动画效果与页面滚动的某个方向进行绑定。该方向值可以是默认的 block 方向,也可以是 inliney 或者 x 方向。例如,指定 scroll(inline root) 后,动画特效会相对于根元素的任意行内滚动进行绑定;而声明 scroll(y nearest) 则会令元素动画与带有滚动条的、最近祖先元素的垂直方向进行绑定。

前面演示的案例都比较简单,但通过将一些富有创意的设计方案与更为复杂的动画特效相结合,我们就能构建出更多极具想象力的动画效果。例如,可以让页面上的装饰性元素在页面滚动经过它们的时候,利用动画特效从框架外部进入页面;再比如,还可以利用动画时间线原理设计一个 视差(parallax 效果,即当用户滚动页面时,让页面不同位置的内容以不同的速度进行移动。

相了解更多基于页面滚动的动画特效案例用法,可以参考发表在 Codrops 网站上的这篇文章:《A Practical Introduction to Scroll-Driven Animations with CSS scroll() and view()》。这样的动画特效可以让页面更加生动有趣且便于交互,即便页面只包含仅供浏览的内容。

17.6 最后一点建议

One final piece of advice

对很多 Web 开发者而言,CSS 是一门让人望而生畏的语言。只因它的一只脚踩在设计领域,另一只脚却踩在代码世界。CSS 语言中有些内容并不太直观,特别是对于那些自学的开发者,情况更是如此。希望这本书可以帮您找到一些门道。

我们已经深入学习了 CSS 这门语言中最基础的部分,也学习了一些页面布局中容易产生疑惑的内容。其中涵盖了很多话题:从如何组织 CSS 让代码更易于维护,到最新的页面布局方案。我们还尝试涉足设计领域,并构建出了方便实用且赏心悦目的界面(interface)。

我最后再给您一条建议,那就是永葆好奇心。我已经向您介绍了 CSS 工具集里的一大批工具,但这些工具可以组合与搭配的方式是无穷无尽的。当您遇到某个令人印象深刻的 Web 页面时,不妨打开浏览器的开发者工具,试着去弄清它的实现原理。新的 CSS 特性层出不穷,尤其是在过去的几年里,这些新特性新语法更是让人眼花缭乱。多多关注那些不断推出创意演示案例、或者提供趣味教程的线上开发者与设计师;不断尝试新鲜事物,养成终身学习的好习惯。

17.7 本章小结