Katashin .info

シームレスな画面遷移アニメーションの Vue Router を使った実装パターン

以下のようにシームレスな(繋ぎ目のない)画面遷移を実装するとしたら、どのように実装しますか?それぞれのカードをクリックするとカードが拡大され、そのコンテンツ全体が見れるようになります。

この実装はカード一覧ページと、コンテンツ全体が見れるページの2ページで構成されていて、画面遷移の処理を工夫することで、シームレスな見せ方をしています。

シームレスな画面遷移は、要素同士の関連性を強調したり、アプリへの没入感を高めるなどのメリットがありますが、実装難易度が高く、コードのメンテナビリティが下がりやすいというデメリットもあります。

本記事では、このようなシームレスな画面遷移を実装する難しさを説明してから、Vue.js を使った実際のコードを交えながらその実装方法について解説します。

シームレスな画面遷移アニメーションの難しさ #

シームレスな画面遷移アニメーションの実装には以下の難しさがあります。アニメーションの実装自体の難しさもありますが、メンテナビリティとのバランスも考慮すべき点です。

コードが密結合してメンテナビリティが下がる #

画面遷移をシームレスにしようとすると、コードが密結合しやすいです。素朴に実装を進めると、画面遷移前の要素をアニメーションさせ、それをそのまま遷移後でも使い、2つの画面が1つの大きなコードの塊となってしまいます。メンテナビリティを上げるためにも、異なる画面は別ページとして実装したいです。

別ページにすると見た目をシームレスにするのが難しい #

コードの密結合を避けるために各画面を別々のページとして実装すると、見た目をシームレスに繋ぐことが難しくなります。素朴に密結合したコードであれば1つの要素を画面遷移の前後で使い回せるため、実装は簡単になります。しかし、別ページにすると、異なる要素をシームレスに見えるように工夫してコードを書く必要があります。

シームレスな画面遷移アニメーションの実装 #

今回の実装ではメンテナビリティを考慮して、各画面を別々のページとして実装します。Vue Router を使用して各ページを管理し、シームレスな画面遷移にするための工夫を実装に入れます。

Home と Document コンポーネントを定義し、それぞれが個別のページになります。Home は store に保存されている documents 配列の一覧を表示するページで、Document は URL で指定された ID に対応するドキュメントの詳細を表示するページです。

Home コンポーネント(Home.vue)
<!-- Home.vue -->
<script setup>
const route = useRoute()
const router = useRouter()

// ドキュメントの一覧をストアから取得
const documents = computed(() => store.documents)

function onClick(id) {
  // クリックされたドキュメントの詳細ページ(Document)へ遷移
  router.push(`/${id}`)
}
</script>

<template>
<ul class="document-list">
  <li class="document-item" v-for="document in documents" :key="document.id">
    <router-link
      class="document-button"
      :to="'/' + document.id"
      @click="onClick(document.id, $event)"
    >
      {{ document.body }}
    </router-link>
  </li>
</ul>
</template>

<style scoped>
.document-list {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: 20px;
  padding: 20px;
  margin: 0;
  width: 100%;
  height: 100%;
  list-style: none;
}

.document-item {
  flex: none;
  height: 200px;
  width: 150px;
}

.document-button {
  display: flex;
  align-items: flex-start;
  justify-content: flex-start;
  overflow: hidden;
  padding: 12px;
  width: 100%;
  height: 100%;
  background-color: #fff;
  color: inherit;
  text-decoration: none;
  box-shadow: 0 0 20px 5px rgba(0, 0, 0, 0.1);
  transition: 0.6s cubic-bezier(0.65, 0, 0.35, 1);
}
</style>
Document コンポーネント(Document.vue)
<!-- Document.vue -->
<script setup>
const router = useRouter()
const route = useRoute()

// URL で指定されたドキュメントをストアから取得
const document = computed(() => {
  return store.documents.find((doc) => doc.id === route.params.id)
})

function onClose() {
  // Home へ遷移
  router.push('/')
}
</script>

