portal要素を使った画面遷移アニメーションをいくつか考えてみる

UI開発者 板垣

この記事はミツエーリンクスアドベントカレンダー2019 - Qiitaの12日目の記事です。

昨今のWebサイトはアニメーションがいたるところにちりばめられていて、ネイティブアプリを触っているようで楽しいですね。
クリックなどのユーザー操作に合わせて要素の振る舞いを変えるもの、ロードアニメーションのようなサイトの状態を表すようなものがある中で、個人的には画面遷移アニメーションが一番好きです。
最近はSPAとして作られたサイトが増えて、画面遷移のアニメーションを見る機会が多いと思いますが、これを自分で試そうと思うと結構な学習コストが掛かってしまうんですよね...。
私もWebについて勉強したての頃は、「SPAってなに...ルーティング...?黒い画面だあ!」といった感じで目的のアニメーションにすら到達できなかったことを覚えています。
おそらく、この記事を読んでいる方も私と同じ経験をしたことがある、もしくは現在進行形で悩んでいらっしゃるのではないでしょうか。
しかし、安心してください。Web業界といえば成長が早いことで有名です。近い将来そんな悩みとはおさらばできそうです。
なぜなら、先月開催された「Chrome Dev Summit 2019」でも紹介されていた「portal要素」を使うことによって、SPAやその周りのことを考えずとも画面遷移のアニメーションを簡単に実装することができるからです。

今回はそんなportal要素を使った画面遷移アニメーションをいくつかご紹介します。

そもそもportal要素とは

事例を紹介する前に、portal要素を知らない方のために簡単に概要を説明します。

portal要素はシームレスな画面遷移を可能にするための要素で、src属性に任意のURLを指定することによってiframe要素のように、ページ上に異なるページを読み込むことができます。
※iframe要素とは違い、読み込んだページを操作することはできません。あくまでJavaScriptでアクティベートされるまでは動的なレンダリング結果を表示するだけです。

通常の画面遷移ではリンクをクリックして、空白のページが表示されて、ようやく目的のページが表示されるという流れでしたが、portal要素を使用すると遷移先を事前にレンダリングできるので即座に画面を切り替えることができます(ここでいう画面切り替えとは通常の画面遷移と同じくURLが変わる遷移のことを指します)。
つまり工夫次第では、ネイティブアプリの画面遷移と同様な体験をユーザーに提供することができるのです。

portal要素を使用するには、Google Chromeのアドレスバーにchrome://flags/#enable-portalsと入力し「Enable Portals.」のセレクトボックスからEnabledを選択する必要があります(Google Chrome以外のブラウザは未対応)。

アニメーション

事例1:Hello Portal

クリックすると拡大されるアニメーションです。
transformプロパティのscale()を使って画面幅いっぱいのportal要素を任意のサイズにスケールダウンしておき、要素をクリックしたときに、JavaScript側でスタイルを元に戻すことによってスムースな画面遷移アニメーションを実現しています。
これはサイトのトップページへ戻るボタンとして使ったりすると楽しいかもしれないですね。

コード

...
<style>
...
portal {
    width: 100%;
    height: 100vh;
    transform: scale(0.2);
    transform-origin: left bottom;
    box-shadow: 0 0 20px 10px rgba(0, 0, 0, .2);
    transition: transform 0.6s cubic-bezier(.33,.72,.08,.98);
    position: fixed;
    bottom: 40px;
    left: 40px;
}
</style>
...
<portal src="https://jobs.mitsue.co.jp/"/></portal>

<script>
(function () {
    const portal = document.querySelector('portal');
    const transitionAnimation = (portal) => {
        portal.style.transform = 'scale(1.0) translate(-40px, 40px)';
    };

    portal.addEventListener('click', function () {
        transitionAnimation(portal);
    });
    portal.addEventListener('transitionend', function (e) {
        if (e.propertyName === 'transform') {
            portal.activate();
        }
    });
}());
</script>
...

