原文链接:Transitioning Hidden Elements,Paul Hebert
如果你在设置了 hidden 属性或者 display: none 声明的元素上使用 CSS 过渡(transition),这是个不好实现的效果。我遇到这个问题很多次了,最后决定编写一个 npm 软件包 提供一个可重用的解决方案。
问题
有很多种隐藏元素的方法。比如使用 visibility、opacity、transform、position 甚至是 clip-path,但这些属性有时并不能满足我们的预期效果。元素使用 visibility: hidden 隐藏后,原来占据的空间显示为空白,而且上述这些属性的隐藏效果对屏幕阅读器而言都是可见的、能够解析到的。
使用 hidden 属性 或 display: none 声明隐藏的元素就没有这个问题。但这两种隐藏的元素方式还有一个明显的缺点:就是我们不能对它们应用 transtion 过渡效果。那么,我们该怎样解决这个问题呢?我最终编写了一个 npm 软件包 来处理这个问题,并在这个过程中学到了不少关于文档流、transtion 事件方面的知识。下面跟大家分享下。
显示和隐藏 .drawer
假设网页里有一个 .drawer 元素,它使用 CSS transform 属性设置偏移,移动到视口之外,随后使用 hidden 属性从文档流中移除。
(本文里的所有例子都是使用 hidden 属性隐藏元素,这种隐藏方式与 display: none 效果一样。npm 包 同时支持这两种方式的设置。)
<div class="drawer" hidden>Hello World!</div>
<style>
.drawer {
transform: translateX(100%);
transition: transform ease-out 0.3s;
}
.drawer.is-open {
transform: translateX(0);
}
</style>
下面展示了我们最终要达到的效果(点击这里 查看 demo):
我们如何实现这种方式的滑入滑出效果,同时还能将隐藏元素从文档流中移除呢?
继续看。
显示 .drawer
由于 hidden 元素不在文档流中,因此是不能对它使用过渡效果的。但可以这样做:删除元素 hidden 属性后,强制文档重新排版(reflow),这样就可以对 CSS 属性做 transition 效果了。同时,该需要一点 JS 代码协助完成这个工作。
const drawer = document.querySelector('.drawer');
function show() {
drawer.removeAttribute('hidden');
/**
* 强制浏览器重绘(re-paint),然后浏览器就能知道
* 元素不是 `hidden` 的了,这样 transition 也就起作用了。
*/
const reflow = element.offsetHeight;
// 触发 CSS transition 效果
drawer.classList.add('is-open');
}
隐藏 .drawer
到目前为止,我们知道如何显示 .drawer 了,但怎样实现元素隐藏时的过渡效果呢?这就要保证隐藏元素再一次应用了 hidden 属性。但问题是,如果我们在元素隐藏的那一时刻,就应用 hidden,是看不见过渡效果的。相反,我们应该先等待过渡效果结束后,再 hidden 元素。有两种方式可以实现:transitionend 和 setTimeout。
transitionend 事件
CSS 的 transition 效果结束时会触发 transitionend 事件。对应到我们的示例中,通过删除 .is-open 这个类名,就能自动触发过渡效果发生。通过利用这个事件回调,我们就能在过渡完成后,为元素添加 hidden 属性。
同时,还要确保过渡完成后要移除事件监听器,否则当元素再次显示时,还会触发一次之前绑定的事件回调,就有问题了。为此,我们可以将监听器(listener)作为一个变量存储,并在过渡完成后将其移除:
const listener = () => {
// 3) 为元素添加 hidden 属性
drawer.setAttribute('hidden', true);
// 4) 最后移除元素的事件监听器
drawer.removeEventListener('transitionend', listener);
};
// 隐藏元素
function hide() {
// 1) 先删除 .is-open 这个类名
drawer.classList.remove('is-open');
// 2) 在过渡效果结束后,进入 3)
drawer.addEventListener('transitionend', listener);
}
还要注意的是,中间可能还会发生这种操作:当前的 .drawer 正在隐藏、但还没有完全隐藏的时候,又再次点击显示。对于这种情况,我们还需要在上述的 show() 函数中手动再移除一次事件监听器:
function show() {
drawer.removeEventListener('transitionend', listener);
/* ... */
}
transitionend 是冒泡事件
transitionend 是冒泡事件,因此使用 transitionend 事件时,有一个边缘情况需要考虑:在 DOM 中,事件冒泡是从子元素传递到祖先元素的。如果 .drawer 元素内部包含具有过渡效果的子元素,那么就会引发问题。
如果一个子元素的过渡效果先于 .drawer 元素完成,那么由于事件冒泡,该元素上的 transitionend 事件监听器会提前触发。
为了避免这个问题的发生,可以在事件监听器中检查当前触发 transitionend 事件的目标元素是否是 .drawer。是的话,在再执行之前的处理逻辑:
const listener = e => {
// 只有在目标元素是 .drawer 的情况下,在再执行之前的处理逻辑
if(e.target === drawer) {
drawer.setAttribute('hidden', true);
drawer.removeEventListener('transitionend', listener);
}
};
使用 setTimeout 等待
使用 transitionend 事件有一个局限性,就是只能处理一个元素的过渡场景。如果要实现的是下面这种涉及到多个元素过渡效果的 交错动画 场景,就不适应了。
点击这里 查看 demo
需要实现的功能:点击右上角按钮实现侧边菜单带有过渡效果的显示和隐藏;同时点击菜单子链接也会触发菜单的隐藏过渡效果。这需要我们等待所有子元素的过渡效果完成,才能将 hidden 属性添加给 .drawer。
最开始的处理方式,是强制等待所有子元素的 transitionend 事件都触发后,再做处理。但是,如果用户是在进行快速切换,那么就可能有问题了:有些子元素就没有发生过渡,那么这些元素也永远不会触发 transitionend 事件。
为了解决这个问题,我们选择使用 setTimeout,而不是去监听 transitionend 事件:
function hide() {
element.classList.remove(visibleClass);
const timeoutDuration = 200;
const timeout = setTimeout(() => {
element.setAttribute('hidden', true);
}, timeoutDuration);
}
除此之外,为了避免前面发生的(菜单正在隐藏时就点击显示)场景,不要忘记在 show() 中手动清除定时器:
function show() {
if (this.timeout) {
clearTimeout(this.timeout);
}
/* ... */
}
我最终创建的 npm 包中,同时提供对 timeout 和 transitionend 选项的支持, 方便大家根据需要使用。
使用 npm 包
经过上面的分析,大家能够知道隐藏元素的过渡效果实现起来还是稍微有点复杂的。我的 npm 包将本例所提到到两类场景所使用的代码,都包装到了 transitionHiddenElement 模块中。
使用方式如下:
import { transitionHiddenElement } from '@cloudfour/transition-hidden-element';
const drawerTransitioner = transitionHiddenElement({
element: document.querySelector('.drawer'),
visibleClass: 'is-open',
});
// 使用
drawerTransitioner.show();
drawerTransitioner.hide();
drawerTransitioner.toggle();
我希望这个方案对大家的项目有所帮助。你可以从 npm 获取或者在 GitHub 上查看源代码!
(正文完)
广告时间(长期有效)
我有一位好朋友开了一间猫舍,在此帮她宣传一下。现在猫舍里养的都是布偶猫。如果你也是个爱猫人士并且有需要的话,不妨扫一扫她的【闲鱼】二维码。不买也不要紧,看看也行。
(完)