在普通的项目中,想实现过渡动画我们可能需要自己定义些 css 的类,写好样式,然后在适当的实际添加或移除对应的类名。而在基于 vue 的项目中,我们可以使用 vue 提供的内置组件 <Transition> 或 <TransitionGroup> 来实现过渡效果。
<Transition>
<Transition> 适用于给单个元素或组件添加过渡动画,如果传入 <Transition> 的是组件,该组件必须只有一个根元素。
通过 css
比如现有一个 div 元素,可以通过按钮来切换其显示与隐藏,要想让切换不过于生硬,就在 div 外包裹上 <transition> 组件,并且给过渡效果取名为 msg:
<!-- 例 1 -->
<script setup lang="ts">
import { ref } from 'vue'
const showMsg = ref(true)
</script>
<template>
<div>
<transition name="msg">
<div v-show="showMsg">
Hello Juejin!
</div>
</transition>
<button @click="showMsg = !showMsg">按钮</button>
</div>
</template>
然后我们就可以根据过渡效果名,定义 css 的过渡或者动画:
css 过渡
/* 例 1.1 */
.msg-enter-from,
.msg-leave-to {
opacity: 0;
}
.msg-enter-active,
.msg-leave-active {
transition: opacity 2s ease;
}
如果不给 <transition> 定义 name 属性,则上面这些 class 的名字默认都是 v-开头, 比如 v-enter-from。
-
msg-enter-from用于定义元素插入/显示前的样式; -
msg-leave-to用于定义元素移除/隐藏后的样式。
这里也可以定义 msg-enter-to 来描述元素插入/显示后的样式和 msg-leave-from 来描述移除/隐藏前的样式:
.msg-enter-to,
.msg-leave-from {
opacity: 1;
}
但是因为本案例 v-show 为 true 时元素默认的 opacity 值就是 1,所以可以忽略不写。
msg-enter-active 和 msg-leave-active 则用于描述过渡的生效状态,比如指定参与过渡的属性,过渡持续的时间以及速度曲线类型。更多具体解释可参见官方文档。
css 动画
除了先定义好 enter 和 leave 的状态然后通过 transition 实现过渡效果,我们也可以通过定义帧动画,使用 animation 来实现:
/* 例 1.2 */
@keyframes opacity-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
接着就可以分别定义元素进入和离开时要应用的动画了,其中 reverse 表明对动画进行反转:
/* 例 1.2.1 */
.msg-enter-active{
animation: opacity-in 2s ease
}
.msg-leave-active {
animation: opacity-in 2s ease reverse
}
结合 animate.css 使用
在项目中我们可以结合第三方动画库 animate.css 来方便地实现诸多动画。
先安装:
pnpm add animate.css
在 main.js 导入
// src\main.js
import 'animate.css'
使用时可以直接将例 1.2.1 中 animation 的 name 值替换为 animate.css 的。(如果你发现动画很奇怪,可能是应用动画的内容为块级元素,默认宽度为 100% 所致,可以尝试添加样式 display: inline-block 后查看):
.msg-enter-active{
animation: bounceIn 1s ease
}
.msg-leave-active {
animation: bounceOutDown 1s ease
}
注意,如果你是去 animate.css 的官网查看的动画预览,上面这种用法我们可以复制如下图中画了横线的动画名字,而不是直接点击后面那个箭头指向的复制图标:
因为查看 animate.css 源码可知,横线所示的就是帧动画定义的名字:
如果直接点击复制图标,复制内容为类名,比如 animate__bounceInDown,查看源码可知其已经通过 animation-name 应用了帧动画 bounceOutDown:
/* node_modules\animate.css\animate.css */
.animate__bounceOutDown {
-webkit-animation-name: bounceOutDown;
animation-name: bounceOutDown;
}
那么我们直接在 <Transition> 上通过对应的属性(自定义过渡 class)来传递类名即可。
自定义过渡 class
我们可以使用如下属性来自定义相应阶段的默认 class 名:
- enter-from-class
- enter-active-class
- enter-to-class
- leave-from-class
- leave-active-class
- leave-to-class
比如下例,我们使用了 enter-active-class 和 leave-active-class 来自定义过渡的 class,结合 animate.css,就可以将上面所说的通过点击复制图标得到的类名传入:
<transition
enter-active-class="animate__animated animate__bounceIn"
leave-active-class="animate__animated animate__bounceOutDown"
mode="out-in"
appear
>
请注意,还需要传入 animate__animated,因为诸如 animate__bounceIn 这些动画类名只定义了 animation-name,而像 animation-duration 这些属性是定义在 animate__animated 中的:
/* node_modules\animate.css\animate.css */
.animate__animated {
-webkit-animation-duration: 1s;
animation-duration: 1s;
-webkit-animation-duration: var(--animate-duration);
animation-duration: var(--animate-duration);
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
css 过渡和动画同时存在
如下, css 过渡和动画可以同时存在:
/* 例 1.3 */
/* transition */
.msg-enter-from,
.msg-leave-to {
opacity: 0;
}
.msg-enter-active,
.msg-leave-active {
transition: opacity 1s ease;
}
/* animation */
.msg-enter-active{
animation: bounce-in 2s ease
}
.msg-leave-active {
animation: bounce-in 2s ease reverse
}
@keyframes bounce-in {
0% {
transform: scale(0);
transform-origin:left top
}
100% {
transform: scale(1);
transform-origin:left top
}
}
但因为 vue 需要在适当的实际添加或移除类 msg-enter-active/ msg-leave-active,可以看到 transition 定义的动画持续时间为 1s,而 animation 的为 2s,那么为了告诉 vue 到底以哪个为准,可以在 <transition> 添加 type 属性来指定:
<transition name="msg" type="animation">
<!-- 或 -->
<transition name="msg" type="transition">
通过 js
通过 css 设置动画有个不足之处是不够灵活,我们可以通过 <Transition> 组件提供的一些钩子函数,通过 js 来设置动画,这样就可以动态地设置动画了。下面举两个常用的钩子为例:
<!-- 例 1.4 -->
<template>
<div>
<transition
@enter="onEnter"
@leave="onLeave"
mode="out-in"
appear
:css="false"
>
<div v-if="showMsg">
Hello Juejin!
</div>
<div v-else>
又是进步的一天
</div>
</transition>
<button @click="showMsg = !showMsg">按钮</button>
</div>
</template>
enter在动画开始进入时触发;leave在动画开始离开时触发;:css="false"是为了让 Vue 跳过对 CSS 动画的自动探测。
结合 GSAP 使用
在定义 js 动画时,我们可以借助第三方库 GSAP(GreenSock Animation Platform) 来实现。
安装:
pnpm add gsap
使用时导入并调用相关方法,下面介绍 gsap.from() 和 gsap.to():
<script setup lang="ts">
import { ref } from 'vue'
import gsap from 'gsap'
const showMsg = ref(true)
const onEnter = (el: Element, done: () => void) => {
gsap.from(el, {
opacity: 0,
x: -100,
onComplete: done
})
}
const onLeave = (el: Element, done: () => void) => {
gsap.to(el, {
opacity: 0,
x: -100,
onComplete: done
})
}
</script>
gsap.from() 方法用于设置目标元素从什么状态变为显示时的默认状态。传入 2 个参数,第 1 个是目标元素,也就是 vue 往钩子函数中传入的 el,第 2 参数是一个对象,用于设置一些变量:
x相当于是 css 的transform: translateX(100px)onComplete表明动画执行完毕。因为例 1.4 我们给<Transition>设置了:css="false",所以这里必须调用done告诉 vue 动画何时完成,否则钩子将被同步调用,过渡将立即完成。
gsap.to() 方法则用于定义目标元素从当前显示时的状态离开后要变成的状态。
效果如下:
GSAP 实现数字动画
这里趁便介绍一个与 <Transition> 无关的关于用 gsap 实现数字变化的动画的小案例:
<script setup lang="ts">
import { ref, watch } from 'vue'
import gsap from 'gsap'
const num = ref(0)
const displayNum = ref(0)
watch(num, newVal => {
const animationTarget = { value: displayNum.value }
gsap.to(animationTarget, {
value: newVal,
duration: 1,
onUpdate: () => {
displayNum.value = animationTarget.value
},
ease: 'power1.out'
})
})
</script>
<template>
<div>
<input v-model="num">
<div>{{ Math.round(displayNum) }}</div>
</div>
</template>
使用 animationTarget 作为 GSAP 动画的中间对象,在 onUpdate 中同步更新 displayNum.value,实现动画过渡效果,ease 的可选值可以参看 GSAP 的文档。
效果如下:
元素/组件间过渡
元素间过渡
前面说过 <Transition> 只适用于给单个元素或组件添加过渡动画,但是只要保证任一时刻只会有一个元素被渲染,那么也是可以在 <Transition> 内放入多个元素的:
<!-- 例 2 -->
<transition name="msg" type="animation">
<div v-if="showMsg">
Hello Juejin!
</div>
<div v-else>
又是进步的一天
</div>
</transition>
默认情况下,进入和离开的元素是同时开始动画的:
如果我们想让进入动画在离开动画执行完毕后执行,可以给 <Transition> 添加 mode 属性为 out-in:
<transition name="msg" mode="out-in">
现在效果如下:
组件间过渡
在例 1 中我们是使用 v-show 触发的切换过渡动画,例 2 中使用 v-if 和 v-else 触发了元素间的过渡动画。我们还可以通过 vue 提供的动态组件元素 <component> 来实现组件间的切换过渡效果:
<script setup lang="ts">
import { ref } from 'vue'
import ComA from './components/ComA.vue'
import ComB from './components/ComB.vue'
// 使用 ref 定义响应式变量并添加类型注解(限制只能为 'ComA' 或 'ComB')
const currentCom = ref<'ComA' | 'ComB'>('ComA')
const components = {
ComA,
ComB
}
</script>
<template>
<div>
<transition name="msg" mode="out-in">
<component :is="components[currentCom]"></component>
</transition>
<button @click="currentCom = currentCom === 'ComA' ? 'ComB' : 'ComA'">
按钮
</button>
</div>
</template>
初次渲染时显示动画
上面这些动画都是在元素/组件发生切换时才生效,如果想在初次渲染时就显示过渡动画,可以在 <transition> 添加 appear 属性:
<transition name="msg" appear>
原理
当我们提供给 <transition> 的内容发生了由以下条件触发的显示隐藏的切换:
v-if;v-show;- 动态组件
<component>切换; - 在 nuxt 项目中,给
<nuxt />外面包一层<transition>也会触发动画。
vue 就会自动检测我们是否定义了对应的 css 过渡/动画,比如上文例 1.1 中的 msg-enter-from,msg-enter-active 等过渡。如果有,则会在适当的时机添加或移除这些类;
如果 <transition> 提供了 js 钩子函数,则在适当时机调用这些钩子函数;
如果既没有定义 css 过渡/动画,也没有定义 js 钩子,则在浏览器的下一个动画帧后执行执行对 DOM 的切换操作,可以看成是立即执行。
<TransitionGroup>
<TransitionGroup> 也是 vue 的内置组件,用于对 v-for 列表中的元素或组件的插入、移除和顺序改变添加动画效果。在默认情况下,<TransitionGroup> 不会渲染一个容器元素,但是可以通过 tag 属性来指定一个元素作为容器渲染,内部元素需要提供唯一的 key。比如下例中,就没有直接写一个 <ul> 标签来包裹 <li>,而是通过 tag 指定:
<script setup lang="ts">
import { ref } from 'vue'
const list = ref<number[]>([])
let num = 0
const handleAdd = () => {
list.value.unshift(num++)
}
const handleSub = () => {
list.value.shift()
}
</script>
<template>
<div>
<transition-group tag="ul" name="list">
<li v-for="item in list" :key="item">{{ item }}</li>
</transition-group>
<button @click="handleAdd">添加</button>
<button @click="handleSub">减少</button>
</div>
</template>
<TransitionGroup>的属性、通过 css 或 js 实现过渡动画的方法基本上和 <Transition> 一样,但也有些区别,比如 <TransitionGroup> 的 css 过渡的类是应用于内部元素上,而不是容器元素。
v-move
<TransitionGroup> 还提供了 v-move 类来对移动中的元素应用的过渡,以实现在我们添加或移除元素时,其它元素能有个平稳的过渡动画:
<style scoped>
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(50px);
}
.list-move, /* 对移动中的元素应用的过渡 */
.list-enter-active,
.list-leave-active {
transition: all 2s ease;
}
.list-leave-active {
position: absolute;
}
</style>
最后给 list-leave-active 的 position 设定为 absolute 是为了在移除元素时,其它元素无需等待被移除元素移除后再开始动画,否则被移除元素本身占着位置其它元素是无法移动过去的。
效果如下:
渐进延迟
结合 gsap 的 delay 属性与元素的 data 属性,我们可以做一个列表在一次性离开多个元素时,让每个元素是渐进离开的:
<script setup lang="ts">
import gsap from 'gsap'
import { ref } from 'vue'
const list = ref<number[]>([])
let num = 0
const handleAdd = () => {
list.value.unshift(num++)
}
const handleSubMul = () => {
list.value.splice(0, 3)
}
const onEnter = (el: Element, done: () => void) => {
gsap.to(el, {
duration: 2,
opacity: 1,
height: '1.5em',
onComplete: done
})
}
const onLeave = (el: Element, done: () => void) => {
const index = parseFloat((el as HTMLElement).dataset.index || '0')
gsap.to(el, {
opacity: 0,
height: 0,
delay: index * 0.5,
onComplete: done
})
}
const onBeforeEnter = (el: Element) => {
;(el as HTMLElement).style.opacity = '0'
;(el as HTMLElement).style.height = '0'
}
</script>
<template>
<transition-group
tag="ul"
@before-enter="onBeforeEnter"
@enter="onEnter"
@leave="onLeave"
>
<li v-for="(item, index) in list" :key="item" :data-index="index">
{{ item }}
</li>
</transition-group>
<button @click="handleAdd">添加</button>
<button @click="handleSubMul">减少多个</button>
</template>
我们在 <li> 上绑定了 :data-index="index",然后我们在 onLeave 中通过 el.dataset.index 获取绑定的值,然后配合 delay 实现:
无限视差滚动效果
最后,综合上面的知识点,配合对滚轮事件的监听,可以实现如下所示的无限视差滚动效果:
代码如下:
<script setup lang="ts">
import { onMounted, ref, useTemplateRef } from 'vue'
const list = ref([
{
text: '让心灵在青山绿水间悠然旅行',
img: 'https://picsum.photos/id/94/3000/2000'
},
{
text: '带你领略大自然的鬼斧神工',
img: 'https://picsum.photos/id/29/3000/2000'
},
{
text: '邂逅千年古城与浪漫小镇',
img: 'https://picsum.photos/id/61/3000/2000'
}
])
const divEl = useTemplateRef<HTMLDivElement>('wrap')
const cur = ref(0)
const isDown = ref(true) // 是否向下滑动滚轮
let isAnimate = false
onMounted(() => {
// https://developer.mozilla.org/zh-CN/docs/Web/API/Element/wheel_event
divEl.value?.addEventListener('wheel', e => {
e.preventDefault()
if (!e.deltaY || isAnimate) return
isAnimate = true
const length = list.value.length
if (e.deltaY > 0) {
// 向下滚:e.deltaY > 0
isDown.value = true
cur.value = cur.value === length - 1 ? 0 : cur.value + 1
} else {
// 向上滚
isDown.value = false
cur.value = cur.value === 0 ? length - 1 : cur.value - 1
}
})
})
function onAfterEnter() {
isAnimate = false
}
</script>
<template>
<div class="wrap" ref="wrap">
<transition-group name="item" @after-enter="onAfterEnter">
<template v-for="(item, index) in list" :key="item">
<div
class="item"
v-show="index === cur"
:class="[isDown ? 'down' : 'up']"
>
<transition name="msg" appear>
<h2 v-show="index === cur">{{ list[index].text }}</h2>
</transition>
<img :src="list[index].img" alt="" />
</div>
</template>
</transition-group>
</div>
</template>
<style>
* {
margin: 0;
}
body {
overflow: hidden;
}
.wrap {
height: 100vh;
overflow: hidden;
}
.item {
position: absolute;
left: 0;
width: 100%;
height: 100vh;
overflow: hidden;
}
.down {
top: 0;
}
.up {
bottom: 0;
}
.item h2 {
position: absolute;
top: 50%;
left: 50%;
width: 50%;
transform: translate(-50%, -50%);
font-size: 7vw;
color: aliceblue;
text-shadow: 2px 2px 16px black;
}
.item img {
width: 100%;
height: 100vh;
object-fit: cover;
}
.item-leave-to {
height: 0;
z-index: 1;
}
.down.item-enter-from {
opacity: 0;
transform: translateY(20%);
}
.up.item-enter-from {
opacity: 0;
transform: translateY(-20%);
}
.item-enter-active,
.item-leave-active {
transition: height 2s ease, transform 1s ease;
}
.msg-enter-from {
opacity: 0;
transform: translate(-55%, -50%) !important;
}
.msg-leave-to {
opacity: 0;
}
.msg-enter-active,
.msg-leave-active {
transition: opacity 2s ease-in, transform 0.5s ease 2s;
}
</style>
另外,我使用渐变色替换图片做背景,方便诸君在线调试: