过渡与动画
进入过渡和离开过渡
在插入、更新或从DOM移除项时,Vue提供了多种方法实现转换效果:
- 自动为CSS过渡和动画应用class
- 集成第三方CSS动画库,例如 Animate.css | A cross-browser library of CSS animations.
- 在过渡钩子期间使用 JS 直接操作 DOM
- 集成第三方 JS 动画库
单个元素或组件的过渡
Vue提供了 transition 封装组件(内置组件),在下列情形下,可以给任何元素或者组件添加进入或离开过渡效果:
- 使用 v-if 条件渲染
- 使用 v-show 条件展示
- 动态组件
- 组件根节点
下面是一个条件渲染淡入淡出的例子
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
<div id="app">
<button @click="show = !show">切换</button>
<transition name="fade">
<p v-if="show">你好</p>
</transition>
</div>
<script>
const { createApp } = Vue
const app = createApp({
data() {
return {
show: true
}
}
})
const vm = app.mount('#app')
</script>
我们<p v-if="...">...</p> 条件渲染元素封装在 transition 内置组件中,当插入或删除该元素时,Vue将做以下处理:
- 自动嗅探目标元素是否应用了 CSS 过渡或动画,如果是,在恰当的时机添加或删除 CSS 类名
- 如果过渡组件提供了 JS 钩子函数,这些钩子将在恰当的时机被调用
- 如果既没有 JS钩子 也没有 CSS 过渡或动画,DOM插入或删除在下一帧立即执行
我们的例子中是上述情形1,内置组件 transition 本身不会渲染新的 DOM,它是把合适的 CSS类应用到了被包裹的元素(或组件这样的自定义元素)上。transition组件name属性为fade,从而自动拓展为 .fade-enter-from,.fade-enter-active,.fade-enter-to,.fade-leave-from,.fade-leave-active,.fade-leave-to 等6个 CSS过渡类(如果name属性值没有指定,则其值为v)。实现进入过渡是设置好前三个CSS类,而离开过渡是后三个CSS类。
(上图引用自 Vue官网)
CSS动画也是使用 transition 组件,只是设置CSS类时,不是设置 transition,而是设置 animation,并且 v-enter-from 类在节点插入 DOM 后不会立刻移除,直到 animationend 事件触发时才移除。
前面说的6个CSS过渡类的名字是可以自定义的,方法是在 transition 组件的属性 enter-from-class,enter-active-class,enter-to-class,leave-from-class,leave-active-class,leave-to-class 中进行覆盖。这么做的好处是可以把 Vue 的过渡机制和一些现成的 CSS动画库结合起来,自己就不用费神编写过渡类的CSS了,例如,在前面的代码中我们可以引入 Animate.css
<link
href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.0/animate.min.css"
rel="stylesheet"
type="text/css"
/>
然后,去掉自己编写的 <style> 内的4个类,将 transition 组件改成
<transition name="custom-classes-transition"
enter-active-class="animate__animated animate__tada"
leave-active-class="animate__animated animate__bounceOutRight">
<p v-if="show">你好</p>
</transition>
这样就能完成过渡或动画效果了。
transition组件的 type属性可以区分使用的是动画还是过渡,duration属性可以自定义过渡持续的时间。transition组件还提供了 before-enter、enter、after-enter……等JS钩子,可以把transition组件的 css属性设置为 false(:css="false"),不使用CSS过渡类,然后在钩子中实现 JS动画(不过 enter 和 leave 钩子中必须使用 done 回调),调用 GreenSock 那样的动画库会省事。
初始渲染的过渡
给 transition 组件设置 appear属性(默认值false)就能获得初始渲染过渡效果
<transition appear>
<!-- ... -->
</transition>
多个元素之间的过渡
transition组件包裹的,可以是 v-if...v-else-if....v-else 那样的多个中选一个的元素,也可以是动态可变的一个元素(使用:key属性)。在多个元素的过渡切换中,我们有时希望新元素先进行进入过渡,完成之后老元素过渡离开(称为in-out模式),更多时候希望老元素先进行离开过渡,完成之后新元素过渡进入(称为out-in模式),我们可以用 transition组件的 mode属性指定要使用哪一种模式。
多个组件之间的过渡
组件之间的过渡更简单,只要让 transition组件包裹一个动态组件(使用<component>和:is属性)
我们把以前的动态组件例子(3个tab页)改一改
<transition name="fade" mode="out-in">
<keep-alive>
<component :is="currentTabComponent" class="tab"></component>
</keep-alive>
</transition>
然后添加相关的过渡类
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
这样就能实现多个组件的过渡了。(注意,keep-alive也要包裹在transition组件内,因为缓存的东西要一块儿过渡切换)
列表过渡
前面提到的过渡,或者是单个节点,或者是多个节点,但每次只渲染一个。这里需要考虑同时渲染整个列表的情形,这也是 <transition-group> 组件的用武之地,<transition-group>有下述特点:
- 默认情况下,它不会渲染包裹的一个元素,但你可以用tag属性指定渲染一个元素
- 过渡模式不可用,因为这里没有切换特定的一个的概念了
- 内部元素总是需要提供唯一的 key 属性值进行标识
- CSS过渡类应用在内部的元素中,而不是这个组(容器)本身
列表的进入/离开过渡
<body>
<div id="app">
<button @click="add">添加</button>
<button @click="remove">移除</button>
<transition-group name="list" tag="p">
<span v-for="item in items" :key="item" class="list-item">
{{ item }}
</span>
</transition-group>
</div>
<script>
const { createApp } = Vue
const app = createApp({
data() {
return {
items: [1, 2, 3, 4, 5, 6, 7, 8, 9],
nextNum: 10
}
},
methods: {
randomIndex() {
return Math.floor(Math.random() * this.items.length)
},
add() {
this.items.splice(this.randomIndex(), 0, this.nextNum++)
},
remove() {
this.items.splice(this.randomIndex(), 1)
}
}
})
const vm = app.mount('#app')
</script>
列表 name 属性 list,tag属性值p表示指定渲染一个<p>作为容器,里面的每个 span 都包含了 :key 属性唯一识别,列表的进入和离开过渡通过CSS指定:
.list-item {
display: inline-block;
margin-right: 10px;
}
.list-enter-active,
.list-leave-active {
transition: all 1s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(30px);
}
这个例子中,新增的元素进入,或者被删的元素离开,都非常平滑,但插入或者移除时,周围元素的移动显得突兀,这涉及列表的移动过渡
列表的移动过渡
<transition-group> 组件除了进入和离开的一些CSS类(.XXX-enter-from,.XXX-enter-active,.XXX-enter-to,.XXX-leave-from,.XXX-leave-active,.XXX-leave-to),还有 .XXX-move 类,它会应用在元素改变定位的过程中。(前缀XXX可以通过name属性定义,也可以通过 move-class 属性手动设置)
修改CSS,使得插入和移除时,左右移动都是平滑的
.list-item {
display: inline-block;
margin-right: 10px;
transition: all 0.8s ease; /* 移除时平滑移动 */
}
/* .list-enter-active,
.list-leave-active {
transition: all 1s ease;
} */
.list-leave-active {
position: absolute; /* 移除时平滑移动 */
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(30px);
}
.list-move {
transition: transform 0.8s ease; /* 插入时平滑移动 */
}
官网还给出列表中块元素 shuffle 时的平滑移动例子 (列表过渡 | Vue.js)
列表的交错过渡
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.3.4/gsap.min.js"></script>
<div id="app">
<input v-model="query" />
<transition-group
name="staggered-fade"
tag="ul"
:css="false"
@before-enter="beforeEnter"
@enter="enter"
@leave="leave"
>
<li
v-for="(item, index) in computedList"
:key="item.msg"
:data-index="index"
>
{{ item.msg }}
</li>
</transition-group>
</div>
<script>
const { createApp } = Vue
const app = createApp({
data() {
return {
query: '',
list: [
{ msg: '张三' },
{ msg: '李四' },
{ msg: '王五' },
{ msg: '李六' },
{ msg: '张七' }
]
}
},
computed: {
computedList() {
var vm = this
return this.list.filter(item => {
return item.msg.indexOf(vm.query) !== -1
})
}
},
methods: {
beforeEnter(el) {
el.style.opacity = 0
el.style.height = 0
},
enter(el, done) {
gsap.to(el, {
opacity: 1,
height: '1.6em',
delay: el.dataset.index * 0.15,
onComplete: done
})
},
leave(el, done) {
gsap.to(el, {
opacity: 0,
height: 0,
delay: el.dataset.index * 0.15,
onComplete: done
})
}
}
})
const vm = app.mount('#app')
</script>
计算属性 computedList 是依赖 query的,所以,(双向绑定的)query 输入值一改,computedList 就重新计算,列表就刷新了。交错过渡效果中,使用了强悍的 gsap JS动画库(https://greensock.com/)。
可复用的过渡
Vue的组件系统可以实现过渡效果的复用,也就是把过渡效果做成组件。要创建一个可以复用的过渡组件,只要将 <transition> 或 <transition-group> 作为根组件,然后将任何子组件放置其中就可以了。我们把上述例子改一改,把过渡效果做成 <my-transition-group> 组件:
<div id="app">
<input v-model="query" />
<my-transition-group>
<li v-for="(item, index) in computedList" :key="item.msg" :data-index="index">
{{ item.msg }}
</li>
</my-transition-group>
</div>
<script>
const { createApp } = Vue
const app = createApp({
data() {
return {
query: '',
list: [
{ msg: '张三' },
{ msg: '李四' },
{ msg: '王五' },
{ msg: '李六' },
{ msg: '张七' }
]
}
},
computed: {
computedList() {
var vm = this
return this.list.filter(item => {
return item.msg.indexOf(vm.query) !== -1
})
}
},
})
app.component('my-transition-group', {
template: '\
<transition-group\
name="staggered-fade"\
tag="ul"\
:css="false"\
@before-enter="beforeEnter"\
@enter="enter"\
@leave="leave"\
>\
<slot></slot>\
</transition-group>\
',
methods: {
beforeEnter(el) {
el.style.opacity = 0
el.style.height = 0
},
enter(el, done) {
gsap.to(el, {
opacity: 1,
height: '1.6em',
delay: el.dataset.index * 0.15,
onComplete: done
})
},
leave(el, done) {
gsap.to(el, {
opacity: 0,
height: 0,
delay: el.dataset.index * 0.15,
onComplete: done
})
}
}
})
const vm = app.mount('#app')
</script>
上面的代码中,组件 <my-transition-group> 模板中把 <transition-group> 作为根,用插槽 <slot> 来容纳子组件。对于可复用的过渡组件,使用函数式组件更合适:
这部分还没搞定(test42.html)
动态过渡
<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.2.3/velocity.min.js"></script>
<div id="app">
淡入:<input type="range" v-model="fadeInDuration" min="0" :max="maxFadeDuration" />
淡出:<input type="range" v-model="fadeOutDuration" min="0" :max="maxFadeDuration" />
<transition :css="false" @before-enter="beforeEnter" @enter="enter" @leave="leave">
<p v-if="show">你好</p>
</transition>
<button v-if="stop" @click="stop = false; show = false">
动画开始
</button>
<button v-else @click="stop = true">停止动画!</button>
</div>
<script>
const { createApp } = Vue
const app = createApp({
data() {
return {
show: true,
fadeInDuration: 1000,
fadeOutDuration: 1000,
maxFadeDuration: 1500,
stop: true
}
},
mounted() {
this.show = false
},
methods: {
beforeEnter(el) {
el.style.opacity = 0
},
enter(el, done) {
var vm = this
Velocity(
el,
{ opacity: 1 },
{
duration: this.fadeInDuration,
complete: function () {
done()
if (!vm.stop) vm.show = false
}
}
)
},
leave(el, done) {
var vm = this
Velocity(
el,
{ opacity: 0 },
{
duration: this.fadeOutDuration,
complete: function () {
done()
vm.show = true
}
}
)
}
}
})
const vm = app.mount('#app')
</script>
动态过渡的一般用法是某个 attribute (最基础的是 name属性) 绑定动态的值,而上面的代码是利用事件钩子和 velocity JS动画库来实现的淡入淡出。
状态过渡
状态动画与侦听器
通过侦听器(watcher)能监听到任何数值 property 的更新,即监听vm的数据变量的变化。下面的例子,用侦听器监听 vm.number 的变化,把 tweenedNumber 动画方式修改为新值,而显示的 animatedNumber 是依赖 tweenedNumber 的计算属性,从而数值的状态变化就产生动画效果。
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.2.4/gsap.min.js"></script>
<div id="app">
<input v-model.number="number" type="number" step="20" />
<p>{{ animatedNumber }}</p>
</div>
<script>
const App = {
data() {
return {
number: 0,
tweenedNumber: 0
}
},
computed: {
animatedNumber() {
return this.tweenedNumber.toFixed(0)
}
},
watch: {
number(newValue) {
gsap.to(this.$data, { duration: 0.5, tweenedNumber: newValue })
}
}
}
Vue.createApp(App).mount('#app')
</script>
动态状态过渡
下面的例子,用SVG 绘制多边形,侦听器监听多边形有关变量,更新时用了状态过渡,实现了动态的过渡效果。
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.2.4/gsap.min.js"></script>
<div id="app">
<svg width="200" height="200">
<polygon :points="points"></polygon>
<circle cx="100" cy="100" r="90"></circle>
</svg>
<label>边数: {{ sides }}</label>
<input type="range" min="3" max="500" v-model.number="sides" />
<label>最小半径: {{ minRadius }}%</label>
<input type="range" min="0" max="90" v-model.number="minRadius" />
<label>更新间隔: {{ updateInterval }} 毫秒</label>
<input type="range" min="10" max="2000" v-model.number="updateInterval" />
</div>
<script>
const defaultSides = 10;
const stats = Array.apply(null, { length: defaultSides }).map(() => 100);
const App = {
data() {
return {
stats: stats,
points: generatePoints(stats),
sides: defaultSides,
minRadius: 50,
interval: null,
updateInterval: 500
};
},
watch: {
sides(newSides, oldSides) {
var sidesDifference = newSides - oldSides;
if (sidesDifference > 0) {
for (var i = 1; i <= sidesDifference; i++) {
this.stats.push(this.newRandomValue());
}
} else {
var absoluteSidesDifference = Math.abs(sidesDifference);
for (var i = 1; i <= absoluteSidesDifference; i++) {
this.stats.shift();
}
}
},
stats(newStats) {
gsap.to(this.$data, this.updateInterval / 1000, {
points: generatePoints(newStats)
});
},
updateInterval() {
this.resetInterval();
}
},
mounted() {
this.resetInterval();
},
methods: {
randomizeStats() {
var vm = this;
this.stats = this.stats.map(() => vm.newRandomValue());
},
newRandomValue() {
return Math.ceil(this.minRadius + Math.random() * (100 - this.minRadius));
},
resetInterval() {
var vm = this;
clearInterval(this.interval);
this.randomizeStats();
this.interval = setInterval(() => {
vm.randomizeStats();
}, this.updateInterval);
}
}
}
Vue.createApp(App).mount('#app')
function valueToPoint(value, index, total) {
var x = 0;
var y = -value * 0.9;
var angle = ((Math.PI * 2) / total) * index;
var cos = Math.cos(angle);
var sin = Math.sin(angle);
var tx = x * cos - y * sin + 100;
var ty = x * sin + y * cos + 100;
return { x: tx, y: ty };
}
function generatePoints(stats) {
var total = stats.length;
return stats.map(function (stat, index) {
var point = valueToPoint(stat, index, total);
return point.x + "," + point.y;
}).join(" ");
}
</script>
把过渡放到组件里
前面的整数动画效果是针对一个数字的,如果页面有多个数字,都去管理状态,会非常复杂,幸好很多的动画效果都可以提取到专门的子组件中,用组件复用来实现动画效果复用。
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.2.4/gsap.min.js"></script>
<div id="app">
<input v-model.number="firstNumber" type="number" step="20" /> +
<input v-model.number="secondNumber" type="number" step="20" /> = {{ result }}
<p>
<animated-integer :value="firstNumber"></animated-integer> +
<animated-integer :value="secondNumber"></animated-integer> =
<animated-integer :value="result"></animated-integer>
</p>
</div>
<script>
const App = {
data() {
return {
firstNumber: 20,
secondNumber: 40
}
},
computed: {
result() {
return this.firstNumber + this.secondNumber
}
}
}
const app = Vue.createApp(App)
app.component('animated-integer', {
template: '<span>{{ fullValue }}</span>',
props: {
value: {
type: Number,
required: true
}
},
data() {
return {
tweeningValue: 0
}
},
computed: {
fullValue() {
return Math.floor(this.tweeningValue)
}
},
methods: {
tween(newValue, oldValue) {
gsap.to(this.$data, {
duration: 0.5,
tweeningValue: newValue,
ease: 'sine'
})
}
},
watch: {
value(newValue, oldValue) {
this.tween(newValue, oldValue)
}
},
mounted() {
this.tween(this.value, 0)
}
})
app.mount('#app')
</script>