script要素内に出てくるactivate()というメソッドはportal要素特有のメソッドです。
このメソッドが実行されたタイミングで実行元のportal要素がアクティベートされて、その要素が読み込んでおいたページに遷移できます。

この事例では、portal要素のtransitionendイベントが発生したタイミングで、activate()メソッドを実行しています。

事例2:紙芝居式

紙芝居のようなスライドアニメーションです。
処理の流れは以下の通りです。

  1. クリック時にbody要素へクラスを付与して疑似要素を表示
  2. 疑似要素がページに覆いかぶさるようなアニメーションを行う
  3. 疑似要素がページ全体を完全に覆ったタイミングでportal要素を表示させる
  4. 疑似要素が画面外に移動したらportal要素をアクティベートする

コード

...
<style>
...
body.transition::before {
    position: fixed;
    left: 0;
    top: 0;
    display: block;
    content: "";
    width: 100%;
    height: 100vh;
    background: #fff url(./logo.png) center center/400px no-repeat;
    animation: transitionAnime 1.8s ease-in-out both;
    z-index: 2;
}
@keyframes transitionAnime {
    0% {
        transform: translateX(100%);
    }
    50% {
        transform: translate(0);
    }
    100% {
        transform: translate(-100%);
    }
}
...
portal {
    position: fixed;
    left: 0;
    top: 0;
    height: 100vh;
    width: 100%;
    visibility: hidden;
    z-index: 1;
}
</style>
</head>
<body>
<header>
    <h1>紙芝居式</h1>
</header>
<section>
    <div class="section-inr">
        <h2>MITSUE-LINKS Recruit</h2>
        <p>Smart Communication<br>Design Company</p>
        <p>ミツエーリンクスは、企業が必要とするコミュニケーションを最高の技術でデザインし、世界に貢献したいと思い続けています。</p>
        <p>私たちとともに、さまざまなコミュニケーションを創造しませんか?</p>
        <a href="https://jobs.mitsue.co.jp/">詳しく見る</a>
    </div>
</section>

<portal src="https://jobs.mitsue.co.jp/"></portal>
<script>
(function () {
    const body = document.body;
    const a = document.querySelector('a');
    const portal = document.querySelector('portal');

    a.addEventListener('click', (e) => {
        e.preventDefault();

        body.classList.add('transition');
    });

    body.addEventListener('animationstart', () => {
        const duration = Number(getComputedStyle(body, 'before').animationDuration.slice(0, -1));
        const portalVisibleTime = duration / 2 * 1000;

        setTimeout(() => {
            portal.style.visibility = 'visible';
        }, portalVisibleTime);
    });
    body.addEventListener('animationend', () => {
        portal.activate();
    });
}());
</script>
</body>
</html>

事例3:iOS風スライドイン

iOSでよくみる動きですね。
このアニメーションでは前2つの事例とは異なり、2つのページを行き来できるようにしています。
これは、windowのportalactivateイベントを使うことで実現できます。
portalactivateイベントとは、ページがアクティベートされたときに発生するイベントで、eventオブジェクトのadoptPredecessor()メソッドから遷移前のページをportal要素として取得できます。adoptPredecessor()メソッドで取得したportal要素をHTMLへ動的に追加することによって遷移前のページと遷移後のページをシームレス行き来できるのです。
portalactivateイベントが実行されるタイミングでportal要素を追加するのではなく、そもそもHTML上に置いておけばいいように思えますが、そうするとportal要素のループが発生して読み込みが止まらなくなってしまうのです。

以下のコード(page1)では、portal要素のループを防ぐためにactivate()メソッドが実行された後にportal要素をすべて削除しています。また、portalactivateイベントが発生したタイミングで再度portal要素を生成し、HTML上へ要素を動的に追加しています。

コード(page1)

...
<ul class="menu">
   <li class="menu__item"><a class="portal-link" href="/demo3/page2.html">ミツエーリンクスとは</a></li>
   ...
</ul>
...

