构建高性能的展开和合拢动画

Paul Lewis
Stephen McGruer
Stephen McGruer

要点

为剪辑添加动画时,请使用缩放转换。您可以通过对子元素进行计数器缩放,以防止子元素在动画期间被拉伸和倾斜。

我们之前曾发布有关如何创建高性能视差效果无限滚动条的更新。在本文中,我们将介绍若要实现高性能的剪辑动画,需要满足哪些条件。如果您想观看演示,请查看界面元素示例 GitHub 代码库

例如,展开式菜单:

构建此容器的某些选项的性能优于其他选项。

错误示例:对容器元素的宽度和高度进行动画处理

您可以想象一下,使用一些 CSS 为容器元素的宽度和高度添加动画效果。

.menu {
  overflow: hidden;
  width: 350px;
  height: 600px;
  transition: width 600ms ease-out, height 600ms ease-out;
}

.menu--collapsed {
  width: 200px;
  height: 60px;
}

此方法的直接问题是,它需要为 widthheight 添加动画效果。这些属性需要计算布局,并在动画的每一帧上绘制结果,这可能非常耗费资源,并且通常会导致您无法达到 60fps。如果您对此感兴趣,请阅读我们的渲染性能指南,详细了解渲染过程的运作方式。

错误示例:使用 CSS clip 或 clip-path 属性

widthheight 添加动画的替代方法可能是使用(现已废弃的)clip 属性为展开和收起效果添加动画。或者,您也可以改用 clip-path。但是,使用 clip-path支持程度不如 clip。但 clip 已被废弃。好吧。但不必担心,这并不是您想要的解决方案!

.menu {
  position: absolute;
  clip: rect(0px 112px 175px 0px);
  transition: clip 600ms ease-out;
}

.menu--collapsed {
  clip: rect(0px 70px 34px 0px);
}

虽然这种方法比为菜单元素的 widthheight 添加动画效果要好,但缺点是它仍然会触发绘制。此外,如果您采用该方式,clip 属性会要求其操作的元素采用绝对定位或固定定位,这可能需要稍微调整一下。

良好:为比例添加动画效果

由于此效果涉及到某些内容变大变小,因此您可以使用缩放变换。 这是一个好消息,因为更改转换不需要布局或绘制,并且浏览器可以将其交给 GPU,这意味着效果会加速,并且更有可能达到 60fps。

与渲染性能的大多数方面一样,这种方法的缺点是需要进行一些设置。不过,这完全值得!

第 1 步:计算起始状态和结束状态

如果使用的是采用缩放动画的方法,第一步是读取元素,以了解菜单在收起和展开时的大小。在某些情况下,您可能无法一次性获取这两项信息,而需要切换一些类,才能读取组件的各种状态。不过,如果您需要这样做,请务必谨慎:如果样式自上次运行后发生了更改,getBoundingClientRect()(或 offsetWidthoffsetHeight)会强制浏览器运行样式和布局传递。

function calculateCollapsedScale () {
    // The menu title can act as the marker for the collapsed state.
    const collapsed = menuTitle.getBoundingClientRect();

    // Whereas the menu as a whole (title plus items) can act as
    // a proxy for the expanded state.
    const expanded = menu.getBoundingClientRect();
    return {
    x: collapsed.width / expanded.width,
    y: collapsed.height / expanded.height
    };
}

对于菜单之类的内容,我们可以做出合理的假设,即它最初将处于其自然缩放比例 (1, 1)。这种自然缩放表示其展开状态,这意味着您需要将动画从缩小的版本(在上面计算)恢复到该自然缩放。

但是先别急!这肯定也会缩放菜单内容,对吗?如您在下方所看到的,是的。

那么,您可以做些什么呢?您可以对内容应用反向转换,例如,如果容器缩小到其正常大小的 1/5,您可以将内容放大 5 倍,以防止内容被压缩。关于这点,有两点需要注意:

  1. 反向转换也是一个缩放操作。这很不错,因为它也可以加速,就像容器上的动画一样。您可能需要确保要进行动画处理的元素拥有自己的合成层(以便 GPU 提供帮助),为此,您可以向元素添加 will-change: transform,或者如果您需要支持旧版浏览器,则可以添加 backface-visiblity: hidden

  2. 必须按帧计算反向转换。这时事情可能会变得有点棘手,因为假设动画采用 CSS 并使用缓动函数,则在为反向转换添加动画时,需要对缓动本身进行反向处理。不过,计算 cubic-bezier(0, 0, 0.3, 1) 的反向曲线并不那么明显。

因此,您可能很想考虑使用 JavaScript 来为这种效果添加动画效果。毕竟,您可以使用缓动方程来计算每帧的缩放和反向缩放值。任何基于 JavaScript 的动画的缺点在于,当主线程(运行 JavaScript 的位置)忙于处理其他任务时,会出现什么情况。简而言之,动画可能会卡顿或完全停滞,这对用户体验很不利。

第 2 步:即时构建 CSS 动画

