使用 Web Animations API 实现叠加动画

Avatar of Dan Wilson
Dan Wilson 发布

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

在撰写本文时,这些功能尚未在任何稳定版浏览器中上线。但是,本文讨论的所有内容默认已包含在 Firefox Nightly 中,并且关键部分已包含在 Chrome Canary 中(启用了实验性 Web 平台功能标志),因此我建议您在阅读本文时使用其中一个浏览器,以便尽可能多地看到这些功能的实际效果。

无论您在 Web 上首选哪种动画方法,都会有需要在单独的动画中对同一属性进行动画处理的情况。也许您有一个使图像缩放的悬停效果,以及一个触发平移的点击事件——两者都影响了 transform。默认情况下,这些动画彼此之间一无所知,并且只会视觉上应用一个动画(因为它们影响的是相同的 CSS 属性,另一个值将被覆盖)。

element.animate({
  transform: ['translateY(0)', 'translateY(10px)']
}, 1000);

/* This will completely override the previous animation */
element.animate({
  transform: ['scale(1)', 'scale(1.15)']
}, 1500);

在这个 Web Animations API 示例中,第二个动画是唯一会在视觉上呈现的动画,因为这两个动画同时播放,并且它是最后定义的动画。

有时我们甚至有更宏伟的想法,我们希望有一个基础动画,然后根据某些用户交互状态的变化,在中途平滑地修改动画,而不会影响其现有的持续时间、关键帧或缓动效果。CSS 动画和当前稳定版浏览器中的 Web Animations API 无法开箱即用地实现这一点。

新的选项

Web Animations 规范引入了 composite 属性(以及相关的 iterationComposite)。默认的 composite'replace',其行为是我们多年来一直使用的行为,即正在进行动画处理的属性的值简单地替换任何先前设置的值——无论是来自规则集还是另一个动画。

'add' 值是事物从以前的规范中发生变化的地方。

element.animate({
  transform: ['scale(1)', 'scale(1.5)']
}, {
  duration: 1000,
  fill: 'both'
});
element.animate({
  transform: ['rotate(0deg)', 'rotate(180deg)']
}, {
  duration: 1500,
  fill: 'both',
  composite: 'add'
});

现在,浏览器会动态计算元素时间轴上给定点的适当变换,考虑两种变换,从而使两种动画都可见。在我们的示例中,缓动效果默认为 'linear',并且动画同时开始,因此我们可以分解在任何给定点处的有效 transform 是什么。例如

  • 0ms:scale(1) rotate(0deg)
  • 500ms:scale(1.25) rotate(60deg)(第一个动画进行到一半,第二个动画进行到 1/3)
  • 1000ms:scale(1.5) rotate(120deg)(第一个动画结束,第二个动画进行到 2/3)
  • 1500ms:scale(1.5) rotate(180deg)(第二个动画结束)

查看 CodePen 上 Dan Wilson (@danwilson) 创建的笔 Animation Composite

所以让我们发挥创意

单个动画不仅包含起始状态和结束状态——它还可以有自己的缓动效果、迭代次数、持续时间以及中间更多的关键帧。当元素处于动画中间时,您可以使用其自己的时间选项在其上添加额外的变换。

查看 CodePen 上 Dan Wilson (@danwilson) 创建的笔 Add more transform animations

此示例允许您在同一元素上应用多个动画,所有这些动画都影响 transform 属性。为了避免在此示例中过于复杂,我们限制每个动画一次只使用单个变换函数(例如,只使用 scale),从默认值(例如 scale(1)translateX(0))开始,并以该相同变换函数上的合理随机值结束,无限重复。下一个动画将影响另一个单个函数,并具有其自己的随机持续时间和缓动效果。

element.animate(getTransform(), //e.g. { transform: ['rotate(0deg), 'rotate(45deg)'] }
{
  duration: getDuration(), //between 1000 and 6000ms
  iterations: Infinity,
  composite: 'add',
  easing: getEasing() //one of two options
});

当每个动画开始时,浏览器将有效地找到它在其先前应用的动画中的位置,并使用指定的时间选项开始一个新的旋转动画。即使在相反方向上已经存在旋转,浏览器也会计算出需要进行多少旋转。
由于每个动画都有其自己的时间选项,因此在您添加了一些动画后,您不太可能看到此示例中重复的完全相同的运动。这使动画在观看时给人一种新鲜的感觉。

由于我们示例中的每个动画都从默认值(平移为 0,缩放为 1)开始,因此我们得到了一个平滑的开始。如果我们改为使用关键帧,例如 { transform: ['scale(.5)', 'scale(.8)'] },我们将得到一个跳跃,因为之前没有这个 scale,并且突然开始其动画,缩放比例为一半。

值是如何累加的?

变换值遵循 规范中的 语法,如果您添加一个变换,则会将其追加到列表中。

