LoginSignup
7

😡VueのTransition使えば簡単にトランジションできるって言ったじゃないですか!〜あるある沼と解決策〜

Posted at

:christmas_tree: この記事はVue Advent Calendar 2022の20日目(太平洋標準時)です :christmas_tree:

ご存じの通り、Vueではコンポーネントを使って簡単に要素の表示/非表示トランジションが書けます。
↓こんなやつですね。

<script setup lang="ts">
import { ref } from "vue";
const visible = ref(true);
</script>

<template>
  <label>
    <input type="checkbox" v-model="visible" />ボタンを表示
  </label>
  <Transition appear>
    <button v-if="visible">ボタンだよ</button>
  </Transition>
</template>

<style lang="scss" scoped>
.v-enter-active,
.v-leave-active {
  transition: opacity 2s;
}
.v-enter-from,
.v-leave-to {
  opacity: 0;
}
</style>

大体Vueの入門記事だと、こんな感じの例でTransition簡単!すごい!で終わっちゃうのだけど、ちょっと深みにハマると抜け出せないのがTransitionです。特に、非表示側(leave)のアニメーションには沼が多いです。

leaveアニメーションって、もうあとは消えるだけなので軽視されがちというか、微妙にちゃんと動いてない気がしても見て見ぬふりされがちな部分ですよね。

この記事ではVueのTransitionをちょっとだけ深掘りして、見て見ぬふりをしていた微妙な部分をクリアにして、気持ちよく🎍新年を迎えよう...そんな企画です。思い当たるフシある方はぜひお付き合いくださいませ。

あるある1: コンポーネントに<Transition>を書いたら消える時だけアニメーションしない

大体<Transition>を使って最初にハマるのが、自作のコンポーネントにいい感じに表示/非表示トランジションをつけようと思った時です(当社比)。

とりあえず何も考えずに表示/非表示のタイミングでいい感じにフェードイン/フェードアウトしてくれるボタンのコンポーネントを作りましょう。

TamaSan.vue
<script setup lang="ts"></script>

<template>
  <Transition appear>
    <div class="TamaSan"></div>
  </Transition>
</template>

<style lang="scss" scoped>
.TamaSan {
  position: relative;
  width: 100px;
  height: 150px;
  background-image: url("@/assets/tama.svg");
  background-size: contain;
  background-position: center bottom;
  background-repeat: no-repeat;
  animation: constant-rotate 4s linear infinite;
}

/* 定常アニメーション(常時回転) */
@keyframes constant-rotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

/* enter/leaveアニメーション */
.v-enter-active,
.v-leave-active {
  transition: opacity 2s, rotate 2s, scale 2s;
}
.v-enter-from {
  rotate: -100deg;
  scale: 0.5;
  opacity: 0;
}
.v-leave-to {
  rotate: 300deg;
  scale: 0.5;
  opacity: 0;
}
</style>

で、これを親コンポーネントから表示/非表示します。

App.vue
<script setup lang="ts">
import { ref } from "vue";
import TamaSan from "./demo0x/TamaSan.vue";
const visible = ref(true);
</script>

<template>
  <label>
    <input type="checkbox" v-model="visible" />
    たまさんコンポーネントを表示(v-if)
  </label>
  <div class="tama">
    <TamaSan v-if="visible" />
  </div>
</template>

これで動きそうな気するじゃないですか。ところがどっこい、このトランジションは半分しか動きません。表示の方はバッチリなのですが、非表示の方がトランジションせずに一瞬で消えます。

00.gif

POINT:トランジションは<Transition>が生きていないと再生されない

<Transition>の中身がトランジションするための絶対条件が「<Transition>自身が生存していること」です。トランジション中でも<Transition>自体が消えると中身も当然即座に消滅します。

わかってしまえば当たり前なのですが、結構見落としがちなので、一度きちんと声に出して理解しておきたい原則です:wink:

一番簡単な解決法はv-ifをv-showに変えてしまうことです。

v-showで非表示になってもコンポーネント自体はアンマウントされずに生きているので、「<Transition>自身が存在していること」の条件を満たせます

App.vueをv-showに変えた
<script setup lang="ts">
import { ref } from "vue";
import TamaSan from "./demo0x/TamaSan.vue";
const visible = ref(true);
</script>

<template>
  <label>
    <input type="checkbox" v-model="visible" />
    たまさんコンポーネントを表示(v-show)
  </label>
  <div class="tama">
    <TamaSan v-show="visible" />
  </div>
</template>

01.gif

v-showだと不要なコンポーネントの中身が残ってしまって嫌な場合は、コンポーネント自体をアンマウントするのではなく、コンポーネントに表示を制御するpropsを生やし、コンポーネントの中でv-ifを使うのもありです。

TamaSanSwitchable.vue
<script setup lang="ts">
withDefaults(
  defineProps<{
    /** 表示するか?非表示の場合コンポーネントの中身はアンマウントされます */
    visible?: boolean;
  }>(),
  {
    visible: true,
  }
);
</script>

<template>
  <Transition appear>
    <div class="TamaSan" v-if="visible"></div>
  </Transition>
</template>

<style scoped>/* 略*/</style>

あるある2:leaveトランジション中は中身が更新されない

これも結構な沼なのですが、Vueの<Transition>は表示(enter)時はトランジション中も中身が更新されるのですが、非表示(leave)時は更新が行われません。どういうことかは、↓の例を見るとわかります