解决方案(起初可能看起来很奇怪)是使用我们自己的缓动函数动态创建关键帧动画,并将其注入页面以供菜单使用。(非常感谢 Chrome 工程师 Robert Flack 指出这一点!)这样做的首要好处是,可在合成器上运行用于更改变形的关键帧动画,这意味着它不会受到主线程上的任务影响。

为了制作关键帧动画,我们从 0 到 100 进行步进,并计算元素及其内容所需的缩放值。然后,这些可以归结为一个字符串,该字符串可以作为样式元素注入到页面中。注入样式会导致页面上进行“重新计算样式”传递,这是浏览器必须执行的额外工作,但它只会在组件启动时执行一次。

function createKeyframeAnimation () {
    // Figure out the size of the element when collapsed.
    let {x, y} = calculateCollapsedScale();
    let animation = '';
    let inverseAnimation = '';

    for (let step = 0; step <= 100; step++) {
    // Remap the step value to an eased one.
    let easedStep = ease(step / 100);

    // Calculate the scale of the element.
    const xScale = x + (1 - x) * easedStep;
    const yScale = y + (1 - y) * easedStep;

    animation += `${step}% {
        transform: scale(${xScale}, ${yScale});
    }`;

    // And now the inverse for the contents.
    const invXScale = 1 / xScale;
    const invYScale = 1 / yScale;
    inverseAnimation += `${step}% {
        transform: scale(${invXScale}, ${invYScale});
    }`;

    }

    return `
    @keyframes menuAnimation {
    ${animation}
    }

    @keyframes menuContentsAnimation {
    ${inverseAnimation}
    }`;
}

无尽的好奇心可能会好奇 for 循环中的 ease() 函数。您可以使用如下代码将 0 到 1 的值映射到缓和等效项。

function ease (v, pow=4) {
  return 1 - Math.pow(1 - v, pow);
}

您也可以使用 Google 搜索绘制示意图。太方便了!如果您需要其他缓动方程,请查看 Soledad Penadés 的 Tween.js,其中包含大量缓动方程。

第 3 步:启用 CSS 动画

创建这些动画并将其以 JavaScript 的形式烧录到网页后,最后一步是切换启用动画的类。

.menu--expanded {
  animation-name: menuAnimation;
  animation-duration: 0.2s;
  animation-timing-function: linear;
}

.menu__contents--expanded {
  animation-name: menuContentsAnimation;
  animation-duration: 0.2s;
  animation-timing-function: linear;
}

这会导致在上一步中创建的动画运行。由于预先烘焙的动画已采用缓动效果,因此需要将时间函数设置为 linear,否则您将在每个关键帧之间采用缓动效果,这看起来会很奇怪!

如需将元素收起,有两种方法:更新 CSS 动画以向后而不是向前运行。这样做可以正常运行,但动画的“感觉”会相反,因此,如果您使用了缓出曲线,则反向动画会感觉像是缓入,这会使动画看起来缓慢。一种更合适的解决方案是创建用于收起元素的第二对动画。您可以使用与展开关键帧动画完全相同的方式创建这些动画,只需将起始值和结束值对调即可。

const xScale = 1 + (x - 1) * easedStep;
const yScale = 1 + (y - 1) * easedStep;

更高级的版本:圆形揭露

您还可以使用此方法制作循环展开和收起动画。

其原理与上一个版本大致相同,对一个元素进行缩放,并对直接子元素进行计数器缩放。在本例中,放大后的元素的 border-radius 为 50%,使其呈圆形,并由具有 overflow: hidden另一个元素封装,这意味着您不会看到圆形超出元素边界。

关于此特定变体,需要提醒一下:由于文本的放大和反向放大导致舍入误差,因此在低 DPI 屏幕上,Chrome 在动画期间会出现模糊的文本。如果您有兴趣了解相关详情,可以为您举报的 bug 加注星标并关注

圆形展开效果的代码可以在 GitHub 代码库中找到。

总结

至此,您已经了解了使用缩放转换实现高性能剪辑动画的方法。在理想情况下,如果剪辑动画能够加速(存在由 Jake Archibald 提出的 Chromium bug),那就太好了,但在我们实现这一目标之前,为 clipclip-path 添加动画效果时应格外小心,绝对避免为 widthheight 添加动画效果。

使用 Web Animations 来实现此类效果也很方便,因为它们具有 JavaScript API,但如果您仅为 transformopacity 设置动画,则可以在合成器线程上运行。很遗憾,对 Web 动画的支持不太好,但您可以使用渐进式增强功能来使用 Web 动画(如果有)。

if ('animate' in HTMLElement.prototype) {
    // Animate with Web Animations.
} else {
    // Fall back to generated CSS Animations or JS.
}

在此之前,虽然您可以使用基于 JavaScript 的库来实现动画,但您可能会发现,通过预编译 CSS 动画并改为使用该动画,可以获得更可靠的性能。同样,如果您的应用已经依靠 JavaScript 实现动画,那么至少与现有代码库保持一致可能会获得更好的效果。

如果您想查看此效果的代码,请查看 GitHub 上的界面元素示例代码库。一如既往,欢迎在下方的评论区告诉我们您的进展。