<template>
<article class="document-body">
  <header class="document-header">
    <!-- Home へと戻るボタン -->
    <router-link class="document-close" to="/" @click="onClose">
      戻る
    </router-link>
  </header>
  <!-- ドキュメントの本文 -->
  {{ document.body }}
</article>
</template>

<style scoped>
.document-body {
  overflow: auto;
  position: absolute;
  left: 5%;
  top: 5%;
  padding: 50px 20px 20px 20px;
  height: 90%;
  width: 90%;
  background-color: #fff;
  text-align: left;
  box-shadow: 0 0 20px 5px rgba(0, 0, 0, 0.1);
  transition: 0.6s cubic-bezier(0.65, 0, 0.35, 1);
}

.document-header {
  display: flex;
  align-items: center;
  position: absolute;
  top: 10px;
  left: 20px;
  right: 20px;
  height: 40px;
  transition: 0.6s cubic-bezier(0.65, 0, 0.35, 1);
}

.document-close {
  color: #0b12e3;
}
</style>
Vue Router の設定
const routes = [
  {
    path: '/',
    component: Home,
  },
  {
    path: '/:id',
    component: Document,
  },
]

このコードを実行すると以下のように動作します。アニメーションの実装前なので、即座に画面が切り替わります。

元の要素のスタイルを遷移後のページに送る #

画面遷移をシームレスにするために、まずは遷移後の要素を遷移前の見た目と同じにします。今回の例では、Home でクリックされたカードのスタイルを、遷移後の要素にも適用し、見た目を同じにします。

onClick でクリックされた要素をとり、getBoundingClientRect で座標と大きさを取得します。取得した値を router.push による画面遷移で query として渡し、遷移後の画面で利用できるようにします。

// Home.vue の <script> 内の onClick を書きかえる
function onClick(id, event) {
  const target = event.currentTarget

  // クリックされた要素の座標と大きさを取得
  const bounds = target.getBoundingClientRect()

  // クリックされた要素のスタイルを保存
  // padding も Document の要素とは異なるので保存しておく
  const style = {
    left: bounds.left,
    top: bounds.top,
    width: `${bounds.width}px`,
    height: `${bounds.height}px`,
    padding: window.getComputedStyle(target).padding,
  }

  router.push({
    path: `/${id}`,

    // query 経由でスタイルを送る
    query: style,
  })
}

遷移後の要素にスタイルを適用し、見た目を一致させる #

遷移後の画面では onMounted フック内で route.query に渡された座標と大きさの値を取得します。これを appearStyle に保存し、要素のスタイルとして適用します。スタイルには transition: none も含めることで、この段階では CSS トランジションが実行されないようにします。ユーザーに URL query を見られないように、router.replace を実行して query を削除します。

// Document.vue の <script> 内に追加
const appearStyle = ref(null)
const documentBody = shallowRef(null)

onMounted(() => {
  if (route.query.left) {
    // 遷移先の要素の座標を取得
    const bodyBounds = documentBody.value.getBoundingClientRect()

    // query から遷移前の要素の座標を数値で取得
    const left = Number(route.query.left)
    const top = Number(route.query.top)

    // 適用先の要素の座標との差分を取って、遷移前の要素と同じ座標になるように translate を設定
    const translate = `${left - bodyBounds.left}px ${top - bodyBounds.top}px`

    // 要素に適用するスタイルを設定
    appearStyle.value = {
      translate,
      width: route.query.width,
      height: route.query.height,
      padding: route.query.padding,

      // transition: none を設定することで、appearStyle の適用時にはアニメーションしないようにする
      transition: 'none',
    }

    // query を URL から削除
    router.replace({
      query: null,
    })
  }
})

テンプレート側では appearStyle をルートの要素に適用します。

<!-- Document.vue の <template> を書きかえる -->
<!-- appearStyle をルートの要素に適用する -->
<article ref="documentBody" class="document-body" :style="appearStyle">
  <header class="document-header">
    <router-link class="document-close" to="/" @click="onClose">
      戻る
    </router-link>
  </header>
  {{ document.body }}
</article>

ここまでのコードを動かすと以下の通りです。画面遷移後の要素が、遷移前の要素と見た目が一致しているのがわかります。