对于变换动画 A、B 和 C,生成的计算 transform 值将为 [A 中的当前值] [B 中的当前值] [C 中的当前值]。例如,假设以下三个动画

element.animate({
  transform: ['translateX(0)', 'translateX(10px)']
}, 1000);

element.animate({
  transform: ['translateY(0)', 'translateY(-20px)']
}, { 
  duration:1000,
  composite: 'add'
});

element.animate({
  transform: ['translateX(0)', 'translateX(300px)']
}, { 
  duration:1000,
  composite: 'add'
});

每个动画持续 1 秒,并使用线性缓动效果,因此在动画进行到一半时,生成的 transform 将具有值 translateX(5px) translateY(-10px) translateX(150px)。缓动效果、持续时间、延迟等等都会随着您的进行而影响值。

但是,变换并不是我们可以进行动画处理的唯一内容。滤镜(hue-rotate()blur() 等)遵循类似的模式,其中项目附加到滤镜列表中。

某些属性使用 数字作为值,例如 opacity。在此,数字将加起来成为一个总和。

element.animate({
  opacity: [0, .1]
}, 1000);

element.animate({
  opacity: [0, .2]
}, { 
  duration:1000,
  composite: 'add'
});

element.animate({
  opacity: [0, .4]
}, { 
  duration:1000,
  composite: 'add'
});

由于每个动画的持续时间再次为 1 秒,并且使用线性缓动效果,因此我们可以在动画的任何点计算生成的值。

  • 0ms:opacity: 0(0 + 0 + 0)
  • 500ms:opacity: .35(.05 + .1 + .2)
  • 1000ms:opacity: .7(.1 + .2 + .4)

因此,如果您有几个动画包含 1 作为关键帧,那么您将不会看到太多效果。这是其视觉状态的最大值,因此累加到超过该值的值看起来与 1 相同。

查看 CodePen 上 Dan Wilson (@danwilson) 创建的笔 Add more opacity animations

与不透明度和其他接受数字值的属性类似,接受长度、百分比或颜色的属性也将求和为单个结果值。对于颜色,您必须记住它们也有最大值(无论是 rgb() 中的 255 最大值,还是 hsl() 中饱和度/亮度的 100% 最大值),因此您的结果可能会达到白色最大值。对于长度,您可以像在 calc() 中一样在单位之间切换(例如,从 px 切换到 vmin)。

有关更多详细信息,规范 概述了不同类型的动画 以及如何计算结果。

使用填充模式

当您没有进行无限动画时(无论您是否使用 composite),默认情况下,动画在结束时不会保持其结束状态。fill 属性允许我们更改此行为。如果您希望在添加有限动画时获得平滑的过渡,您可能希望将填充模式设置为 forwardsboth,以确保结束状态保持不变。

查看 CodePen 上 Dan Wilson (@danwilson) 创建的笔 Spiral: Composite Add + Fill Forwards

此示例通过指定旋转和平移来创建一个带有螺旋路径的动画。有两个按钮可以添加新的持续时间为 1 秒的动画,并带有额外的少量平移。由于它们指定了 fill: 'forwards',因此每次额外的平移实际上都保留在变换列表中。扩展(或缩小)的螺旋线随着每次平移调整而平滑地适应,因为它是从 translateX(0) 到新值的叠加动画,并保持在该新值上。

累积动画

新的composite选项有一个第三个值——'accumulate'。从概念上讲,它与'add'一致,但某些类型的动画行为会有所不同。让我们继续使用transform,先从一个使用'add'的新示例开始,然后讨论'accumulate'的不同之处。

element.animate({
  transform: ['translateX(0)', 'translateX(20px)']
}, {
  duration: 1000,
  composite: 'add'
});
element.animate({
  transform: ['translateX(0)', 'translateX(30px)']
}, {
  duration: 1000,
  composite: 'add'
});
element.animate({
  transform: ['scale(1)', 'scale(.5)']
}, {
  duration: 1000,
  composite: 'add'
});

在1秒标记处(动画结束时),有效值为

transform: translateX(20px) translateX(30px) scale(.5)

从视觉上看,这会将元素向右推50px,然后将其缩放到一半宽度和一半高度。

如果每个动画都使用'accumulate',那么结果将是

transform: translateX(50px) scale(.5)

从视觉上看,这会将元素向右推50px,然后将其缩放到一半宽度和一半高度。

无需再看一遍,视觉效果实际上完全相同——那么'accumulate'有什么不同呢?

从技术上讲,当累积transform动画时,我们不再总是追加到列表中。如果变换函数已经存在(例如我们示例中的translateX()),当我们开始第二个动画时,我们将不会追加值。相反,内部值(即长度值)将被加起来并放置在现有的函数中。

如果我们的视觉结果相同,为什么存在累积内部值的选项呢?

