トランジション — 出入りのアニメーションを宣言する
13 章は「どこへ描くか」と「いつ描くか」を扱いました。この章は「どう出入りさせるか」です。<Transition> は 1 つの要素の出入りに、<TransitionGroup> はリストの出入りと並べ替えにアニメーションを付けます。
どちらもアニメーションそのものは書きません。やるのは、決まったタイミングでクラスを付け外しし、JavaScript のフックを呼ぶことだけです。実際の動きは CSS か自分で書いたコードが担います。
この章で学ぶこと
- 6 つのクラスが付く順序と、「1 フレーム後」の実体
- Vue がトランジションの終わりを判定する仕組みと、それが外れる場面
- JavaScript のフックで
doneを待つかどうかが何で決まるか appearを付けたときにフックがどう変わるかTransitionGroupが並べ替えを追えるようになる条件- 他の組み込みコンポーネントと組んだときの見え方
出入りにクラスを配る
<Transition> は既定スロットの中身に enter / leave のアニメーションをかけます。公式ガイドが挙げる出入りのきっかけは 4 つです — v-if / v-show / <component :is> の切り替え / key の変更。
<script setup lang="ts">
import { ref } from 'vue'
const show = ref(true)
</script>
<template>
<button type="button" @click="show = !show">切り替え</button>
<Transition name="fade">
<p v-if="show" class="box">フェードする段落</p>
</Transition>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
name を付けるとクラスの接頭辞がその名前になります。書かなければ v-enter-from のように v が付きます。enter / leave のクラスは 6 つです (appear を使うときはさらに 3 つ増えます)。
| クラス | 付く時点 | 外れる時点 |
|---|---|---|
*-enter-from | 挿入の直前 | 挿入の 1 フレーム後 |
*-enter-active | 挿入の直前 | トランジションの終了時 |
*-enter-to | 挿入の 1 フレーム後 | トランジションの終了時 |
*-leave-from | leave が始まった直後 | 1 フレーム後 |
*-leave-active | leave が始まった直後 | トランジションの終了時 |
*-leave-to | leave の 1 フレーム後 | トランジションの終了時 |
*-active だけが最初から最後まで付いているので、transition の指定はここに書きます。
スロットに置けるのは要素かコンポーネント 1 つだけです。コンポーネントを置く場合、そのコンポーネント自身も単一のルート要素でなければなりません。
「1 フレーム後」の実体
実測すると、クラスの状態はこう動きます。
| 時点 | 付いているクラス |
|---|---|
| 挿入の直前 | fade-enter-from + fade-enter-active |
requestAnimationFrame を 2 回跨いだ後 | fade-enter-active + fade-enter-to |
| 終了後 | どちらも無い |
「1 フレーム後」は requestAnimationFrame の二重ネストです。
function nextFrame(cb) {
requestAnimationFrame(() => {
requestAnimationFrame(cb)
})
}
1 回では足りないのは、enter-from を付けた状態がブラウザに認識される前に外してしまうとトランジションが始まらないためです。自分でスタイルを触るコードを混ぜるときは、この 2 フレームの間に何をするかで結果が変わります。
クラス名を直接指定する props (enter-from-class など) もあります。空白区切りで複数のクラスを書けます。ユーティリティクラス中心の CSS と組むときに使います。
<Transition
enter-from-class="opacity-0 scale-90"
enter-active-class="transition-all duration-300"
enter-to-class="opacity-100 scale-100"
>
<p v-if="show">本文</p>
</Transition>
終わりをどう判定しているか
Vue は要素の computed style から transition と animation の delay + duration を読み、長い方を採ります。そのうえで transitionend か animationend を数えます。
timeout = Math.max(transitionTimeout, animationTimeout)
type = timeout > 0 ? (transitionTimeout > animationTimeout ? TRANSITION : ANIMATION) : null
引用したのは type を書かなかったときの分岐です。ここには癖があります。
- どちらも 0 なら
typeが null になり、その場で終了扱いになります。CSS を書き忘れた<Transition>は何もせず通り抜けます - 比較が厳密な不等号なので、同じ長さなら animation が勝ちます
type を書いた場合はその種類の duration だけを見ます。type="transition" と書いて animation しか付けていなければ、上と同じく「0 なので即終了」になります。
数えるイベントの側にも条件があります。その要素自身から出たものだけを数え、カンマ区切りの duration の個数に達したら終わりです。子要素から上がってきたイベントは数に入りません。
これが入れ子の要素をアニメーションさせたときに問題になります。内側から上がってきたイベントは数に入りません。 だから外側自身の transition が終わった時点で終了扱いになり、内側に長い transition-delay を付けても待ってくれません。外側に何も宣言していなければ、そもそも 0 と判定されて即終了です。
外側の transition を内側より長くすれば待てますが、それは内側の時間を外側へ写す手作業です。duration を明示すれば上書きできます。
<Transition :duration="550">...</Transition>
<Transition :duration="{ enter: 500, leave: 800 }">...</Transition>
duration を渡すと、その方向についてはイベントを一切聞かなくなります。指定した時間の setTimeout だけで終了を決めるので、実際のアニメーションが長引いてもクラスは外れます。オブジェクトで片方だけ書くと、書かなかった方は自動判定へ戻ります。
transition と animation を同じ要素にかけていると自動判定が当てになりません。この場合だけ type で明示します。
<Transition type="animation">...</Transition>
JavaScript で書く
CSS で書けないもの (要素の実寸に依存する高さのアニメーションなど) は JavaScript のフックで書きます。enter / leave のフックは 8 つあります (appear 用に 4 つ加わって全部で 12 個です)。公式ガイドは leave-cancelled を v-show 限定と説明しています。
JavaScript だけで書くなら :css="false" を添えます。クラスの付け外しと自動判定を丸ごと止めるので、CSS が事故で干渉することもなくなります。
<script setup lang="ts">
import { ref } from 'vue'
const show = ref(true)
function onEnter(el: Element, done: () => void) {
const box = el as HTMLElement
box.style.height = '0'
box.animate([{ height: '0' }, { height: `${box.scrollHeight}px` }], { duration: 300 })
.finished.then(() => {
box.style.height = ''
done()
})
}
function onLeave(el: Element, done: () => void) {
const box = el as HTMLElement
box.animate([{ height: `${box.scrollHeight}px` }, { height: '0' }], { duration: 300 })
.finished.then(done)
}
</script>
<template>
<Transition :css="false" @enter="onEnter" @leave="onLeave">
<div v-if="show" class="box">高さが変わる中身</div>
</Transition>
</template>
ここで押さえておくことが 1 つあります。done を待つかどうかは、フックが宣言した引数の数で決まります。
if (hook.length <= 1) {
done()
}
引数を 2 つ書けば、こちらが done() を呼ぶまで待ちます。1 つ以下だと待ちません。その先の挙動は css の設定で分かれます。
css | 引数 1 つ以下 | 引数 2 つ以上 |
|---|---|---|
false | Vue が即座に done() を呼ぶ | done() を呼ぶまで待つ |
| 既定 (有効) | CSS の終わりまで待つ | done() を呼ぶまで待つ (CSS の終わりも見ない) |
つまり :css="false" で (el) => { el.animate(...) } と書くとアニメーションの完了を待たずに次へ進み、どちらの設定でも (el, done) => { ... } で done() を呼び忘れるといつまでも終わりません。判定に使われるのは関数の length です。length は既定値を持つ引数とそれ以降を数えないので、(el, done = noop) => { ... } は 1 引数として扱われます。宣言だけ見て「done を受け取っている」と思っていると、待たずに先へ進みます。
初回描画にもかける
appear を付けると、最初に描かれるときもトランジションが走ります。
<Transition appear name="fade">
<p>最初から居る段落</p>
</Transition>
クラスもフックも、appear 用を書いていない分だけ enter 用へ落ちます。appear-from-class を書かなければ *-enter-from が使われ、@after-appear を書かなければ @after-enter が呼ばれます。
裏返すと、書いた分は enter 用を置き換えます。@before-appear と @appear を書くと、初回描画ではそちらだけが呼ばれ、@before-enter と @enter は呼ばれません。両方に処理を置いて両方走るつもりでいると、初回だけ片方が抜けます。
2 つの要素を入れ替える
v-if / v-else で 2 つの要素を切り替えると、既定では両方が同時に DOM にいます。重なりを避けるために position: absolute を使うのが定石ですが、それが取れない場合は mode で順番を付けられます。
<Transition mode="out-in" name="fade">
<p v-if="saved" class="saved">保存済み</p>
<p v-else class="editing">編集中</p>
</Transition>
out-in は古い方が抜けきってから新しい方を入れます。逆の in-out もありますが、公式ガイドも "much less frequently used" としています。
name は動的にできます。状態に応じてアニメーションを切り替えたいときに使います。
<Transition :name="direction === 'next' ? 'slide-left' : 'slide-right'" mode="out-in">
<p :key="page">{{ page }} ページ目</p>
</Transition>
key を振っているのは、同じ要素のまま中身だけ変わってもトランジションが起きないためです。key を変えると要素の入れ替えとして扱われます — この章の冒頭で挙げた 4 つのきっかけのうちの 1 つです。
リストの出入りと並べ替え
<TransitionGroup> はリストを扱います。<Transition> との主な違いはこの 3 つです。
- 既定ではラッパー要素を描きません。
tagを渡すとその要素で包みます - 中の要素は常に
keyが必須です。無いと<TransitionGroup> children must be keyed.の警告が出ますが、これは開発ビルドだけです。本番では何も言わずに並べ替えの追従が狂います modeが使えません。排他的な 2 つを入れ替えるわけではないためです
<script setup lang="ts">
import { ref } from 'vue'
interface Item {
id: number
name: string
}
let nextId = 3
const items = ref<Item[]>([
{ id: 1, name: 'りんご' },
{ id: 2, name: 'ばなな' },
])
function add() {
nextId += 1
items.value.push({ id: nextId, name: `果物 ${nextId}` })
}
function remove(id: number) {
items.value = items.value.filter((item) => item.id !== id)
}
</script>
<template>
<button type="button" @click="add">追加</button>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id" @click="remove(item.id)">
{{ item.name }}
</li>
</TransitionGroup>
</template>
<style scoped>
.list-enter-active,
.list-leave-active,
.list-move {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-leave-active {
position: absolute;
}
</style>
*-move が並べ替えに使われるクラスです。仕組みは FLIP で、更新後に旧位置と新位置の差を transform で打ち消してから、それを外してアニメーションさせます。
このクラスが transform の transition を宣言していないと、並べ替えのアニメーションは一切走りません。 判定のしかたも独特です。Vue は要素のクローンを作り、*-move を付けて display: none で一時的に DOM へ挿し、computed style の transition-property に transform か all が入っているかを見ます。.list-move { transition: transform 0.3s } を書き忘れると、この時点で全部が省かれます。
判定は前節の自動判定を type 抜きで通します。transition として選ばれることも条件なので、クローンに当たる animation が同じ長さ以上あると animation が勝ち、transform の transition を書いていても FLIP は省かれます。*-move に併記した場合だけでなく、要素そのものに付けた animation でも起こります。<TransitionGroup type="transition"> と書いてもこの判定には効きません。
.list-leave-active { position: absolute } を添えるのも定石です。抜けていく要素を通常フローから外さないと、残る要素の移動と重なって位置が飛びます。
他の組み込みと組むとき
<Transition> を <KeepAlive> や 13 章の <Suspense>、Vue Router の <RouterView> と組むときは入れ子の順序が決まっています。公式ガイドの Suspense のページが示す形はこれです。
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Transition mode="out-in">
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>読み込み中</template>
</Suspense>
</KeepAlive>
</Transition>
</template>
</RouterView>
この形は実測でも動きます。測って分かった注意を 2 つ挙げます。
ライフサイクルフックは境界の解決より後に来ます。 初回は pending → fallback → resolve → onMounted → onActivated の順でした。Suspense がポストフラッシュの処理を解決まで遅らせるためです。
差し替えの間も前の中身が残ります。 13 章で見た Suspense の挙動 (差し替えでは前の中身を出したまま待つ) が、mode="out-in" を挟んでも変わりません。古い方を外すのは境界が resolve したときで、pending の間は触られないためです。実測した順序は pending → (新しい方の解決) → resolve → 古い方の onDeactivated → 新しい方の onMounted でした。この経路で fallback は出ません。差し替えのときに timeout を書いていないためです。
まとめ
<Transition>はアニメーションを書きません。決まったタイミングでクラスを付け外しし、JavaScript のフックを呼ぶだけです- 公式ガイドが挙げる出入りのきっかけは
v-if/v-show/<component :is>の切り替え /keyの変更の 4 つ。スロットに置けるのは要素かコンポーネント 1 つだけです - enter / leave のクラスは 6 つ。
*-activeだけが最初から最後まで付いているのでtransitionはそこに書きます。nameで接頭辞が変わり、書かなければvです - 「1 フレーム後」の実体は
requestAnimationFrameの二重ネストです。1 回ではenter-fromの状態がブラウザに認識される前に外れてしまいます - クラス名の props は空白区切りで複数書けます
- 終わりの判定は computed style の delay + duration です。
typeを書かなければ長い方が採られ、どちらも 0 なら何もせず通り抜け、同じ長さなら animation が勝ちます。数えるのはその要素自身から出たイベントだけで、カンマ区切りの個数に達するまで待ちます。内側の要素から上がってきたイベントは数に入りません durationを渡すと、その方向についてはイベントを一切聞かずsetTimeoutだけで終了を決めます。オブジェクトで片方だけ書くと、書かなかった方は自動判定へ戻ります- transition と animation を同じ要素にかけたときは
typeで明示します。ただしTransitionGroupの*-moveの判定には効きません - enter / leave の JS フックは 8 つ (
appear用が 4 つ加わって全部で 12 個)。doneを待つかどうかはフックが宣言した引数の数で決まります。2 つ以上なら呼ばれるまで待ち、1 つ以下なら:css="false"では即座にdone()、CSS が有効なら CSS の終わりまで待ちます。lengthは既定値付きの引数を数えないので(el, done = noop) => {}は 1 引数扱いです :css="false"はクラスの付け外しと自動判定を丸ごと止めますappearはクラスもフックも書いていない分だけ enter 側へ落ちます。書いた分は enter 用を置き換えるので、@appearを書くと初回描画で@enterは呼ばれませんmodeを書かなければ新旧が同時に DOM にいます。out-inは古い方が抜けきってから新しい方を入れます。nameは動的にできます。中身だけが変わる要素はkeyを振らないとトランジションが起きません<TransitionGroup>はラッパーを描かず (tagで指定)、keyが必須で (警告は開発ビルドだけ)、modeが使えません- 並べ替えは
*-moveの FLIP です。このクラスがtransformの transition を宣言していて、かつtype抜きの自動判定でtransitionが選ばれないと一切走りません。判定はクローンを一時的に DOM へ挿して computed style を読む形で行われます - 他の組み込みと組む順序は
RouterView>Transition>KeepAlive>Suspense。ライフサイクルは境界の解決より後に来ます。差し替えの間はmode="out-in"を挟んでも前の中身が残り、古い方が外れるのは境界がresolveしたときです
- Vue — Transition — クラス / フック /
mode/durationの仕様 - Vue — TransitionGroup —
tag/key/*-moveの仕様 - Vue — Built-in Components — props とイベントの一覧
次に読む
- Vue Router 基礎 — 画面の切り替え
- ナビゲーションガード — 遷移を止める / 逸らす