动画已经发展了很长一段时间,不断为开发者提供更好的工具。特别是 **CSS 动画**,已经奠定了基础,可以解决大多数用例。但是,有些动画需要稍微多一点的工作。
您可能知道动画应该在 合成 层运行。(我在这里不会扩展太多,但如果您想了解更多信息,请查看 这篇文章。)这意味着动画 transform
或 opacity
属性不会触发 布局 或 绘制 层。动画 height
和 width
等属性是大忌,因为它们会触发这些层,迫使浏览器重新计算样式。
最重要的是,即使在动画 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()
。这与上面解释的没有什么太大区别。对于此用例,我们实际上需要创建四个动画
- 展开包装器的动画
- 内容的反向展开动画
- 折叠包装器的动画
- 内容的反向折叠动画
我们遵循与之前相同的方法,运行一个长度为 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
属性的重要性。应将其设置为 linear
或 step-end
以避免在每个关键帧之间进行缓动。
will-change
属性——正如您可能知道的那样——将为转换动画启用 GPU 加速,以获得更流畅的体验。并且使用 contains
属性,其值为 contents
,将帮助浏览器独立于 DOM 树的其余部分处理元素,限制其重新计算布局、样式、绘制和大小属性之前的区域。
我们使用 visibility
和 opacity
来隐藏内容,并在折叠时阻止屏幕阅读器访问它。
.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
,因此难以设置边框样式。
尽管如此,我认为这种方法潜力巨大。请告诉我您的想法!
对于一个可以用 100% CSS 完成(或多或少)的下拉菜单来说,代码量实在太多了。
嗨,Liam,
确实如此!老实说,我想我永远不会在实际项目中使用它。不过,这仍然是一个很酷的想法 :)
关于您在上一篇文章中提到的缩放和问题,应该可以计算出内容的反向缩放。问题解决了,并且使用了高性能 CSS 属性。