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

Paul Lewis
斯蒂芬·麦格鲁尔
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 相比,使用 clip-path受支持程度较低。但 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)。这种自然缩放代表其展开状态,这意味着您需要从缩小的版本(在上面计算得出)开始添加动画效果,直至缩放到该自然缩放为止。

但是先别急!这当然也会扩展菜单的内容,不是吗?如下所示,是的

那么,您可以做些什么呢?您可以对内容应用 counter- 转换,例如,如果容器缩小到正常大小的 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 指出这一点!)这样做的主要好处是可以在合成器上运行 mutate 转换的关键帧动画,这意味着它不受主线程上任务的影响。

为了制作关键帧动画,我们需要从 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元素封装,这意味着该圆形不会扩展到元素边界之外。

关于此特定变体的警告:在动画播放期间,由于文本缩放和计数器缩放导致的舍入错误,Chrome 会在低 DPI 屏幕上出现文本模糊。如果您想了解具体细节,可以在提交的 bug 中加星标并关注相应 bug

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

总结

这就是使用缩放转换实现高性能剪辑动画的方法。在完美的情况下,最好能够看到剪辑动画加速(有 Jake Archibald 提出的 Chromium bug),但在我们实现这一点之前,您在为 clipclip-path 添加动画效果时应小心谨慎,并且一定要避免为 widthheight 添加动画效果。

此外,使用 Web 动画来实现此类效果也非常方便,因为它们具有 JavaScript API,但如果您只为 transformopacity 添加动画效果,它们就可以在合成器线程上运行。遗憾的是,对网页动画的支持并不理想,但您可以使用渐进式增强(如果可用)。

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

在这之前,虽然您可以使用基于 JavaScript 的库来执行动画,但您可能会发现通过烘焙 CSS 动画并改用该动画来提高性能。同样,如果您的应用已经依赖 JavaScript 来实现其动画,则至少与您的现有代码库保持一致性会更好。

如果您想浏览此效果的代码,请查看界面元素示例 GitHub 代码库。一如既往,请通过下面的注释告知我们您是如何工作的。