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)
}
}
描述不能清楚说明问题,可以看视频:
视频上传不了,视频截图:
可以看到,动画的过程中,它的一部分出现在了最上层。
这个问题,在 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 介绍:
文章介绍的很详细,尤其是示例,清楚的展示了各个元素创建的独自的层叠上下文如何相互影响。一个层叠上下文之内只在同层级之间比较(上下层叠),它(父级)会作为一个整体,和它同级的层叠上下文比较。子级层叠上下文的 z-index
值只在父级中才有意义。
可以看到 transform
会形成层叠上下文。
另外,transform
也会影响 position
定位:
这个,我想不出来有什么使用场景需要这样。
形成层叠上下文 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>
效果:
形成层叠上下文 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,比背景图更低。
层叠上下文总结
如果上面动画时不把 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 渲染优化策略。但是我们能做的是利用层叠上下文,根据层叠上下文设置动画层级。