高性能可扩展动画:动态构建关键帧

Avatar of Bernardo Cardoso
Bernardo Cardoso 发布

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

动画已经发展了很长一段时间,不断为开发者提供更好的工具。特别是 **CSS 动画**,已经奠定了基础,可以解决大多数用例。但是,有些动画需要稍微多一点的工作。

您可能知道动画应该在 合成 层运行。(我在这里不会扩展太多,但如果您想了解更多信息,请查看 这篇文章。)这意味着动画 transformopacity 属性不会触发 布局绘制 层。动画 heightwidth 等属性是大忌,因为它们会触发这些层,迫使浏览器重新计算样式。

最重要的是,即使在动画 transform 属性时,如果您想真正达到 60 FPS 的动画效果,您可能也应该从 JavaScript 获取一些帮助,使用 FLIP 技术 来获得更流畅的动画!

但是,使用 transform 进行可扩展动画的问题在于,scale 函数与动画 width/height 属性并不完全相同。它会在内容上产生倾斜效果,因为所有元素都会被拉伸(向上缩放时)或压缩(向下缩放时)。

因此,由于这个原因,我的首选解决方案一直是(并且可能仍然是,原因我将在后面详细说明),来自 Brandon Smith 的文章 中的 **技术 #3**。这仍然对 height 进行了过渡,但使用 Javascript 计算内容大小,并使用 requestAnimationFrame 强制进行过渡。在 OutSystems,我们实际上使用它为 OutSystems UI 手风琴模式 构建动画。

使用 JavaScript 生成关键帧

最近,我偶然发现了 Paul Lewis 的另一篇 精彩文章,详细介绍了一种扩展和折叠动画的新解决方案,这促使我撰写本文并传播这种技术。

用他的话说,主要思想是生成动态关键帧,逐步……

[...] 从 0 到 100,并计算元素及其内容所需的缩放值。然后,这些值可以简化为一个字符串,该字符串可以作为样式元素注入页面。

要实现这一点,主要有三个步骤。

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

我们需要计算两种状态的正确缩放值。这意味着我们对用作起始状态代理的元素使用 getBoundingClientRect(),并将其除以结束状态的值。它应该是这样的

function calculateStartScale () {
  const start= startElement.getBoundingClientRect();
  const end= endElement.getBoundingClientRect();
  return {
    x: start.width / end.width,
    y: start.height / end.height
  };
}

步骤 2:生成关键帧

现在,我们需要运行一个 for 循环,使用所需的帧数作为长度。(为了确保动画流畅,它不应该小于 60。)然后,在每次迭代中,我们使用 ease 函数计算正确的缓动值

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

let easedStep = ease(i / frame);

使用该值,我们将获得元素在当前步骤的缩放比例,使用以下数学公式

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

然后我们将步骤添加到动画字符串中

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

为了避免内容被拉伸/倾斜,我们应该对其执行反向动画,使用反向值

const invXScale = 1 / xScale;
const invYScale = 1 / yScale;

inverseAnimation += `${step}% {
  transform: scale(${invXScale}, ${invYScale});
}`;

最后,我们可以返回完成的动画,或直接将它们注入到新创建的样式标签中。

步骤 3:启用 CSS 动画

在 CSS 方面,我们需要在正确的元素上启用动画

.element--expanded {
  animation-name: animation;
  animation-duration: 300ms;
  animation-timing-function: step-end;
}

.element-contents--expanded {
  animation-name: inverseAnimation ;
  animation-duration: 300ms;
  animation-timing-function: step-end;
}

您可以在 Codepen 上查看 Paul Lewis 文章中的菜单示例(由 Chris 提供)。

构建可扩展部分

在掌握了这些基本概念后,我想检查一下是否可以将此技术应用于不同的用例,例如可扩展部分。

在这种情况下,我们只需要动画化高度,特别是在计算缩放比例的函数中。我们从部分标题获取 Y 值,作为折叠状态,并获取整个部分来表示展开状态

    _calculateScales () {
      var collapsed = this._sectionItemTitle.getBoundingClientRect();
      var expanded = this._section.getBoundingClientRect();
      
      // create css variable with collapsed height, to apply on the wrapper
      this._sectionWrapper.style.setProperty('--title-height', collapsed.height + 'px');

      this._collapsed = {
        y: collapsed.height / expanded.height
      }
    }

由于我们希望展开的部分具有 absolute 定位(为了避免它在折叠状态下占用空间),因此我们使用折叠高度在包装器上设置其 CSS 变量。这将是唯一具有相对定位的元素。

接下来是创建关键帧的函数:_createEaseAnimations()。这与上面解释的没有什么太大区别。对于此用例,我们实际上需要创建四个动画

  1. 展开包装器的动画
  2. 内容的反向展开动画
  3. 折叠包装器的动画
  4. 内容的反向折叠动画

我们遵循与之前相同的方法,运行一个长度为 60 的 for 循环(以获得流畅的 60 FPS 动画),并根据缓动步骤创建关键帧百分比。然后,我们将其推送到最终的动画字符串中