transform的情况下,函数列表的顺序很重要。变换translateX(20px) translateX(30px) scale(.5)translateX(20px) scale(.5) translateX(30px)不同,因为每个函数都会影响其后函数的坐标系。当你在中间执行scale(.5)时,后面的函数也会以一半的比例发生。因此,在这个例子中,translateX(30px)将视觉上呈现为向右平移15px。

参见Dan Wilson在CodePen上发布的笔:视觉参考:变换坐标系 (@danwilson)。

因此,通过累积,我们可以获得不同的顺序,而不是始终将值追加到列表中。

每次迭代的累积

之前我提到过还有一个新的相关属性iterationComposite。它提供了执行我们已经讨论过的一些行为的能力,但在单个动画中从一次迭代到下一次迭代。

composite不同,此属性只有两个有效值:'replace'(您已经了解和喜爱的默认行为)和'accumulate'。使用'accumulate',值将遵循列表的已讨论的累积过程(如transform)或对于基于数字的属性(如opacity)加在一起。

作为一个起始示例,以下两个动画的视觉结果将相同

intervals.animate([{ 
  transform: `rotate(0deg) translateX(0vmin)`,
  opacity: 0
}, { 
  transform: `rotate(50deg) translateX(2vmin)`,
  opacity: .5
}], {
  duration: 2000,
  iterations: 2,
  fill: 'forwards',
  iterationComposite: 'accumulate'
});

intervals2.animate([{ 
  transform: `rotate(0deg) translateX(0vmin)`,
  opacity: 0
},{ 
  transform: `rotate(100deg) translateX(4vmin)`,
  opacity: 1
}], {
  duration: 4000,
  iterations: 1,
  fill: 'forwards',
  iterationComposite: 'replace' //default value
});

第一个动画仅将其不透明度增加0.5,旋转50度,并在2000毫秒内移动2vmin。它具有我们新的iterationComposite值,并设置为运行2次迭代。因此,当动画结束时,它将运行2 * 2000ms并达到opacity为1(2 * 0.5),旋转100度(2 * 50deg)并平移4vmin2 * 2vmin)。

参见Dan Wilson在CodePen上发布的笔:使用WAAPI iterationComposite的螺旋动画 (@danwilson)。

太棒了!我们刚刚使用了一个仅在Firefox Nightly中支持的新属性来重新创建我们已经可以使用Web动画API(或CSS)完成的操作!
iterationComposite更有趣的部分在于,当您将其与Web动画规范中即将推出的其他项目(以及Firefox Nightly中已有的项目)结合使用时。

设置新的效果选项

在当今稳定浏览器中,Web动画API在很大程度上与CSS动画相当,并增加了一些不错的功能,例如playbackRate选项以及跳转/搜索到不同点位的选项。但是,Animation对象正在获得更新已运行动画的效果和时间选项的能力。

参见Dan Wilson在CodePen上发布的笔:WAAPI iterationComposite & composite (@danwilson)。

这里我们有一个元素,有两个动画影响transform属性并依赖于composite: 'add'——一个使元素水平横跨屏幕移动,另一个以交错方式垂直移动它。最终状态比第二个动画的起始状态稍微高一些,并且使用iterationComposite: 'accumulate',它会越来越高。经过八次迭代后,动画结束并反向运行另外八次迭代回到屏幕底部,然后再次开始该过程。

我们可以通过动态更改迭代次数来更改动画在屏幕上上升的距离。这些动画无限播放,但您可以在动画过程中将下拉菜单更改为不同的迭代次数。例如,如果您从七次迭代更改为九次迭代,并且您当前正在查看第六次迭代,那么您的动画会继续运行,就像没有发生任何更改一样。但是,您会看到,它不会在下一次(第七次)迭代后开始反向,而是会继续进行两次迭代。您还可以交换新的关键帧,并且动画时间将保持不变。

animation.effect.timing.iterations = 4;
animation.effect.setKeyframes([
  { transform: 'scale(1)' },
  { transform: 'scale(1.2)' }
]);

在中途修改动画可能不是您每天都会使用的东西,但由于它是浏览器级别的新功能,因此随着功能的更广泛可用,我们将了解其可能性。更改迭代次数对于游戏来说可能很方便,当用户获得奖励回合并且游戏持续时间超过预期时。当用户从某种错误状态变为成功状态时,不同的关键帧可能是有意义的。

接下来我们该去哪里?

新的composite选项以及更改时间选项和关键帧的能力为响应式和编排动画打开了新的大门。CSS工作组也正在讨论将此功能添加到CSS中,甚至超出了动画的范围——以一种新的方式影响级联。在任何这些功能都进入稳定的主要浏览器之前,我们还有时间,但看到新的选项出现,甚至更令人兴奋的是能够在今天尝试它们。