在网站上,为手风琴制作动画一直是最常被问到的动画之一。 有趣的事实:jQuery 的 slideDown()
函数在 2006 年的 第一个版本 中就已经可用。
在本文中,我们将了解如何使用 Web 动画 API 动画原生 <details>
元素。
HTML 设置
首先,让我们看看我们将如何构建此动画所需的标记。
<details>
元素需要一个 <summary>
元素。 摘要是在手风琴关闭时可见的内容。<details>
中的所有其他元素都是手风琴内部内容的一部分。 为了便于我们动画化该内容,我们将其包装在 <div>
内。
<details>
<summary>Summary of the accordion</summary>
<div class="content">
<p>
Lorem, ipsum dolor sit amet consectetur adipisicing elit.
Modi unde, ex rem voluptates autem aliquid veniam quis temporibus repudiandae illo, nostrum, pariatur quae!
At animi modi dignissimos corrupti placeat voluptatum!
</p>
</div>
</details>
Accordion 类
为了使我们的代码更具可重用性,我们应该创建一个 Accordion
类。 通过这样做,我们可以在页面上的每个 <details>
元素上调用 new Accordion()
。
class Accordion {
// The default constructor for each accordion
constructor() {}
// Function called when user clicks on the summary
onClick() {}
// Function called to close the content with an animation
shrink() {}
// Function called to open the element after click
open() {}
// Function called to expand the content with an animation
expand() {}
// Callback when the shrink or expand animations are done
onAnimationFinish() {}
}
Constructor()
构造函数是我们保存每个手风琴所需的所有数据的地方。
constructor(el) {
// Store the <details> element
this.el = el;
// Store the <summary> element
this.summary = el.querySelector('summary');
// Store the <div class="content"> element
this.content = el.querySelector('.content');
// Store the animation object (so we can cancel it, if needed)
this.animation = null;
// Store if the element is closing
this.isClosing = false;
// Store if the element is expanding
this.isExpanding = false;
// Detect user clicks on the summary element
this.summary.addEventListener('click', (e) => this.onClick(e));
}
onClick()
在 onClick()
函数中,您会注意到我们正在检查元素是否正在动画(关闭或展开)。 我们需要在用户在动画过程中点击手风琴时这样做。 如果快速点击,我们不希望手风琴从完全打开跳到完全关闭。
<details>
元素有一个属性 [open]
,当我们打开元素时,该属性由浏览器应用到它。 我们可以通过使用 this.el.open
检查元素的 open
属性来获取该属性的值。
onClick(e) {
// Stop default behaviour from the browser
e.preventDefault();
// Add an overflow on the <details> to avoid content overflowing
this.el.style.overflow = 'hidden';
// Check if the element is being closed or is already closed
if (this.isClosing || !this.el.open) {
this.open();
// Check if the element is being openned or is already open
} else if (this.isExpanding || this.el.open) {
this.shrink();
}
}
shrink()
此缩小函数使用 WAAPI .animate()
函数。 您可以在 MDN 文档 中详细了解它。 WAAPI 与 CSS @keyframes
非常相似。 我们需要定义动画的开始和结束关键帧。 在这种情况下,我们只需要两个关键帧,第一个是元素的当前高度,第二个是元素关闭时的 <details>
元素的高度。 当前高度存储在 startHeight
变量中。 关闭高度存储在 endHeight
变量中,等于 <summary>
的高度。
shrink() {
// Set the element as "being closed"
this.isClosing = true;
// Store the current height of the element
const startHeight = `${this.el.offsetHeight}px`;
// Calculate the height of the summary
const endHeight = `${this.summary.offsetHeight}px`;
// If there is already an animation running
if (this.animation) {
// Cancel the current animation
this.animation.cancel();
}
// Start a WAAPI animation
this.animation = this.el.animate({
// Set the keyframes from the startHeight to endHeight
height: [startHeight, endHeight]
}, {
// If the duration is too slow or fast, you can change it here
duration: 400,
// You can also change the ease of the animation
easing: 'ease-out'
});
// When the animation is complete, call onAnimationFinish()
this.animation.onfinish = () => this.onAnimationFinish(false);
// If the animation is cancelled, isClosing variable is set to false
this.animation.oncancel = () => this.isClosing = false;
}
open()
当我们想要展开手风琴时,会调用 open
函数。 此函数尚不控制手风琴的动画。 首先,我们计算 <details>
元素的高度,然后使用内联样式将此高度应用于它。 完成后,我们可以设置其上的 open
属性,以使内容可见,但隐藏起来,因为我们在元素上有一个 overflow: hidden
和一个固定高度。 然后,我们等待下一帧调用展开函数并动画化元素。
open() {
// Apply a fixed height on the element
this.el.style.height = `${this.el.offsetHeight}px`;
// Force the [open] attribute on the details element
this.el.open = true;
// Wait for the next frame to call the expand function
window.requestAnimationFrame(() => this.expand());
}
expand()
展开函数类似于 shrink
函数,但它不是从当前高度动画到关闭高度,而是从元素的高度动画到结束高度。 该结束高度等于摘要的高度加上内部内容的高度。
expand() {
// Set the element as "being expanding"
this.isExpanding = true;
// Get the current fixed height of the element
const startHeight = `${this.el.offsetHeight}px`;
// Calculate the open height of the element (summary height + content height)
const endHeight = `${this.summary.offsetHeight + this.content.offsetHeight}px`;
// If there is already an animation running
if (this.animation) {
// Cancel the current animation
this.animation.cancel();
}
// Start a WAAPI animation
this.animation = this.el.animate({
// Set the keyframes from the startHeight to endHeight
height: [startHeight, endHeight]
}, {
// If the duration is too slow of fast, you can change it here
duration: 400,
// You can also change the ease of the animation
easing: 'ease-out'
});
// When the animation is complete, call onAnimationFinish()
this.animation.onfinish = () => this.onAnimationFinish(true);
// If the animation is cancelled, isExpanding variable is set to false
this.animation.oncancel = () => this.isExpanding = false;
}
onAnimationFinish()
此函数在缩小或展开动画结束时调用。 正如您所见,有一个参数 [open]
,当手风琴打开时,该参数被设置为 true,允许我们设置元素上的 [open]
HTML 属性,因为它不再由浏览器处理。
onAnimationFinish(open) {
// Set the open attribute based on the parameter
this.el.open = open;
// Clear the stored animation
this.animation = null;
// Reset isClosing & isExpanding
this.isClosing = false;
this.isExpanding = false;
// Remove the overflow hidden and the fixed height
this.el.style.height = this.el.style.overflow = '';
}
设置手风琴
Whew,我们完成了代码中最重要的一部分!
剩下的就是为 HTML 中的每个 <details>
元素使用我们的 Accordion
类。 为此,我们在 <details>
标签上使用 querySelectorAll
,并为每个标签创建一个新的 Accordion
实例。
document.querySelectorAll('details').forEach((el) => {
new Accordion(el);
});
笔记
为了计算关闭高度和打开高度,我们需要确保 <summary>
和内容始终具有相同的高度。
例如,不要尝试在摘要打开时添加填充,因为这会导致动画过程中出现跳跃。 内部内容也是如此——它应该具有固定高度,我们应该避免在打开动画过程中可能改变高度的内容。
此外,不要在摘要和内容之间添加边距,因为它不会计算关键帧的高度。 相反,直接在内容上使用填充来添加一些间距。
结束
瞧,我们在没有任何库的情况下,用 JavaScript 做了一个漂亮的手风琴动画!🌈
非常好,但感觉为了一个简单的手风琴付出了很多工作。
我使用两个 CSS 自定义属性来保存“折叠”和“展开”的高度,然后用一小段 JavaScript 代码来计算它们。有一个 ResizeObserver 用于在宽度变化时重新计算它们。
details 元素
height: var(–collapsed);
在 [open] 状态下
height: var(–expanded);
我在 Codepen 上做了一个演示
哦,对,预先计算打开和关闭值很聪明!
我以前从未使用过 ResizeObserver,但它非常有用(而且浏览器支持很棒 ❤)
感谢您分享您的演示
我还应该提到,如果它不是高度,例如超级菜单或切换提示,这些不会将内容向下推,那么你可以在没有 JavaScript 的情况下动画化 details 标签。但是,你不能在 [open] 状态上使用过渡,但动画效果很好。我在此做了切换提示的演示:https://codepen.io/stoumann/pen/abZXxPx
还有... 在我的第一条评论中,我忘了感谢您撰写了一篇很棒的文章!
此脚本的问题是它会关闭打开的 details 元素。
实际上,您的宽度重新测量不起作用。宽度从未改变,因此 resize 观察器永远不会触发。如果您尝试在实际会调整大小的元素上执行此操作,您会发现它在关闭高度下测量了打开和关闭高度。
您是否真的知道如何使用 waapi 动画化 DOM 属性(如 scrollTop)。在我看来,我只能动画化 CSS,但能够动画化任何东西是添加基于 JavaScript 动画 API 的一个关键用例。
遗憾的是,WAAPI 无法动画化任何 JavaScript 变量或其他任何东西。它实际上是关于像 CSS 动画一样动画化 DOM 元素。
要动画化 scrollTop,您可以创建一个包含滚动位置的变量,并使用 requestAnimationFrame 为其设置动画。在每一帧中,您都会将新的滚动位置应用到窗口。或者使用 GreenSock 之类的库为您完成所有这些工作 :)
所以您希望从一个数值动画到另一个数值,也许动画到一个元素位置,使用
getBoundingClientRect()
获取?我在下面创建了一个可以使用to 和from 数值调用的函数。
element 是要在其中滚动的元素(默认为 body 标签),dir 是滚动方向(默认为
0
,即垂直方向,1
表示水平方向),duration
以毫秒为单位,easing
是作为函数的缓动类型。我包含了一些缓动函数,您可以在此处获取更多:https://easings.net/示例:滚动到滚动位置
1200
,使用scrollFromTo(0, 1200)
。Alexander,这是我上一条评论中动画代码的演示:https://codepen.io/stoumann/full/QWEoWPN
非常好。很棒的东西!
我唯一的建议——我还没有尝试自己解决它——是使打开/关闭持续时间成为高度的函数。按照目前的做法,高度越大,速度越快。也就是说,打开或关闭必须在相同固定时间内覆盖更多距离。
这不是抱怨。我已经在使用它 :) 谢谢!这只是我注意到的一些可以考虑的事情。
感谢您撰写本文。因为我喜欢使用 Web 组件,所以我使用 LitElement 修改了此演示,并添加了一些用于样式的自定义属性:https://codepen.io/johnthad/pen/wvzgzYx
这很棒——同意,为了让 details 元素进行动画化,需要进行很多操作,但我更喜欢使用 details 元素来创建手风琴,而且大多数情况下,客户希望对其进行动画化,而且感觉真的很棒。
一个额外的好处是,如果用户在操作系统上启用了
prefers-reduced-motion
,则可以添加一个检查以完全跳过动画。您可以在对元素调用手风琴类时执行此逻辑,但我已向手风琴中的各个组件添加了一些类,因此在我看来,在 Accordion 类中执行此逻辑更有意义。您可以向构造函数添加一个检查然后在
shrink
和expand
方法中,只需将所有 WAAPI 相关内容包装在然后,可能需要执行一个
else
语句来调用onAnimationFinish
方法。因此,例如,我的
shrink
方法整体看起来像这样我创建了一个使用纯 CSS 的版本,没有 JavaScript。
我还撰写了一篇文章解释了如何做到这一点
https://dev.to/jgustavoas/how-to-fully-animate-the-details-html-element-with-only-css-no-javascript-2n88
这非常有趣!我不太明白为什么过渡在使用输入技巧而不是使用
detail[open]
选择器时,在关闭时会起作用……此解决方案仍然存在两个问题。第一个问题是,它遗憾的是在 Safari 上不起作用。第二个问题是,如果您有比设置的
max-height
更高的内容。此外,如果您有一个 100px 高度的内容旁边有一个 800px 高度的内容,那么过渡持续时间会有偏差,因为第一个过渡会更快,但并不那么戏剧性 :)
感谢您的反馈,Louis!
这似乎是由于 Safari 对
<summary>
元素和::marker
伪元素的支持较差。有趣的是,在 MacOS 上的 Firefox 上,此解决方案根本不起作用,而在其他操作系统上,只有使用
:has()
伪类的方法在 Firefox 上默认情况下不起作用。