阅读视图

发现新文章,点击刷新页面。

CSS transform 动画与 z-index 层叠上下文分析

问题表现

CSS 动画很常见,如果动画时刚好有元素叠加在动画之上,就不太常见了。比如,点击一个元素会有动画,同时页面有一个 toast 提示,刚好 toast 的位置叠加在动画的元素之上。按说也没有问题,toast 的层级更高,在上面显示。

但是在 iOS WebView 中,会表现异常,比如:

<template>
  <div class="page relative h-screen w-screen">
    <div class="main absolute">
      <button
        type="button"
        class="btn animate__animated absolute text-white"
        @click="handleClick($event)"
      >
        动画元素
      </button>
      <div class="toast absolute text-white">
        toast 提示提示提示提示提示提示提示
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { showToast } from 'vant'

function handleClick(event: TouchEvent | MouseEvent): void {
  const target = event.currentTarget as HTMLElement
  showToast({
    message: '测试toast提示重叠在动画元素之上',
    duration: 2000,
  })

  target.classList.add('animate__headShake')
  target.addEventListener(
    'animationend',
    () => {
      target.classList.remove('animate__headShake')
    },
    { once: true },
  )
}
</script>

<style lang="scss" scoped>
.main {
  box-sizing: content-box;
  border: 1px solid blue;
  left: 50%;
  top: calc(50% + 0px);
  margin: -300px 0 0 -300px;
  width: 600px;
  height: 600px;
}
.btn {
  background: red;
  left: 50%;
  top: 50%;
  margin: -150px 0 0 -150px;
  width: 300px;
  height: 300px;
}
.toast {
  border: 1px solid yellow;
  left: 0;
  bottom: 150px;
  padding: 0 20px;
  width: 100%;
  height: 100px;
  line-height: 100px;
  overflow: hidden;
  background: rgba(0, 0, 0, 0.6);
  font-size: 40px;
  z-index: 2002;
}
</style>

点击元素时给元素添加动画,同时页面显示 toast,动画 headShake 实现如下,就是 animation transform translate 等。当动画元素向左边偏移时,元素左边部分与 toast 重叠的部分会显示在最上层,当元素向右边偏移时,元素右边部分与 toast 重叠的部分会显示在最上层。这样在动画的过程中,图层交错显示,出现闪烁的现象。而期望的是 toast 一直显示在最上层。

@keyframes headShake {
    0% {
        -webkit-transform: translateX(0);
        transform: translateX(0)
    }

    6.5% {
        -webkit-transform: translateX(-6px) rotateY(-9deg);
        transform: translateX(-6px) rotateY(-9deg)
    }

    18.5% {
        -webkit-transform: translateX(5px) rotateY(7deg);
        transform: translateX(5px) rotateY(7deg)
    }

    31.5% {
        -webkit-transform: translateX(-3px) rotateY(-5deg);
        transform: translateX(-3px) rotateY(-5deg)
    }

    43.5% {
        -webkit-transform: translateX(2px) rotateY(3deg);
        transform: translateX(2px) rotateY(3deg)
    }

    50% {
        -webkit-transform: translateX(0);
        transform: translateX(0)
    }
}

描述不能清楚说明问题,可以看视频:

视频上传不了,视频截图:

image.png

可以看到,动画的过程中,它的一部分出现在了最上层。

这个问题,在 iOS WebView 中出现,在其他平台正常,另外在 iOS Safari 中也正常。

网上搜了一下,没有找到类似的案例。

为了说明 vant 的 showToast 没有干扰因素,代码中做了一个 toast 提示进行对比。

原因分析

  • 将 toast 的层级提升:vant toast 的 z-index 为 2002,设置更大的值。
  • 使用 will-change 明确指定渲染层:尝试给动画元素添加 will-change: transform; 确保动画在一个独立的合成层渲染。
  • 让 toast 也进行合成:给 toast 元素添加 transform: translate3d(0, 0, 0) 强制提升到独立渲染层。

尝试了以上方法都不能修复问题,后面就会发现,这个问题和层叠上下文有关,所以第一个方法没有意义,动画元素本来就是层叠上下文,所以应该已经有了独立的合成层。

问题是 iOS 是如何提升渲染合成层的?是如何进行渲染的?通过观察能够发现,动画元素和 toast 的重叠的一部分显示在最上层,而不是整体,所以渲染的时候是将动画的一部分进行了提升?

