使用 Vue 和 @Vueuse/motion 制作带动画的模态框

本文介绍了如何使用 Vue@vueuse/motion 创建一个带有动画的模态框。你可以在 这里 找到最终代码。

首先,让我们来创建一个 Modal,核心部分如下(略去了自定义类的具体内容,可以在最终代码处查看):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<!-- Modal.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const isOpen = ref(false);
const openModal = () => {
isOpen.value = true;
};
const closeModal = () => {
isOpen.value = false;
};
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeModal();
}
};
onMounted(() => {
document.addEventListener("keydown", handleKeydown);
});
onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown);
});
</script>

<template>
<button class="btn" @click="openModal">Open</button>
<Teleport to="body">
<div v-if="isOpen" class="modal-background" />
<div v-if="isOpen" class="modal-container" @click.self="closeModal">
<div class="modal">
<p class="modal-text">Hello, world!</p>
<button class="btn" @click="closeModal">Close</button>
</div>
</div>
</Teleport>
</template>

几个注意点如下:

  • 使用 Teleport 来将最后的模态框 “传送” 到 DOM 中更合理的位置去;
  • 拆分了模态框背景(标注为 modal-background)和模态容器(标注为 modal-container),这是因为我打算创建的模态框的动画是从下往上上浮显示、同时带有一个透明度的变化,而背景的动画我只希望其带有一个透明度的变化;
  • 使用 position: fixed;inset: 0; 来让模态框背景和模态容器正确定位;
  • 使用 @click.self 来为模态框外侧区域( modal-container 的区域)添加一个点击事件,此处为关闭模态框;
  • 使用 onMountedonUnmounted 来在组件加载和卸载时分别添加和移除一个事件监听器,用于在 Esc 键被按下时关闭模态框;

下面让我们来制作动画。背景动画比较简单,我们直接使用 Vue 提供的 Transition 组件将原来的 modal-background 包裹,并添加相应的 CSS 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<!-- ... -->
<Transition name="fade">
<div v-if="isOpen" class="modal-background" @click="closeModal" />
</Transition>
<!-- ... -->
</template>

<style>
/* ... */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

然后是模态框主体的动画,这里使用了 @vueuse/motion 。首先,添加这个包:

1
pnpm install @vueuse/motion

接着编辑 main.ts

1
2
3
4
5
6
7
8
import { createApp } from "vue";
import { MotionPlugin } from "@vueuse/motion";
import "./style.css";
import App from "./App.vue";

const app = createApp(App);
app.use(MotionPlugin);
app.mount("#app");

OK,可以开始使用了。但是简单地为 modal-container 添加 v-motion 并不足够:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- error -->
<template>
<!-- ... -->
<div
v-if="isOpen"
class="modal-container"
@click.self="closeModal"
v-motion
:initial="{ opacity: 0, y: 100 }"
:enter="{ opacity: 1, y: 0 }"
:leave="{ opacity: 0, y: 100 }"
>
<div class="modal">
<p class="modal-text">Hello, world!</p>
<button class="btn" @click="closeModal">Close</button>
</div>
</div>
<!-- ... -->
</template>

如果是这样,你会发现进入动画非常顺利地播放了,但退出动画完全没有生效!为什么会这样?

原因在于我们是通过 v-if 来控制是否显示这个组件的。在进入时,首先 v-if 后跟的值 isOpen 为真,接着进入动画正常播放;但是在退出时,isOpen 变为假,v-if 检测到后直接将该组件从 DOM 中移除了,退出动画根本没有时间播放!

如何解决呢?这个 Demo 演示了这一点,可以配合 Transition 使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<script setup lang="ts">
import { useMotions } from "@vueuse/motion";
const motions = useMotions();
// ...
</script>

<template>
<!-- ... -->
<Transition :css="false" @leave="(_, done) => motions.modal.leave(done)">
<div
v-if="isOpen"
class="modal-container"
@click.self="closeModal"
v-motion="'modal'"
:initial="{ opacity: 0, y: 100 }"
:enter="{ opacity: 1, y: 0 }"
:leave="{ opacity: 0, y: 100 }"
>
<div class="modal">
<p class="modal-text">Hello, world!</p>
<button class="btn" @click="closeModal">Close</button>
</div>
</div>
</Transition>
<!-- ... -->
</template>

这样退出动画也能顺利播放了。