スタイルを削除してトランジションを実行する #

画面遷移の繋ぎ目がシームレスになったので、次は遷移後の元々のスタイルへとアニメーションさせます。requestAnimationFrame で appearStyle が描画されるのを待ち、描画された後にその値を削除します。これにより、CSS トランジションで元々のスタイルまでアニメーションします

// 先ほどの onMounted を更新
const appearStyle = ref(null)
const documentBody = shallowRef(null)

onMounted(() => {
  if (route.query.left) {
    // 遷移先の要素の座標を取得
    const bodyBounds = documentBody.value.getBoundingClientRect()

    // query から遷移前の要素の座標を数値で取得
    const left = Number(route.query.left)
    const top = Number(route.query.top)

    // 適用先の要素の座標との差分を取って、遷移前の要素と同じ座標になるように translate を設定
    const translate = `${left - bodyBounds.left}px ${top - bodyBounds.top}px`

    // 要素に適用するスタイルを設定
    appearStyle.value = {
      translate,
      width: route.query.width,
      height: route.query.height,
      padding: route.query.padding,

      // transition: none を設定することで、appearStyle の適用時にはアニメーションしないようにする
      transition: 'none',
    }

    // query を URL から削除
    router.replace({
      query: null,
    })

    // (追加)appearStyle を適用し、描画されるまで待つ
    requestAnimationFrame(() => {
      // スタイルを削除することで、遷移前のスタイルから遷移後のスタイルにアニメーションする
      appearStyle.value = null
    })
  }
})

ここまでのコードを繋ぎ合わせると、画面遷移がシームレスなアニメーションになっていることがわかります。

対象の要素以外のアニメーションを設定 #

ここまでの実装で、クリックされたカードはシームレスにアニメーションするようになりましたが、それ以外はまだ突然出現したり消えたりしています。具体的には、Home でクリックされなかったカードは即座に消えてしまいますし、遷移後の画面の「戻る」ボタンも突然出現しています。

これらにもアニメーションを付けるために、<Transition> を使ってページ全体に CSS トランジションを設定できるようにします。

<div id="app">
  <!-- <transition> でページ全体をアニメーションの対象にする -->
  <router-view v-slot="{ Component }">
    <transition>
      <component :is="Component"></component>
    </transition>
  </router-view>
</div>

そして、Home では遷移時にページ全体をフェードアウトします。

/* Home.vue の <style> に追加 */
.document-list.v-leave-active {
  /* ページを離れる時にアニメーションさせる */
  transition: 0.6s cubic-bezier(0.65, 0, 0.35, 1);
}

.document-list.v-leave-to {
  /* フェードアウトするようにスタイルを指定 */
  opacity: 0;
}

Document では「戻る」ボタンを含む .document-header に対して、ページ遷移時に appearing クラスを付与し、フェードインで表示します。これは appearStyle と同様のことを行っていて、遷移直後の見た目を appearing クラスで設定した後、そのクラスを削除して CSS トランジションを実行します。

<!-- Document.vue の <template> を書きかえる -->
<article ref="documentBody" class="document-body" :style="appearStyle">
  <!-- appearStyle が設定されている間、「戻る」ボタンを含む要素にも appearing クラスが指定される -->
  <header class="document-header" :class="{ appearing: appearStyle }">
    <router-link class="document-close" to="/" @click="onClose">
      戻る
    </router-link>
  </header>
  {{ document.body }}
</article>
/* Document.vue の <style> に追加 */
.document-body.v-leave-active {
  /* Document ページは全体へのアニメーションは不要なので無効化 */
  transition: none;
}

.document-header.appearing {
  /* 「戻る」ボタンの初期スタイルを透明にし、フェードインアニメーションを行う */
  opacity: 0;
  transition: none;
}

上記を適用すると以下のように、Home でクリックされなかったカードと、Document の「戻る」ボタンがシームレスになったのがわかります。

戻る画面遷移にもアニメーションを付ける #