层叠上下文

MDN 介绍:

image.png

文章介绍的很详细,尤其是示例,清楚的展示了各个元素创建的独自的层叠上下文如何相互影响。一个层叠上下文之内只在同层级之间比较(上下层叠),它(父级)会作为一个整体,和它同级的层叠上下文比较。子级层叠上下文的 z-index 值只在父级中才有意义。

可以看到 transform 会形成层叠上下文。

另外,transform 也会影响 position 定位:

image.png

这个,我想不出来有什么使用场景需要这样。

形成层叠上下文 1

如果给 main 增加 z-index,形成层叠上下文,在动画时,设置动画元素 z-index: -1; 就可以实现 vant showToast 正常显示,但是 toast 还是不正常,会有被动画元素覆盖的现象。

<template>
  <div class="page relative h-screen w-screen">
    <div class="main absolute z-10">
      <button
        type="button"
        :class="btn animate__animated absolute text-white"
        @click="handleClick($event)"
      >
        动画元素
      </button>
      <div class="toast absolute text-white">
        toast 提示提示提示提示提示提示提示
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { showToast } from 'vant'

function handleClick(event: TouchEvent | MouseEvent): void {
  const target = event.currentTarget as HTMLElement
  showToast({
    message: '测试toast提示重叠在动画元素之上',
    duration: 2000,
  })

  target.classList.add('animate__headShake')
  target.style.zIndex = '-1'
  target.addEventListener(
    'animationend',
    () => {
      target.classList.remove('animate__headShake')
      target.style.zIndex = 'auto'
    },
    { once: true },
  )
}
</script>

效果:

image.png

形成层叠上下文 2

如果给 main 增加外层元素 animate__animated animate__fadeIn,即使 main 不设置 z-index,也能实现和上面相同的效果。神奇吧?

<template>
  <div class="page relative h-screen w-screen">
    <section class="animate__animated animate__fadeIn">
      <div class="main absolute">
        <button
          type="button"
          :class="btn animate__animated absolute text-white"
          @click="handleClick($event)"
        >
          动画元素
        </button>
        <div class="toast absolute text-white">
          toast 提示提示提示提示提示提示提示
        </div>
      </div>
    </section>
  </div>
</template>

因为 fadeIn 改变了 opacity 的原因,形成了层叠上下文:

@keyframes fadeIn {
    0% {
        opacity: 0
    }

    to {
        opacity: 1
    }
}

让 toast 也能正常显示

想要 toast 提示正常,需要将 toast 元素提到外层,这样它就属于外层的层叠上下文,不受 main 的层叠上下文的影响。

<template>
  <div class="page relative h-screen w-screen">
    <div class="main absolute z-10">
      <button
        type="button"
        :class="btn animate__animated absolute text-white"
        @click="handleClick($event)"
      >
        动画元素
      </button>
    </div>
    <div class="toast absolute text-white">
      toast 提示提示提示提示提示提示提示
    </div>
  </div>
</template>

我们再调整 toast 的定位,使它在动画元素的正上方,可以看到重合时已经不会被覆盖。

还有一种异常显示

如果上面形成层叠上下文中,设置了 absolute 定位,但是没有设置 z-index,那就没有形成层叠上下文,会产生这种效果,动画元素的一半消失,因为动画时设置层级为 -1,比背景图更低。

image.png

层叠上下文总结

如果上面动画时不把 z-index 的值设置为 -1,而是其他值比如 1,则修复不能生效。

这是为什么呢?通过以上尝试可以看到,要将 button 放入一个层叠上下文中,同时在 button 动画时(已经形成层叠上下文)设置 z-index,降低在 Z 轴的层级,但即使这样降低,它在动画时的渲染仍然遮挡了其他元素(toast),所以似乎 iOS 在处理合成层时有独特的逻辑,会突破层叠上下文的限制。

测试总结

开始以为是 iOS WebView 才有问题,跟 WebView 处理合成层渲染上的实现有关,后来经过测试发现:

  • iPhone 14 没有问题,Safari 和 WebView 均正常。
  • iPhone 12、11 的 Safari、WebView 在以上各版本的层叠上下文处理上,表现各不相同。

只能说,CSS 的层级问题十分复杂,不同版本的 iOS 和设备可能采用了不同的 GPU 渲染优化策略。但是我们能做的是利用层叠上下文,根据层叠上下文设置动画层级。

参考

❌