outerAnimation.push(`
  ${percentage}% {
    transform: scaleY(${yScale});
  }`);
  
innerAnimation.push(`
  ${percentage}% {
    transform: scaleY(${invScaleY});
  }`);

我们首先创建一个样式标签来保存完成的动画。由于它是作为构造函数构建的,以便能够轻松添加多个模式,我们希望所有这些生成的动画都在同一个样式表中。因此,首先,我们验证元素是否存在。如果不存在,我们创建它并添加一个有意义的类名。否则,您最终将为每个可扩展部分获得一个样式表,这不是理想的。

 var sectionEase = document.querySelector('.section-animations');
 if (!sectionEase) {
  sectionEase = document.createElement('style');
  sectionEase.classList.add('section-animations');
 }

说到这一点,您可能已经在想,“嗯,如果我们有多个可扩展部分,它们是否仍然会使用相同名称的动画,并且其内容可能具有错误的值?”

您完全正确!因此,为了防止这种情况,我们还生成动态动画名称。很酷,对吧?

我们在进行 querySelectorAll('.section') 时利用从 for 循环传递给构造函数的索引,以向名称添加唯一的元素

var sectionExpandAnimationName = "sectionExpandAnimation" + index;
var sectionExpandContentsAnimationName = "sectionExpandContentsAnimation" + index;

然后我们使用此名称在当前可扩展部分上设置 CSS 变量。由于此变量仅在此范围内,我们只需要在 CSS 中将动画设置为新变量,并且每个模式都将获得其各自的 animation-name 值。

.section.is--expanded {
  animation-name: var(--sectionExpandAnimation);
}

.is--expanded .section-item {
  animation-name: var(--sectionExpandContentsAnimation);
}

.section.is--collapsed {
  animation-name: var(--sectionCollapseAnimation);
}

.is--collapsed .section-item {
  animation-name: var(--sectionCollapseContentsAnimation);
}

脚本的其余部分与添加事件侦听器、切换折叠/展开状态的函数以及一些可访问性改进有关。

关于 HTML 和 CSS:它需要额外的工作才能使可扩展功能正常工作。我们需要一个额外的包装器作为不进行动画的相对元素。可扩展的子元素具有 absolute 位置,因此在折叠时它们不占用空间。

请记住,由于我们需要进行反向动画,因此我们将其缩放为全尺寸以避免内容出现倾斜效果。

.section-item-wrapper {
  min-height: var(--title-height);
  position: relative;
}

.section {
  animation-duration: 300ms;
  animation-timing-function: step-end;
  contain: content;
  left: 0;
  position: absolute;
  top: 0;
  transform-origin: top left;
  will-change: transform;
}

.section-item {
  animation-duration: 300ms;
  animation-timing-function: step-end;
  contain: content;
  transform-origin: top left;
  will-change: transform;  
}

我想强调 animation-timing-function 属性的重要性。应将其设置为 linearstep-end 以避免在每个关键帧之间进行缓动。

will-change 属性——正如您可能知道的那样——将为转换动画启用 GPU 加速,以获得更流畅的体验。并且使用 contains 属性,其值为 contents,将帮助浏览器独立于 DOM 树的其余部分处理元素,限制其重新计算布局、样式、绘制和大小属性之前的区域。

我们使用 visibilityopacity 来隐藏内容,并在折叠时阻止屏幕阅读器访问它。

.section-item-content {
  opacity: 1;
  transition: opacity 500ms ease;
}

.is--collapsed .section-item-content {
  opacity: 0;
  visibility: hidden;
}

最后,我们有了可扩展的部分!以下是完整的代码和演示,供您查看

性能检查

无论何时我们处理动画,性能都应该放在首位。因此,让我们使用开发者工具来检查一下这些工作在性能方面是否值得。使用性能选项卡(我使用的是 Chrome DevTools),我们可以在动画过程中分析 FPS 和 CPU 使用情况。

结果非常棒!

绿色条越高,帧率越高。而且也没有任何垃圾,垃圾会用红色部分表示。

使用 FPS 计工具更详细地检查数值,我们可以看到它即使在高负荷使用的情况下也能始终达到 60 FPS。

最终考虑

那么,结论是什么?这是否取代了所有其他方法?这是“圣杯”解决方案吗?

在我看来,不是。

但是……这真的没关系!它只是列表中的另一个解决方案。而且,与任何其他方法一样,应该分析它是否是最适合用例的方法。

这种技术肯定有其优点。正如 Paul Lewis 所说,这确实需要大量的准备工作。但另一方面,我们只需要在页面加载时执行一次。在交互过程中,我们仅仅是在切换类(以及某些情况下用于辅助功能的属性)。

然而,这给元素的 UI 带来了某些限制。正如您在可扩展部分元素中看到的,反向缩放使其对于绝对定位和画布外的元素(如浮动操作按钮或菜单)更加可靠。由于使用了overflow: hidden,因此难以设置边框样式。

尽管如此,我认为这种方法潜力巨大。请告诉我您的想法!