[Vue组件]弹窗添加异步关闭动画

前言

弹窗是日常开发中经常会用到的组件。一般做法是直接使用 v-model 传入 boolean 值绑定 v-show 指令切换组件dom元素 display 显示隐藏,如果不加入开启/关闭过渡动画,UI就会特别生硬。

过渡动画的实现方法有很多种,但由于元素渲染是由 v-show 指令直接控制的,所以在 v-show = false 时元素行内样式会被设为 display: none 脱离文档渲染,那么元素的一切样式都不会显示,所以编写弹窗关闭动画时需要些额外处理。

本文将以 CSS animation 举例,介绍实现异步关闭动画的思路,文章内代码演示地址

CSS动画

简单介绍一下CSS动画实现弹窗开启动画,只要预先写好 @keyframes rtl 关键帧,再使用基本的 animation-name: rtlanimation-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-modelprops.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动画是无法正常渲染的。

  1. 实现的第一步逻辑是,弹窗渲染状态不能直接绑定 v-model,我们需要一个内部变量独立控制弹窗组件显示状态,而 v-model 仅作为一个 watch 触发弹窗显示状态异步更新。
  2. watch 的过程中,弹窗开启动画是不受 display: none 影响的,所以还需要区分开启/关闭状态,定义一个 closed 状态以定义一个触发更新的时机。
  3. 弹窗关闭是在我们关闭过渡动画播放结束之后,那么我们异步更新显示状态的关键就是读取弹窗动画的播放状态。

具体实现代码如下:

<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>

首先我们可以通过 animationstartanimationend 事件读取 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>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值