この例では常に更新され続ける現在時刻を表示するコンポーネントを<Transition>で2秒かけて表示/非表示しているだけです。フェードイン時には時計が動いた状態で徐々に現れますが、フェードアウト時は時計の数字が止まった状態で消えていくことがわかります。

10.gif

類例として「アンマウント直前(onBeforeUnmount以降)にrefやreactiveを変更しても反映されない」沼もあります。

POINT: アンマウント時のアニメーション中はコンポーネントはすでにリアクティブではない

これは実際には問題というより、Vueの設計です。Vueではleaveトランジションの開始時点でDOMの内容は完全にフリーズされ、トランジションが何秒あってもトランジション中にDOMの中身は更新(再レンダリング)されません。実際にはこれはかなりありがたい機能で、「表示する内容がなくなったので<Transition>でフェードアウトしたい」みたいなケースでは、この挙動がないと(表示するものかなくなってしまったので)フェードアウトすら行うことができません。

これも基本的にはv-ifではなくv-showに変えれば解決できます。ただし、原因で書いた通り、これはこれで困ることもあるので使い分けが必要です。

  • :cat: leaveトランジション開始時の状態でDOMを固定したい場合→v-ifでトランジション(コンポーネントはアンマウントされる)
  • :dog: leaveトランジション中も最新の状態にDOMを更新し続けたい場合→v-showでトランジション(コンポーネントはアンマウントされずにそのまま残る)

▼ v-showを使ってleaveトランジション中もリアクティブを生かす例
11.gif

あるある3:もうめんどくさいから<Transition>使わないで全部自分でアニメーション書いてもいいですか?

<Transition>はきちんと使えば強力で便利なのですが、色々沼も深いので、無理に使わない選択肢もありだと思っています。

enter(appear)アニメーション

登場時のアニメーションは元々沼も深くないので<Transition>を使ってもさほどハマりどころはないのですが、その分<Transition>を使わなくても良いケースも多いです。

単純なenter(マウント時)アニメーションであれば、以下のように1回再生のanimationを指定すれば事足ります。

<style scoped>
.TamaButton {
  animation: appear 1s;
}

@keyframe {
  from { opacity: 0 }
  to { opacity: 1 }
}
</style>

leaveアニメーション

leave側は<Transition>とv-showを使うのが基本的にはハマりどころが少ないと思っているのですが、まれにアンマウント時に好きにアニメーションさせてくれ、って思うこともあります。

あまり真っ当な書き方ではないと思うのですが、下のようにonBeforeUnmountで要素を取得して、Web Animations APIなりGSAPなりで好きにアニメーションさせることもあります。前述した通りonBeforeUnmount以降、コンポーネントのDOMはリアクティブではなくなるので、好き勝手書き換えても基本的に安全です。

アンマウント時に自力でアニメーションするTimeStampClock.vue
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";
import { useTimestamp } from "./useTimestamp";

const { timestamp } = useTimestamp();
const elRef = ref<HTMLElement>();
onBeforeUnmount(() => {
  const el = elRef.value;
  if (!el) return;
  el.animate(
    [
      { opacity: 1, scale: 1 },
      { opacity: 0, scale: 0 },
    ],
    1000
  );
});
</script>

<template>
  <button ref="elRef">{{ timestamp }}</button>
</template>

ただし、これだけどアニメーション開始と同時にアンマウントされたDOMも破棄されてしまうので、破棄を遅らせるためだけに親側にdurationを指定した<Transition>を設置します。

App.vue
<script setup lang="ts">
import { ref } from "vue";
import TimeStampClock from "./components/TimeStampClock.vue";
const visible = ref(true);
</script>

<template>
  <label><input type="checkbox" v-model="visible" />時計を表示</label>
  <Transition :duration="1000">
      <TimeStampClock v-if="visible" />
  </Transition>
</template>

一見すると面倒なだけにも見えますが、ページ遷移のトランジションなど、一度にたくさんの子孫コンポーネントをトランジションつけながらアンマウントするようなケースでは使える方法です。次の例では、この方法で<TamaSans>コンポーネントの表示(マウント)/非表示(アンマウント)を切り替えています。

<script setup lang="ts">
import { ref } from "vue";
import TamaSans from "./demo2x/TamaSans.vue";
const visible = ref(true);
</script>

<template>
  <div class="DemoStage01">
    <label>
      <input type="checkbox" v-model="visible" />
      たまさんたちのコンポーネントを表示(v-if)
    </label>
    <div class="tama">
      <Transition :duration="2000">
        <TamaSans v-if="visible" />
      </Transition>
    </div>
  </div>
</template>

20.gif

<TamaSans>コンポーネントの中では(そのさらに先の子・孫コンポーネントまで含めて)アンマウント時に2秒の猶予が与えられることになるので、この範囲で各々自由にleaveアニメーションを実装することができるようになります。

あまり詳しくはないですが、ルーターに使えばNuxtのPageTransitionみたいなこともできると思います。

まとめ:Vueの<Transition>は便利だけど沼が深い

以上、Vueの<Transition>初心者がハマりがちな沼でした。
もうお分かりだとは思いますが、この沼にハマって四苦八苦したのは全部私です。それも割と結構最近...

<Transition>は便利だけど、実際結構ドキュメントに明記されていないことも多い奥の深い機能です。意外と何年使っててもわかっていないことも多かったりするので、たまに深掘ってみるのも良いですね:relaxed:

明日は @nishihara_zari さんです:crab:

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
7