前言
弹窗是日常开发中经常会用到的组件。一般做法是直接使用 v-model
传入 boolean
值绑定 v-show
指令切换组件dom元素 display
显示隐藏,如果不加入开启/关闭过渡动画,UI就会特别生硬。
过渡动画的实现方法有很多种,但由于元素渲染是由 v-show
指令直接控制的,所以在 v-show = false
时元素行内样式会被设为 display: none
脱离文档渲染,那么元素的一切样式都不会显示,所以编写弹窗关闭动画时需要些额外处理。
本文将以 CSS animation
举例,介绍实现异步关闭动画的思路,文章内代码演示地址。
CSS动画
简单介绍一下CSS动画实现弹窗开启动画,只要预先写好 @keyframes rtl
关键帧,再使用基本的 animation-name: rtl
和 animation-duration: 0.3s
两个属性就能实现最简单的弹窗开启(从右侧进入)动画。
@keyframes rtl {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(0%);
}
}
.wrapper {
animation-name: rtl;
animation-duration: 0.3s;
animation-fill-mode: both;
}
实现弹窗关闭动画的 CSS 也非常简单,只要重置一下 animation-name
属性,并使用 animation-direction: reverse
就能让动画直接倒放,不需要专门写关闭动画的 @keyframes
。
基础思路有了,下面就开始用示例代码演示实现。
基本功能
这是一个最基础的弹窗组件代码。弹窗的显示状态读取 v-model
即 props.modelValue
,开启动画关键帧为 @keyframes rtl
,定义在 Class .s-popup--wrapper.rtl
上:
<script lang="ts" setup>
import { computed } from 'vue';
defineOptions({
name: 'Popup',
});
interface Props {
modelValue: boolean;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false, // 自定义的 v-model 属性,双向绑定控制弹窗显示
});
const emit = defineEmits(['update:modelValue']); // 更新 v-model 值
// 弹窗内部 v-model 显示状态,被赋值时双向绑定更新父组件 v-model
const visible = computed({
get() {
return props.modelValue;
},
set(val) {
emit('update:modelValue', val);
},
});
const handleClose = () => {
visible.value = false;
};
</script>
<template>
<div v-show="visible" class="s-popup rtl" v-bind="$attrs">
<div class="s-popup--wrapper">
<button class="btn-close" @click="handleClose">关闭</button>
<slot></slot>
</div>
</div>
</template>
<style lang="scss">
@keyframes rtl {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(0%);
}
}
.s-popup {
position: fixed;
z-index: 1000;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&--wrapper {
display: grid;
width: 100%;
height: 100%;
animation-name: rtl;
animation-duration: 0.3s;
animation-fill-mode: both;
background-color: var(--base-color-lighter);
.btn-close {
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
}
}
&.rtl {
height: 100%;
top: 0;
left: auto;
right: 0;
transform: translate(0%, 0%);
.s-popup--wrapper {
animation-name: rtl;
}
}
}
</style>
注意: 这里有个技巧,弹窗组件的定位样式是写在最外层的 .s-popup
上,而播放动画则是由其子元素 .s-popup--wrapper
负责,这种写法好处是,编写关键帧时不需要考虑元素本身的 transform
定位问题,让写好的关键帧少一些多余样式,能够适用到不同的组件里。
异步关闭
在文章一开头的时候已经说明过了,弹窗如果由 v-show
指令直接控制渲染状态的话,CSS动画是无法正常渲染的。
- 实现的第一步逻辑是,弹窗渲染状态不能直接绑定
v-model
,我们需要一个内部变量独立控制弹窗组件显示状态,而v-model
仅作为一个watch
触发弹窗显示状态异步更新。 watch
的过程中,弹窗开启动画是不受display: none
影响的,所以还需要区分开启/关闭状态,定义一个closed
状态以定义一个触发更新的时机。- 弹窗关闭是在我们关闭过渡动画播放结束之后,那么我们异步更新显示状态的关键就是读取弹窗动画的播放状态。
具体实现代码如下:
<script lang="ts" setup>
import { computed } from 'vue';
defineOptions({
name: 'Popup',
inheritAttrs: false, // 禁用 Attributes 继承
});
interface Props {
modelValue: boolean;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false, // 自定义的 v-model 属性,双向绑定控制弹窗显示
});
const emit = defineEmits(['update:modelValue']); // 更新 v-model 值
const closed = ref(false); // 弹窗关闭中
const animationPlaying = ref(false); // 过渡动画播放中
const shouldVisible = ref(props.modelValue); // 不确定的弹窗状态(判断动画过渡后才能确定显示状态)
// 弹窗内部 v-model 显示状态,被赋值时双向绑定更新父组件 v-model
const visible = computed({
get() {
return props.modelValue;
},
set(val) {
emit('update:modelValue', val);
},
});
watch(visible, (val) => {
changeShouldVisible(val);
});
// 异步切换自身显示状态
const changeShouldVisible = (val: boolean) => {
// 弹窗显示时没有动画播放延迟,不能异步显示,否则打开弹窗时会有延迟
if (val) {
shouldVisible.value = val;
} else {
closed.value = true;
const timer = setInterval(() => {
if (animationPlaying.value) return; // 动画播放中时继续轮询
closed.value = false;
shouldVisible.value = val;
clearInterval(timer); // 弹窗关闭,结束轮询
}, 100);
}
};
const handleClose = () => {
visible.value = false;
};
const handleAnimationstart = () => {
animationPlaying.value = true;
};
const handleAnimationend = () => {
animationPlaying.value = false;
}
</script>
<template>
<div v-show="shouldVisible" class="s-popup rtl" v-bind="$attrs">
<div class="s-popup--wrapper" @animationstart.self="handleAnimationstart"
@animationend.self="handleAnimationend">
<button class="btn-close" @click="handleClose">关闭</button>
<slot></slot>
</div>
</div>
</template>
首先我们可以通过 animationstart
和 animationend
事件读取 animationPlaying
播放中状态,在 v-model
触发弹窗关闭后,我们使用 changeShouldVisible
方法去控制弹窗显示,通过 setInterval
方法不断查询 animationPlaying
保证弹窗的 display: none
在动画完成后才被设置。
至此,我们已把 v-model
和弹窗开启/关闭分离开,只需要在关闭时为元素设置动画倒放,就能完成弹窗的关闭动画。
关闭动画
这里我们结合 Vue3
的特性,使用组合式函数来编写一个可复用的倒放样式。
由于动画是使用同一个 animation-name
,所以在触发的那一刻我们需要先把 animation-name
清空,然后 setTimeout
一个 animation-irection: reverse
倒放属性,就能够让元素动画倒放,完成功能。
// useAnimationReverse.ts
import type { Ref } from 'vue';
import { ref, watch } from 'vue';
export function useAnimationReverse(on: Ref<boolean>) {
const style = ref<{ [key: string]: any }>({});
watch(on, (val) => {
if (val) {
// 先重设元素动画样式
style.value = {
animationDuration: 'auto',
animationTimingFunction: 'ease',
animationName: 'none',
};
// 随后设置倒放
setTimeout(() => {
style.value = {
animationDirection: 'reverse',
};
}, 0);
} else {
style.value = {};
}
});
return {
style,
};
}
最后引入到组件代码中。
最终代码
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { useAnimationReverse } from '@packages/composables';
defineOptions({
name: 'Popup',
});
interface Props {
modelValue: boolean;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
});
const emit = defineEmits(['update:modelValue']);
const closed = ref(false); // 弹窗关闭中
const animationPlaying = ref(false); // 过渡动画播放中
const shouldVisible = ref(props.modelValue); // 不确定的弹窗状态(判断动画过渡后才能确定显示状态)
const visible = computed({
get() {
return props.modelValue;
},
set(val) {
emit('update:modelValue', val);
},
});
watch(visible, (val) => {
changeShouldVisible(val);
});
const { style: animationStyle } = useAnimationReverse(closed);
const changeShouldVisible = (val: boolean) => {
// 弹窗显示时没有动画播放延迟,不能异步显示,否则打开弹窗时会有延迟
if (val) {
shouldVisible.value = val;
} else {
closed.value = true;
const timer = setInterval(() => {
if (animationPlaying.value) return; // 动画播放中时继续轮询
closed.value = false;
shouldVisible.value = val;
clearInterval(timer); // 弹窗关闭,结束轮询
}, 100);
}
};
const handleClose = () => {
visible.value = false;
};
const handleAnimationstart = () => {
animationPlaying.value = true;
};
const handleAnimationend = () => {
animationPlaying.value = false;
}
</script>
<template>
<div v-show="shouldVisible" class="s-popup rtl" v-bind="$attrs">
<div class="s-popup--wrapper" :style="animationStyle" @animationstart.self="handleAnimationstart"
@animationend.self="handleAnimationend">
<button class="btn-close" @click="handleClose">关闭</button>
<slot></slot>
</div>
</div>
</template>
<style lang="scss">
@keyframes rtl {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(0%);
}
}
.s-popup {
position: fixed;
z-index: 1000;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&--wrapper {
display: grid;
width: 100%;
height: 100%;
animation-name: rtl;
animation-duration: 0.3s;
animation-fill-mode: both;
background-color: var(--base-color-lighter);
.btn-close {
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
}
}
&.rtl {
height: 100%;
top: 0;
left: auto;
right: 0;
transform: translate(0%, 0%);
.s-popup--wrapper {
animation-name: rtl;
}
}
}
</style>