次は Document から Home へ戻る画面遷移にもアニメーションを付けます。実装の考え方は Home から Document の画面遷移と同じです。まず、「戻る」ボタンが押された時に、要素のスタイルを query 経由で Home へ送ります。ここで重要なのは、Document で開かれているドキュメントの ID も fromId として送っている点です。fromId によって Home 内のどのカードに対してアニメーションをするかを特定できます。

// Document.vue の onClose を書きかえる
function onClose() {
  const bodyBounds = documentBody.value.getBoundingClientRect()

  const query = {
    // どのドキュメントをアニメーションさせるかを指定するために ID を送る
    fromId: route.params.id,

    // 今までと同様に要素のスタイルを送る
    left: bodyBounds.left,
    top: bodyBounds.top,
    width: `${bodyBounds.width}px`,
    height: `${bodyBounds.height}px`,
    padding: window.getComputedStyle(documentBody.value).padding,
  }

  router.push({
    path: '/',

    // query 経由でスタイルを送る
    query,
  })
}

Home 側では query 経由で送られたスタイルを appearStyle に保存します。ここで、fromId の値からアニメーションを適用するカードを特定します。fromId の値は appearId に保存し、テンプレート側で appearStyle を適用する要素を特定するために使います。

// Home.vue の <script> に追加する
const appearId = ref(null)
const appearStyle = ref(null)

const documentRefs = shallowRef([])

onMounted(() => {
  if (route.query.fromId) {
    // 対象となる要素のドキュメント ID を保存
    appearId.value = route.query.fromId

    // 指定されたドキュメント ID に対応する要素を取得し、座標を取得
    const el = documentRefs.value.find(
      (el) => el.dataset.documentId === appearId.value,
    )
    const bounds = el.getBoundingClientRect()

    // 遷移前の要素の座標を数値で取得
    const left = Number(route.query.left)
    const top = Number(route.query.top)

    // 適用先の要素の座標との差分を取って、遷移前の要素と同じ座標になるように translate を設定
    const translate = `${left - bounds.left}px ${top - bounds.top}px`

    // 要素に適用するスタイルを設定
    appearStyle.value = {
      translate,
      width: route.query.width,
      height: route.query.height,
      padding: route.query.padding,
      transition: 'none',
    }

    // query を URL から削除
    router.replace({
      query: null,
    })

    // appearStyle を適用し、描画されるまで待つ
    requestAnimationFrame(() => {
      // スタイルを削除することで、遷移前のスタイルから遷移後のスタイルにアニメーションする
      appearId.value = appearStyle.value = null
    })
  }
})

テンプレートも書き換えて、appearStyle を appearId の値と一致するカードに適用します。また、documentRefs に要素の参照を入れるようにし、要素には data-document-id 属性を付与します。この属性を使うことで、先ほどの onMounted 内のコードでドキュメントの ID に対応したカードを特定できます。

<!-- Home.vue の <template> を書きかえる -->
<ul class="document-list">
  <li class="document-item" v-for="document in documents" :key="document.id">
    <router-link :to="'/' + document.id" custom v-slot="{ href }">
      <!--
        appearId とドキュメントの ID が一致する時に appearStyle を適用する。
        onMounted で fromId から要素を特定するために、data-document-id にドキュメント ID を入れておく。
      -->
      <a
        class="document-button"
        ref="documentRefs"
        :style="appearId === document.id ? appearStyle : null"
        :data-document-id="document.id"
        :href="href"
        @click.prevent="onClick(document.id, $event)"
      >
        {{ document.body }}
      </a>
    </router-link>
  </li>
</ul>

以上の実装により、両方の画面遷移にシームレスなアニメーションが付きました。

結論 #

シームレスな画面遷移アニメーションはコードのメンテナビリティが悪化しやすく難易度の高い実装です。しかし、UI の触り心地を向上させるために実装する価値があると思います。今回の実装にあった、遷移前の要素のスタイルを適用した直後に、そのスタイルを削除し、CSS トランジションでアニメーションをさせる手法は FLIP テクニックと呼ばれています。より理解を深めたい方は以前に FLIP テクニックに関する記事を書いているので、そちらも参考にしてください。