【MPA対応】View Transitions APIを使用したページ遷移の実装方法

この記事では下記のページを参考にしています。
View Transitions APIとは
View Transitions APIとは、異なる DOM 状態間のアニメーション遷移を可能にし、同時に DOM コンテンツも更新可能にする新しいWebブラウザの技術です。
私の認識が間違っていなければ、つまるところ「シームレス(継ぎ目のない)なページ遷移を可能にする技術」です。
従来の方法では、ページの遷移時にリロードが発生し、ユーザーにとってストレスとなることがありました。
しかし、View Transitions APIの登場でページ間の遷移がシームレスになることで、スムーズなアニメーションが実現可能となり結果的にユーザーエクスペリエンスの向上が期待できるようになります。
どういう動きのこと?
上記は実際のView Transitions APIを使用したページ遷移のサンプルです。
リンクをクリックするとページ遷移の際に一覧ページのサムネイル画像が大きくなり、そのまま記事ページのアイキャッチ画像にスムーズに遷移しているように見えるのではないでしょうか?
このような動きは身近なところだと、スマートフォンアプリによく取り入れられているので皆さんも何となくイメージできるかと思います。
従来のWebサイトではこのような動きはページ間でDOMの構造が異なるため不可能でしたが、View Transitions APIの登場でこのようなスマホアプリライクな実装が可能になりました。
対応ブラウザ
SPA(シングルページアプリケーション)の場合はChrome、Edgeのバージョン111。
MPA(マルチページアプリケーション)の場合はChrome、Edgeのバージョン126.
それ以外のバージョンや他のブラウザ(SafariやFireFox)では現状未対応となっているため、View Transitions APIを使用したページ遷移には対応していません。(通常のページ遷移は可能です)
今回実現したいこと
こちらは実際のアニメーション時間を30秒まで引き延ばして、ページ遷移の様子を観察した動画です。
ご覧の様にサムネイルの画像が徐々にアニメーションし、ブログ記事のアイキャッチ画像に切り替わってく様子が分かるかと思います。
その逆にブログ記事から記事一覧ページへ戻る際にはアイキャッチ画像からサムネイル画像へ徐々に切り替わっていきます。
基本的な仕組み
実はMPAの場合そこまで難しくはなく、下記のCSSをトランジションさせたいページのheadタグ内に記述するか、もしくは下記に記述を行ったCSSファイルを読み込むだけで基本的なアニメーションは完成します。
@view-transition {
navigation: auto;
}
これだけで動画の様にページ全体がトランジションするアニメーションが実装できます。
さらに、遷移前のページと遷移後のページで共通のview-transition-nameを付与することで、その要素にトゥイーンアニメーションを掛けることができます。
今回の場合は一覧のサムネイル画像と記事のアイキャッチ画像に同じview-transition-nameを付与することでそれぞれがアニメーションの起点と終点となり自動で補完されるというイメージです。
view-transition-nameの注意点
しかしながら、view-transition-nameはページ内で重複してはいけません。
重複した状態があると、すべてのView Transitions APIのアニメーションが、一切発火しなくなります。
そのため、JavaScript側で遷移元ページ離脱時と遷移先ページ読み込み時に動的にview-transition-nameを付け替えるという方法で実装していく必要があります。
それぞれ固有のIDなどを付け加えて「view-transition-name=”transition-${id}”」などの様にしてしまってもよいのですが、そうなるとデフォルトのアニメーションの挙動を変更したい際に困ります。
例えば、特定のview-transition-nameを持つ要素にのみアニメーションを変更を加えたい場合は下記の様に記述します。
/*遷移前と遷移後の要素で共通のアニメーションを持たせる場合*/
html::view-transition-pair(view-transition-nameの名称){
animation: someAnimation 0.5s forwards;
}
/*遷移前と遷移後の要素で別々のアニメーションを持たせる場合*/
//遷移前ページのアニメーション
html::view-transition-old(view-transition-nameの名称){
animation: oldPageAnimation 0.5s forwards;
}
//遷移後のページのアニメーション
html::view-transition-new(view-transition-nameの名称){
animation: newPageAnimation 0.5s forwards;
}
仮にそれぞれのブログ記事に固有のID付のview-transition-nameを持たせたとすると上記のアニメーション制御のコード数がその分だけどんどん増えていくことになります。
それならば、同じ挙動を取らせたい要素のview-transition-nameは統一してしまって、動的にview-transition-nameを付けたり外したりすることで、ページ内での重複を避ければ良いのではないか?というわけです。
MPAでの実装方法
今回のデモサンプルのコードで解説を行っていきます。
マークアップとスタイルに関しては下記のICSメディアさんのサンプルサイトのコードほぼそのままコピペして使用しています。
フォルダ構成
detail-1
|-index.html
detail-2
|-index.html
detail-3
|-index.html
detail-4
|-index.html
detail-5
|-index.html
detail-6
|-index.html
|
images
|
index.html
|
main.js
|
style.css
HTML
<div id="app">
<div class="main-layout">
<!--省略-->
<main class="main">
<ul class="photo-list">
<li>
<a id="detail-1" href="/detail-1" class="photo-link">
<div class="vta-img">
<img class="photo-thumb" src="./images/photo_11.jpg" alt="" />
</div>
<div class="photo-meta photo-body">
<p class="i-title">MS Designer Generated</p>
<p class="i-sub">Designer</p>
<p class="i-desc">Lorem ipsum dolor sit amet consectetur adipisicing elit. Facere iusto amet rerum possimus dolores at alias! Architecto aspernatur aut praesentium alias? Voluptates similique ullam soluta. Fugiat repudiandae aut ab omnis.</p>
</div>
</a>
</li>
<!--省略-->
</ul>
</main>
</div>
</div>
TOPページの記事一覧のHTMLには、各記事部分に固有のIDを付与しています。
<div id="app">
<div class="main-layout">
<!--省略-->
<main class="main">
<div class="detail-layout">
<div id="detail-1">
<div class="detail-photo-container vta-img">
<img src="../images/photo_11.jpg" class="photo-img" width="640" height="360" alt="" />
</div>
<div class="photo-details photo-body">
<p class="i-title">MS Designer Generated</p>
<p class="i-sub">Designer</p>
<p class="i-desc">Lorem ipsum dolor sit amet consectetur adipisicing elit. Repellat fugit dolore tempora corrupti deserunt illo sapiente quas sunt, temporibus quidem, perferendis quibusdam praesentium consectetur reprehenderit, at minima quia. Possimus, unde.</p>
</div>
</div>
<aside class="aside">
<ul class="side">
<li>
<a class="side-item" href="/detail-1"
><img class="side-thumb" src="../images/photo_11.jpg" alt="" />
<div class="side-meta">
<p class="i-title">MS Designer Generated</p>
<p class="i-sub">Designer</p>
</div>
</a>
</li>
<!--省略-->
</ul>
</aside>
</div>
</main>
</div>
</div>
ブログの記事ページにもTOPページと同じIDを付与しています。
JavaScript
//ホームページかどうかを判定
const isHomePage = (url) => {
// return homePagePattern.exec(url);
if (url.pathname === "/") {
return true;
}
return false;
};
//detailページかどうかを判定
const isDetailPage = (url) => {
// return detailPagePattern.exec(url);
if (url.pathname.startsWith("/detail-")) {
return true;
}
return false;
};
//urlから記事IDを取得
const extractDetailIdFromUrl = (url) => {
const pathname = url.pathname;
const splitArray = pathname.split("/").filter((item) => item !== "");
const detailId = splitArray[0];
return detailId;
};
//ページのアンロード時の処理(遷移元ページ)
window.addEventListener("pageswap", async (e) => {
if (e.viewTransition) {
//現在のURLを取得
const currentUrl = e.activation.from?.url ? new URL(e.activation.from.url) : null;
//遷移先のURLを取得
const targetUrl = new URL(e.activation.entry.url);
//現在のURLがdetailページ&遷移先のURLがhomeページの場合
if (isDetailPage(currentUrl) && isHomePage(targetUrl)) {
//現在のページからdetailIDを取得
const detailId = extractDetailIdFromUrl(currentUrl);
//一致したIDを持つ要素のimgタグにview-transition-nameを付与
document.querySelector(`#${detailId} img`).style.viewTransitionName = "banner-img";
//トランジションが終了したら
await e.viewTransition.finished;
//view-transition-nameを空にする
document.querySelector(`#${detailId} img`).style.viewTransitionName = "none";
}
//遷移先のURLがdetailページの場合
if (isHomePage(currentUrl) && isDetailPage(targetUrl)) {
//遷移先のURLからdetailIDを取得
const detailId = extractDetailIdFromUrl(targetUrl);
//一致したIDを持つ要素のimgタグにview-transition-nameを付与
document.querySelector(`#${detailId} img`).style.viewTransitionName = "banner-img";
//トランジションが終了したら
await e.viewTransition.finished;
//view-transition-nameを空にする
document.querySelector(`#${detailId} img`).style.viewTransitionName = "none";
}
//現在のURLがdetailページ&遷移先のURLがdetailページの場合
if (isDetailPage(currentUrl) && isDetailPage(targetUrl)) {
//遷移先のページからdetailIDを取得
const detailId = extractDetailIdFromUrl(targetUrl);
document.querySelector(`aside a[href="/${detailId}"] img`).style.viewTransitionName = "banner-img";
//トランジションが終了したら
await e.viewTransition.finished;
//view-transition-nameを空にする
document.querySelector(`aside a[href="/${detailId}"] img`).style.viewTransitionName = "none";
}
}
});
//ページの最初のレンダリング直前に起動するイベント(遷移先ページ)
window.addEventListener("pagereveal", async (e) => {
//前のページからのナビゲーションが無い場合は処理を行わない(つまりページの初初期表示)
if (!navigation.activation.from) return;
if (e.viewTransition) {
const fromUrl = new URL(navigation.activation.from.url);
const currentUrl = new URL(navigation.activation.entry.url);
//遷移元のURLがdetailページ&現在のURLがhomeページの場合
if (isDetailPage(fromUrl) && isHomePage(currentUrl)) {
//遷移元のページからdetailIDを取得
const detailId = extractDetailIdFromUrl(fromUrl);
//一致したIDを持つ要素のimgタグにview-transition-nameを付与
document.querySelector(`#${detailId} img`).style.viewTransitionName = "banner-img";
//トランジションが終了したら
await e.viewTransition.ready;
//view-transition-nameを空にする
document.querySelector(`#${detailId} img`).style.viewTransitionName = "none";
}
//遷移元のURLがhomeページ&現在のURLがdetailページの場合
if (isHomePage(fromUrl) && isDetailPage(currentUrl)) {
//現在のページからdetailIDを取得
const detailId = extractDetailIdFromUrl(currentUrl);
//一致したIDを持つ要素のimgタグにview-transition-nameを付与
document.querySelector(`#${detailId} img`).style.viewTransitionName = "banner-img";
sessionStorage.setItem("currentUrl", currentUrl.pathname);
//トランジションが終了したら
await e.viewTransition.ready;
//view-transition-nameを空にする
document.querySelector(`#${detailId} img`).style.viewTransitionName = "none";
}
//現在のURLがdetailページ&遷移元のURLがdetailページの場合
if (isDetailPage(currentUrl) && isDetailPage(fromUrl)) {
//遷移先のページからdetailIDを取得
const detailId = extractDetailIdFromUrl(currentUrl);
document.querySelector(`#${detailId} img`).style.viewTransitionName = "banner-img";
//トランジションが終了したら
await e.viewTransition.ready;
//view-transition-nameを空にする
document.querySelector(`#${detailId} img`).style.viewTransitionName = "none";
}
}
});
- URLのパス名から記事のIDを抽出する関数(extractDetailNameFromUrl)
- ブログ一覧ページかを判定する関数(isHomePage)
- ブログ記事ページかを判定する関数 (isDetailPage)
の3つをあらかじめ作っておきます。
- pageswap:ページがアンロードされようとするときに発生
- pagereveal:ドキュメントが最初にレンダリングされる直前に発生
なぜページの判定が必要なのかというと、「extractDetaiIIdFromUrl」関数がurlからIDを取得する機能であるため、pageswapイベントpagerevealイベントのそれぞれにおいて、遷移前と遷移後のどちらのURLからIDを取得すれば良いかを決定付けるためです。
【pageswapイベント時】
現在のページ=ブログ一覧(/) 遷移先のページ=ブログ記事(/detail-番号/) | 遷移先のURLからIDを取得 |
現在のページ=ブログ記事(/detail-番号/) 遷移先のページ=ブログ一覧(/) | 現在のURLからIDを取得 |
現在のページ=ブログ記事(/detail-番号/) 遷移先のページ=ブログ記事(/detail-番号/) | 遷移先のページIDを取得 |
【pagerevealイベント時】
遷移元のページ=ブログ一覧(/) 現在のページ=ブログ記事(/detail-番号/) | 現在のURLからIDを取得 |
遷移元のページ=ブログ記事(/detail-番号/) 現在のページ=ブログ一覧(/) | 遷移元のURLからIDを取得 |
遷移元のページ=ブログ記事(/detail-番号/) 現在のページ=ブログ記事(/detail-番号/) | 現在のページIDを取得 |
view-transition-nameの付け外し
//一致したIDを持つ要素のimgタグにview-transition-nameを付与
document.querySelector(`#${detailId} img`).style.viewTransitionName = "banner-img";
//トランジションが終了したら
await e.viewTransition.ready;
//view-transition-nameを空にする
document.querySelector(`#${detailId} img`).style.viewTransitionName = "none";
そして、pageswapではページのアンロード時に、pagerevealではページのレンダリング直前に上記のコードを実行します。
トランジションアニメーションが終了したら必ずview-transition-nameの値はnoneに設定します。
また、今回のデザインではブログ記事ページからブログ記事ページへの遷移は必ずサイドバーの一覧から、リンクを踏む導線になっています。
document.querySelector(`aside a[href="/${detailId}"] img`).style.viewTransitionName = "banner-img";
//トランジションが終了したら
await e.viewTransition.finished;
//view-transition-nameを空にする
document.querySelector(`aside a[href="/${detailId}"] img`).style.viewTransitionName = "none";
そのため、pageswapイベント時には上記のようにサイドバー部分の記事サムネイル画像にview-transition-nameを付け外しすることになります。
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MS Designer Website</title>
<link rel="stylesheet" href="/../style.css" />
<script src="../main.js"></script>
</head>
最後にheadタグ内で上記のJSファイルを読み込ませます。
(なぜか</body>の直前にJSファイルを読み込ませてもトランジションアニメーションが効いたり効かなかったり不安定な動作になりました…)
まとめ
以上、MPA(マルチページアプリケーション)におけるView Transitions APIの実装方法について簡単ですが解説しました。
View Transitions APIそのものの詳細に関しては私自身もまだ勉強不足なところも多いため、とりあえずシンプルな実装方法のみ解説させて頂きました。
View Transitions APIを活用することで、ユーザーにとって快適で魅力的なウェブ体験を提供できるだけでなく、スマホアプリライクな動きをウェブに取り入れられるというのは開発者目線でも非常に画期的だなと思いました。