<script>
(function () {
    const getHref = (els) => {
        const linkList = [];

        els.forEach((el) => {
            const href = el.getAttribute('href');

            linkList.push(href);
        })

        return linkList;
    };
    const appendPortals = (linkList) => {
        let portals = '';

        linkList.forEach((uri) => {
            portals += `<portal src="${uri}"></portal>`;
        });

        document.body.insertAdjacentHTML('beforeend', portals);
    };
    const removePortals = () => {
        const portal = document.querySelectorAll('portal');

        portal.forEach((el) => {
            el.remove();
        });
    };
    const portalLinkEl = document.querySelectorAll('.portal-link');
    const linkList = getHref(portalLinkEl);

    appendPortals(linkList);

    portalLinkEl.forEach((el) => {
        el.addEventListener('click', (e) => {
            e.preventDefault();

            const href = el.getAttribute('href');
            const target = document.querySelector(`portal[src="${href}"]`);

            target.style.opacity = '1';
            target.style.transform = 'translateX(-100%)';
            target.addEventListener('transitionend', (e) => {
                if (e.propertyName === 'transform') {
                    target.activate().then(() => removePortals());
                }
            });
        });
    });

    window.addEventListener('portalactivate', (e) => {
        appendPortals(linkList);
    });
}());
</script>
...

以下のコード(page2)にあるscript要素内を見ていただくと、最初の行にlocation.hrefによるページ遷移処理が書かれているのがわかります。
これは、今回のような複数ページで1つのアプリケーションのように動作させたいときなどで重要になってきます。
なぜならportal要素でページ遷移をした場合、セッション履歴が破棄されてしまいブラウザバックボタンで元のページに戻れなくなってしまうからです。
今回は、portal要素上で表示されたページではwindow.portalHostプロパティの値がセットされるという仕様を利用し、portalHostがない場合、つまり通常のページ遷移の場合はトップページへリダイレクトするようにしています。

コード(page2)

...
<script>
(function () {
    if (!window.portalHost) {
        location.href = "/demo3/";
    }

    const headerPrevBtn = document.querySelector('.header-prev-btn');
    const page = document.querySelector('.page');

    window.addEventListener('portalactivate', (e) => {
        const prevPortal = e.adoptPredecessor();

        document.body.append(prevPortal);

        headerPrevBtn.addEventListener('click', () => {
            prevPortal.style.opacity = '1';
            page.style.transform = 'translateX(100%)';
        });

        page.addEventListener('transitionend', (e) => {
            if (e.propertyName === 'transform') {
                prevPortal.activate();
            }
        });
    });
}());
</script>
...

事例4:リンクから直接ページを出す

最後は、クリックしたリンクからページが出てくるようなアニメーションです。
この動きを他のサイトで見たことはありませんが、「リンクを開く」という言葉通りのアニメーションで悪くない気がします。処理自体も簡単で、クリックした箇所のXY軸を取得し、それらの値をportal要素のleftとtopプロパティに与え、あとはHello portalアニメーションと同じようにtransformプロパティのscale()で大きさを変更するだけです。

コード

...
<style>
...
portal.active {
    animation: transitionAnime 0.6s ease-in-out both;
    visibility: visible;
}

@keyframes transitionAnime {
    0% {
        transform: scale(0);
    }
    100% {
        left: 0;
        top: 0;
        transform: scale(1);
    }
}

</style>
...
<script>
const a = document.querySelector('a');
const portal = document.querySelector('portal');

a.addEventListener('click', (e) => {
    e.preventDefault();

    const portalW = portal.offsetWidth;
    const portalH = portal.offsetHeight;
    const posX = e.pageX - (portalW / 2);
    const posY = e.pageY - (portalH / 2);

    portal.style.left = `${posX}px`;
    portal.style.top = `${posY}px`;
    portal.classList.add('active');
});

portal.addEventListener('animationend', () => {
    portal.activate();
});
</script>
</body>
</html>

おわりに

開発途中の機能なので仕様の変更はありそうですが、とても期待できる要素です。今回4つのアニメーションをご紹介しましたが、バリエーションは無限大だと作っていて感じました。

これからもこういった新しい技術を取り入れて、より面白みのあるサイトを作っていきたいですね。