[译] 如何实现隐藏元素的过渡效果?

4,233 阅读6分钟

原文链接:Transitioning Hidden Elements,Paul Hebert

如果你在设置了 hidden 属性或者 display: none 声明的元素上使用 CSS 过渡(transition),这是个不好实现的效果。我遇到这个问题很多次了,最后决定编写一个 npm 软件包 提供一个可重用的解决方案。

问题

有很多种隐藏元素的方法。比如使用 visibilityopacitytransformposition 甚至是 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):

GIF.gif

我们如何实现这种方式的滑入滑出效果,同时还能将隐藏元素从文档流中移除呢?

继续看。

显示 .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 事件有一个局限性,就是只能处理一个元素的过渡场景。如果要实现的是下面这种涉及到多个元素过渡效果的 交错动画 场景,就不适应了。

GIF.gif

点击这里 查看 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 上查看源代码!

(正文完)


广告时间(长期有效)

我有一位好朋友开了一间猫舍,在此帮她宣传一下。现在猫舍里养的都是布偶猫。如果你也是个爱猫人士并且有需要的话,不妨扫一扫她的【闲鱼】二维码。不买也不要紧,看看也行。

(完)