<details>
是 HTML5 加入的新标签,尽管问世已经有了一段时间,但它似乎很少被提及和使用。目前,在主流浏览器中,除了 IE 浏览器的大部分浏览器都支持 <details>
标签,但其中的一些未严格按照规范编写,并且可能存在一些 bug。由于 <details>
的特殊性,为它编写动画是一个难点。
为什么要编写动画
为了更好的用户体验。<details>
标签的功能是:默认展示一段文本,点击后展开详细内容。
但是这个「展开」十分生硬,点击文本后,原本朝左的箭头会变成朝下,隐藏的内容会直接出现,这些改变都是在一瞬间完成的。面对突然出现的内容,尤其是展开内容较多时,会让用户有一种「不知所措」的感觉。就像这样:
See the Pen Default Details Element Demostration by Eltrac Koalar (@Eltrac) on CodePen.
为了修正这个问题,我们需要为 <details>
编写动画,让元素的出现更自然,给用户更多的反应时间。
为什么 Details 标签难以编写动画
首先是因为 <details>
标签的结构,它是这样的。
<details>
<summary>Click me!</summary>
Now you see me!
</details>
其中,被 <summary>
标签包裹的就是在默认情况下会显示的部分,而被直接包裹在 <details>
标签内的文本就是被隐藏的部分。这两个部分都是 details 的子元素,我们难以为它们分别指定选择器。
举个例子,我们可以直接用 summary
选择器选中 <summary>
标签,但只能用 details *
选中隐藏内容,而这个选择器又会同时选中 <summary>
标签。
其次,<summary>
标签前显示的用于指示标签开闭状态的小三角,其实是类似于列表前小圆点的东西,即 list-style-type
;在 Chrome 中,它是一种伪元素,即 ::marker
(然而这并不规范)。这一特性导致我们无法直接对这个小三角编写动画,无论是旋转,淡入淡出等变换方式都不行。
最后,<details>
标签的开闭状态是由标签是否拥有 open
属性决定的。点击 <summary>
之后,它的父元素,即 <details>
就会被赋予 open
属性;拥有 open
属性的 <details>
标签会被打开,即展示其内部不在 <summary>
标签内的内容。
令人不解的是,<details>
标签是如何隐藏内容的?是从 DOM 树中直接删除或添加内容,还是更改内容的 display
属性?似乎都不是。通过浏览器的检查工具可以看到,无论元素是否拥有 open
属性,其内容都存在于 DOM 树中;尝试查看对应内容的 css 属性也没有找到任何线索。这似乎是一种特殊的渲染方式。
这就是最麻烦的,我们(至少我和我在 StackOverflow 上看到的大佬)完全无从得知 <details>
标签是如何工作的。
寻找解决方案
要为 <details>
标签编写动画,我们就需要克服以上问题。
首先是结构的问题,这很简单,也很暴力,我们只需要在编写 <details>
标签的时候,用额外的标签包裹住需要隐藏的内容就可以了,这里使用 <section>
标签作为例子。
<details>
<summary>Click me!</summary>
<section>Now you see me!</section>
</details>
这样我们就可以分别用 details summary
和 details section
选择这两个元素了。
然后是小三角的问题,只要我们隐藏这个三角,然后插入一个新的,类似的图标,这样我们就能够随意地定义它的样式了。要隐藏这个小三角,Chrome 的做法与标准的做法有所不同,所以我们需要定义两个选择器。
details summary {
list-style-type: none
}
details summary::-webkit-details-marker {
display: none
}
然后,用伪元素 ::before
创建一个新的图标,这里用+
代替,打开后将其旋转 45 度,变成x
,分别表示打开和关闭。
details summary::before {
content: '+';
transition: transform .3s;
margin-right: .5em;
display: inline-block /* 必须是块级元素才能够旋转 */
}
details[open] summary::before {
transform: rotate(45deg)
}
这样,图标的旋转过渡动画就做好了,接下来是重头戏,也就是展开和收起的动画。
我尝试更改 height
属性并添加过渡动画来实现,但并没有成功。
details section {
height: 0;
overflow: hidden;
transition: height .3s
}
details[open] section {
height: auto
}
这可能是因为 <details>
标签的特殊性导致的,就像前面说的,我不知道它是怎么工作的。
于是我只能退而求其次,使用淡入淡出动画。
details[open] summary ~ * {
animation: sweepIn .3s ease-in-out;
}
@keyframes sweepIn {
0% {opacity: 0; transform: translateY(-10px); margin-bottom: -10px}
100% {opacity: 1; transform: translateY(0)}
}
See the Pen Roughly Animated Details Element by Eltrac Koalar (@Eltrac) on CodePen.
这次的效果看起来还不错,但是...这个动画只有初次点击的时候有效,并且关闭的动画也同样生硬。
我又尝试了各种方法,并且到 StackOverflow 等各大平台寻找答案无果后,我不得不使用 JavaScript 来处理 <details>
标签的动画。啊我真的服了,这个标签的目的不就是为了方便写手风琴吗,怎么还要自己写 JS 的啊(恼
首先我们要有一个思路,我们并不知道浏览器是如何处理 <details>
标签的,那么我们就需要阻止浏览器的默认行为。
$('details').on("click",function(e){
e.preventDefault();//阻止 details 直接显示内容
});
然后我们就需要根据这个 <details>
标签有没有被打开,即有没有 open
属性,来决定点击后的操作,大体思路如下。
当 <details>
标签被点击,
- 如果有
open
属性,为该元素添加.closing
类,并编写淡出动画,动画结束后,移除.closing
类和open
属性 - 如果没有
open
属性,直接为该元素添加open
属性,因为我们已经为details[open]
编写了淡入动画
具体代码如下
$('details').on("click",function(e){
e.preventDefault();//阻止 details 直接显示内容
if(!$(this).attr('open')){
$(this).attr('open','');
}else{
$(this).addClass('closing');
setTimeout(() => {
$(this).removeClass('closing');
$(this).removeAttr('open');
}, 300);
}
});
details.closing section {
animation: sweepOut .3s ease-in-out;
}
@keyframes sweepOut {
0% {opacity: 1; transform: translateY(0)}
100% {opacity: 0; transform: translateY(-10px)}
}
在此基础上还可以做一些优化,给 sweepOut
动画的最后一帧加上一个负值的 margin-bottom,这样就可以让后面的文本跟着它向上运动一段距离,看起来会更加平滑。
@keyframes sweepOut {
0% {opacity: 1; transform: translateY(0);}
100% {opacity: 0; transform: translateY(-10px); margin-bottom: -1.5em}
}
在行距为 1.5,文字大小为 1em,且隐藏内容只有一行的时候,这个动画的表现几乎完美,但行数比较多的时候,只能看到下面的文字跟着一起动了一段距离,随后便直接生硬地移动到了它原来的位置。但这是我能做到最好的效果了。
接着,我又发现了一个问题:在收起 <details>
标签时,表示展开/关闭的符号 +
只会等到收起的动画结束后才会旋转,而这两个动画理应同时进行。
我本来以为这个问题会需要修改 JS 才能解决,但仔细思考了一下,其实只需要改一下 css 选择器就可以了。
details[open]:not(.closing) summary::before {
transform: rotate(45deg)
}
关键就在于这个 :not(.closing)
,当元素没有 closing
类的时候才会旋转 45 度,而关闭 <details>
标签时,被添加了 closing
类,恰巧不满足这个条件,于是就变回 0 度了,这个过程仍然有过渡动画。
最终结果
$('details').on("click",function(e){
e.preventDefault();//阻止 details 直接显示内容
if(!$(this).attr('open')){
$(this).attr('open','');
}else{
$(this).addClass('closing');
setTimeout(() => {
$(this).removeClass('closing');
$(this).removeAttr('open');
}, 300);
}
});
details summary {
list-style-type: none
}
details summary::-webkit-details-marker {
display: none
}
details summary::before {
content: '+';
transition: transform .3s;
margin-right: .5em;
display: inline-block /* 必须是块级元素才能够旋转 */
}
details[open]:not(.closing) summary::before {
transform: rotate(45deg)
}
details[open] summary ~ * {
animation: sweepIn .3s ease-in-out;
}
@keyframes sweepIn {
0% {opacity: 0; transform: translateY(-10px); margin-bottom: -10px}
100% {opacity: 1; transform: translateY(0)}
}
details.closing section {
animation: sweepOut .3s ease-in-out;
}
@keyframes sweepOut {
0% {opacity: 1; transform: translateY(0);}
100% {opacity: 0; transform: translateY(-10px); margin-bottom: -1.5em}
}
因为用了 jQuery,就不方便用 codePen 展示了,直接看在我博客的效果吧。
点我点我点我点我
我也把这段代码发到了 gist 上,对你有帮助的话,给我点个 star 吧。
2022/8/27 更新
我突然意识到我都用 preventDefault()
了怎么不能直接用 js 写动画呢?傻了。既然前面用到了 jQuery,那这里也更进一下,用 jQuery 提供的 slideDown()
和 slideUp()
函数优化一下这段代码。
$('details').on("click",function(e){
e.preventDefault();//阻止 details 直接显示内容
if(!$(this).attr('open')){
$(this).children('section').slideDown();
$(this).attr('open','');
}else{
$(this).children('section').slideUp();
$(this).addClass('closing');
setTimeout(() => {
$(this).removeClass('closing');
$(this).removeAttr('open');
}, 300);
}
});
然后就是,记得把 css 动画里面那个 margin-bottom
去掉。
2022/9/25 更新
在使用过程中又发现一个问题,就是第一次打开的时候不会有动画,故作改良:
因为第一次打开 details 标签时,浏览器才会渲染里面的文字内容,就导致第一次打开时内容是直接出现而没有动画。所以这里直接强制打开所有 details 标签,但是隐藏内容。
$('details').attr('open','');//强制开启,但不显示内容
$('details').children('section').hide();
这样,details 标签的内容就一直存在,这样我们就只需要简单地显示和隐藏这个一直存在的内容就好了,让我们的工作简单了不少。当然,这个方法也有一些问题,也就是弃用了 open
属性作为 details 标签是否打开的依据,在语义化上可能有些问题,但正常使用时,我们只需要设置一个 open
类名作为替代就好了。
$('details').on("click",function(e){
e.preventDefault();
if(!$(this).hasClass('open')){
$(this).children('section').slideDown();//展开内容
$(this).addClass('open');//标志 details 已经打开
}else{
$(this).children('section').slideUp();//收起内容
$(this).addClass('closing');
setTimeout(() => {
$(this).removeClass('closing').removeClass('open');//标志 details 被关闭
}, 300);
}
});
后记
这是我在给 Matcha 主题适配 BracketDown 插件 CSS 样式的时候遇到的问题,觉得有一点讨论的价值,就发出来了。写完这个东西,我最大的感受就是:<details>
就是个垃圾元素。
怎么说呢,虽然这东西方便,而且还是 HTML5 规范,美其名曰「面向未来」。但面向了这么久的未来,这东西硬是一点没优化,兼容性和自定义性都有很大的问题,给它写个动画都那么麻烦,而且写它的动画还要动用 JS,那还真不如完全自己写,比原生的好用多了。
又掉了几根头发,累啊。
就是个垃圾元素。谁反对,谁赞同?我赞同!确实挺垃圾的
枫叶 2022-10-23
来了来了,瞻仰大佬
张 2022-08-29
Safari 6是什么啊?现在的版本是15.X
沉舟侧畔 2022-08-18
文档里关于兼容性写的就是 Safari 6,可能指的是 6 及以后的版本。另外,关于兼容性的问题我看错了,现在市面上出了 IE,大部分浏览器都兼容 details 标签。
Eltrac 2022-08-18 回复 @沉舟侧畔