我从虚幻中惊醒

给 Details 元素添加动画

<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 summarydetails 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> 标签的,那么我们就需要阻止浏览器的默认行为。

由于我是懒狗,这里请出大名鼎鼎的 jQuery。

$('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,那还真不如完全自己写,比原生的好用多了。

又掉了几根头发,累啊。

给 Details 元素添加动画

https://blog.guhub.cn/coder/how-to-animate-details.html

作者

Eltrac

发布时间

2022-08-18

许可协议

CC BY-NC 4.0

添加新评论