jQuery無しでイージングアニメーションを伴う機能をつくろう!その2(トグル編)

UI開発者 宇賀

本記事の補足記事を公開しました(2019/06/28)
JavaScriptで操作するCSS TransitionとCSSOMの関係

jQueryを使わずにスムーズなアニメーションを実装しようというシリーズの第2弾です。

今回は様々なWebサイトで採用されているUIの1つ、ディスクロージャーウィジェット(いわゆるトグル)の実装方法を考えて見ます。

jQueryを使った例では「.slideToggle()」や「.slideDown()」「.slideUp()」などで実現されていることが多いでしょう。

HTMLの仕様でも、トグルのように情報の表示非表示を切り替えることができる要素が定義されていますので、今回はそんなdetails要素とsummary要素も活用していきたいと思います。

※ この記事は、以前執筆したjQuery無しでイージングアニメーションを伴う機能をつくろう!その1(イージングの紹介編)の続きです。
※ この記事は1つのタイトルと1つのコンテンツの関係性で完結し、タイトルが押下されるとコンテンツの表示非表示を切り替えるUIを「トグル」と呼称します。

details要素とsummary要素とは

次のサンプルでは、ただのdetails要素とsummary要素のサンプルを設置しています。

本記事の補足記事を公開しました(2019/06/28)
JavaScriptで操作するCSS TransitionとCSSOMの関係

details要素は、子要素のsummary要素が押下されることでopen属性が付いたり外れたりします。details属性にopen属性が付いている場合、summary要素以外のコンテンツも表示されるという仕様です。

対応しているブラウザであれば、「タイトル(summary要素)」を押下することで「詳細テキスト」が表示されます。 JavaScript無しでトグルを実現する大変便利な要素ですが、対応していないブラウザでは開いた状態になっており、「タイトル」を押下しても何も起こらないでしょう。

<details>
<summary>タイトル</summary>

詳細テキスト詳細テキスト詳細テキスト詳細テキスト
</details>
details要素の実装サンプル
タイトル 詳細テキスト詳細テキスト詳細テキスト詳細テキスト

開閉自体をJavaScriptで制御してみる

details要素に対応されていないブラウザを意識しつつ開閉自体をサポートできるよう次のようなHTML、CSS、JavaScriptコードを用意します。

<details id="sample">
<summary>タイトル</summary>

<div class="details-content">
<p>詳細テキスト詳細テキスト詳細テキスト詳細テキスト</p>
<!-- /.details-content --></div>
</details>
summary {
  color: #000;
  font-weight: bold;
  background: #ebebeb;
  padding: 10px;
  display: block; /* 対応していないブラウザを考慮 */
}
details .details-content {
  padding: 10px;
  border: 2px solid #ebebeb;
}
details:not([open]) .details-content { /* 対応していないブラウザを考慮 */
  display: none;
}
(function () {
    'use strict';

    var details = document.querySelector('#sample');
    var summary = details.querySelector('summary');
    var isClose = details.getAttribute('open') === null; // openがあれば空文字、なければ null が返ります
    var clickHandler = function (e) {
        e.preventDefault(); // summary要素の機能をキャンセル

        isClose = details.getAttribute('open') === null; // openがあれば空文字、なければ null が返ります

        if (isClose) {
            details.setAttribute('open', '');

            return;
        }

        details.removeAttribute('open');
    };

    summary.tabIndex = 0; // IEではフォーカスがあたらないため tabIndex を指定する
    summary.addEventListener('click', clickHandler);
    summary.addEventListener('keydown', function (e) {
        // IEではキーボード操作ができないため keydown イベントリスナを追加する
        switch (e.keyCode) {
        case 13: // enter
        case 32: // space
            e.preventDefault(); // summary要素の機能をキャンセル

            clickHandler.call(this, e); // クリックイベントに登録しているハンドラを実行する

            break;

        default:
            break;
        }
    });
}());

実際の動きは次のようなイメージです。対応しているブラウザでは特に変化は感じないでしょう。

ここまでは単純にdetails要素の動作をサポートしただけになっています。次のセクションから早速アニメーションの実装を試みてみたいと思います。

IEでもdetails要素の挙動をJavaScriptで実現したサンプル
タイトル

詳細テキスト詳細テキスト詳細テキスト詳細テキスト

details要素の展開、折り畳みをアニメーションさせてみる

前回アニメーションさせたいプロパティの値は、必ず数値で表されているものでなければならないというお話を紹介しました。

今回のようなパターンも同様に、高さの変化をアニメーションさせる上で開始値と終了値が設定されていなければなりません。

具体的にはアニメーションの開始時、開始値をstyle属性で指定してから、終了値を指定することでアニメーションさせることができます。

事前にtransitionプロパティを指定した上で、アニメーションさせる手順は次のようなイメージになります。

閉じている場合
  1. summary要素が押下される
  2. details要素にopen属性を付与する
  3. 開いた状態のコンテンツの高さを取得しておく
  4. コンテンツの高さを0に指定する
  5. コンテンツの高さに取得しておいた値を指定する
  6. アニメーションが終了したタイミングで、指定した値をstyle属性から削除する // これを実施しないと開けなくなったりどんどん高さが大きくなったりする
開いている場合
  1. summary要素が押下される
  2. 開いた状態のコンテンツの高さを取得しておく
  3. コンテンツの高さに取得しておいた値を指定する
  4. コンテンツの高さを0に指定する
  5. アニメーションが終了したタイミングで、details要素のopen属性を削除する
  6. 指定した値をstyle属性から削除する

いずれも「アニメーションが終了したタイミング」を検知して処理を実行するステップが含まれています。

transitionアニメーションの終了は「transitionend」イベントにハンドラを追加することで検知することができます。

一部ブラウザによってイベントタイプ名が異なるため、変数に格納して使用します。

transitionendイベントは、実際にtransitionアニメーションが実行されたときにのみ処理が実施されるため、CSSの読み込みがうまくいっていなかった場合などは発火しないことに注意してください。

var TRANSITION_END = 'onwebkitTransitionEnd' in window ? 'webkitTransitionEnd' :
                     'onmoztransitionend' in window ? 'mozTransitionEnd' :
                     'onotransitionend' in window ? 'oTransitionEnd' :
                     'transitionend';

element.addEventlistener(TRANSITION_END, function () {
    // transition が終わった!
});

transitionendイベントを利用して、先述の手順をただ記述した結果を次に示します。

手順どおりにコードを記述しただけの実装例(バグを含みます)
タイトル

詳細テキスト詳細テキスト詳細テキスト詳細テキスト

おや、なぜだかうまく動きません。

この例では、いくつかコードの中に問題点を含んでいます。次にこのサンプルを実現しているコードを、問題点をハイライトして示します。

まず、CSSを見てみましょう。

summary {
  color: #000;
  font-weight: bold;
  background: #ebebeb;
  padding: 10px;
  display: block; /* 対応していないブラウザを考慮 */
}
details .details-content {
  padding: 10px;
  border: 2px solid #ebebeb;
  transition: .2s height ease-out; /* アニメーションの設定 */
}
details:not([open]) .details-content { /* 対応していないブラウザを考慮 */
  display: none;
}

transitionする要素にpaddingやborderが指定されています。

今回は計算上0pxまで要素の高さを減らす必要があるため、同じ要素にpaddingやborderを指定することができません。

高さをアニメーションさせるような場面では、サイズが変わる要素にpaddingやborderを指定しないようにしなければなりません。

次にHTMLを見てみましょう。

<details id="sample">
<summary>タイトル</summary>

<div class="details-content">
<p>詳細テキスト詳細テキスト詳細テキスト詳細テキスト</p>
<!-- /.details-content --></div>
</details>

先述のCSSの問題を解決することは、このままでも解決することは可能です。しかし、汎用性を持たせるためにdiv.details-contentの直下にもう1つ要素を入れ子にしておいたほうがよいでしょう。

最後にJavaScriptを見てみましょう。

(function () {
    'use strict';

    var TRANSITION_END = 'onwebkitTransitionEnd' in window ? 'webkitTransitionEnd' :
                         'onmoztransitionend' in window ? 'mozTransitionEnd' :
                         'onotransitionend' in window ? 'oTransitionEnd' :
                         'transitionend';
    var details = document.querySelector('#sample');
    var summary = details.querySelector('summary');
    var content = summary.nextElementSibling;
    var height = 0;
    var isSliding = false; // 連打対策
    var isClose = details.getAttribute('open') === null;
    var clickHandler = function (e) {
        e.preventDefault();

        if (isSliding) {
            return;
        }

        // スライドが終わるまでクリックを禁止する
        isSliding = true;

        // summary要素が押下される
        isClose = details.getAttribute('open') === null;

        // 閉じている場合
        if (isClose) {
            details.setAttribute('open', ''); // details要素にopen属性を付与する
            height = content.offsetHeight; // 開いた状態のコンテンツの高さを取得しておく
            content.style.height = 0; // コンテンツの高さを0に指定する
            content.style.height = height + 'px'; // コンテンツの高さに取得しておいた値を指定する

            return;
        }

        // 開いている場合
        height = content.offsetHeight; // 開いた状態のコンテンツの高さを取得しておく
        content.style.height = height + 'px'; // コンテンツの高さに取得しておいた値を指定する
        content.style.height = 0; // コンテンツの高さを0に指定する
    };
    var transitionendHandler = function (e) {
        if (!isClose) {
            // 閉じる場合はアニメーションが終了したタイミングで、details要素のopen属性を削除する
            details.removeAttribute('open');
        }

        // アニメーションが終了したタイミングで、指定した値をstyle属性から削除する
        // これを実施しないと開けなくなったりどんどん高さが大きくなったりする
        content.style.height = '';
        // クリック禁止を解除する
        isSliding = false;
    };

    summary.tabIndex = 0;
    summary.addEventListener('click', clickHandler);
    summary.addEventListener('keydown', function (e) {
        switch (e.keyCode) {
        case 13: // enter
        case 32: // space
            clickHandler.call(this, e);

            break;

        default:
            break;
        }
    });

    content.addEventListener(TRANSITION_END, transitionendHandler); // アニメーションが終わったときに実行する処理を登録
}());

値を取得し、開始値を設定した後に終了値を設定してはいるものの、処理のタイミングが同一なのでアニメーションが実施されていないことからうまく動いてくれないようです。

transitionは、レンダリングされている状態から値の変化をアニメーションさせる仕組みです。レンダリングされる前にJavaScriptの処理の中だけで値が書き換えられただけでは、最後に指定された値が描画されるだけで終わってしまいます。

結果、transitionend イベントが発火せず、連打対策の「isSliding」がずっとtrueのままなので閉じることができなくなってしまいました。

それでは早速、これらのコードが孕んでいる問題点を解決していきます。

CSS Transition を考慮したコードに修正してみる

最後に実際に修正したコードを実行したサンプルを用意しています。具体的にどんな修正を実施したかを見ていきましょう。

HTMLでは、開閉アニメーションを行う要素の中に1つネストさせる要素を追加しました。

<details id="sample">
<summary>タイトル</summary>

<div class="details-content">
<div class="content-inner">
<p>詳細テキスト詳細テキスト詳細テキスト詳細テキスト</p>
<!-- /.content-inner --></div>
<!-- /.details-content --></div>
</details>

CSSでは「div.content-inner」にpaddingやborderプロパティを設定して見た目を再現しています。

また、開閉アニメーション中に表示させる内容がはみ出ることを抑制するためにoverflowプロパティにhiddenを設定しています。

summary {
  background: #ebebeb;
  color: #000;
  font-weight: bold;
  padding: 10px;
  display: block; /* 対応していないブラウザを考慮 */
}
details .details-content {
  transition: .2s height ease-out; /* アニメーションの設定 */
  overflow: hidden;
}
details .details-content > .inner {
  padding: 10px;
  border: 2px solid #ebebeb;
}
details:not([open]) .details-content { /* 対応していないブラウザを考慮 */
  display: none;
}

JavaScriptではheightプロパティを操作する処理のタイミングを変更しています。

setTimeoutで実施されている例もいくつか見かけますが、今回はrequestAnimationFrameを利用しています。

requestAnimationFrameはブラウザの描画更新と同じタイミングで関数を呼び出すことができるもので、今回のようなアニメーションをJavaScriptで実現したい場合によく登場します。

requestAnimationFrameの処理は通常の処理を離れて並行処理になるため、開始値が画面にレンダリングされるまで待機することができます。開始値を設定した後、requestAnimationFrameで画面上にレンダリングされている高さが、事前に設定した開始値と一致するまで待機し、一致しているのを確認した後、終了値を設定すると無事transitionアニメーションは実施されます。

(function () {
    'use strict';

    var TRANSITION_END = 'onwebkitTransitionEnd' in window ? 'webkitTransitionEnd' :
                         'onmoztransitionend' in window ? 'mozTransitionEnd' :
                         'onotransitionend' in window ? 'oTransitionEnd' :
                         'transitionend';
    var details = document.querySelector('#sample');
    var summary = details.querySelector('summary');
    var content = summary.nextElementSibling;
    var height = 0;
    var isSliding = false; // 連打対策
    var isClose = details.getAttribute('open') === null;
    var open = function () {
        if (content.offsetHeight !== 0) {
            requestAnimationFrame(open);
            return;
        }

        content.style.height = height + 'px'; // コンテンツの高さに取得しておいた値を指定する
    };
    var close = function () {
        if (content.offsetHeight !== height) {
            requestAnimationFrame(close);
            return;
        }

        content.style.height = 0; // コンテンツの高さに取得しておいた値を指定する
    };
    var clickHandler = function (e) {
        e.preventDefault();

        if (isSliding) {
            return;
        }

        // スライドが終わるまでクリックを禁止する
        isSliding = true;

        // summary要素が押下される
        isClose = details.getAttribute('open') === null;

        // 閉じている場合
        if (isClose) {
            details.setAttribute('open', ''); // details要素にopen属性を付与する
            height = content.offsetHeight; // 開いた状態のコンテンツの高さを取得しておく
            content.style.height = 0; // コンテンツの高さを0pxに指定する
            open(); // 初回呼び出し。以降は関数内でループする

            return;
        }

        // 開いている場合
        height = content.offsetHeight; // 開いた状態のコンテンツの高さを取得しておく
        content.style.height = height + 'px'; // コンテンツの高さに取得しておいた値を指定する
        close(); // 初回呼び出し。以降は関数内でループする
    };
    var transitionendHandler = function (e) {
        if (!isClose) {
            // 閉じる場合はアニメーションが終了したタイミングで、details要素のopen属性を削除する
            details.removeAttribute('open');
        }

        // アニメーションが終了したタイミングで、指定した値をstyle属性から削除する
        // これを実施しないと開けなくなったりどんどん高さが大きくなったりする
        content.style.height = '';
        // クリック禁止を解除する
        isSliding = false;
    };

    summary.tabIndex = 0;
    summary.addEventListener('click', clickHandler);
    summary.addEventListener('keydown', function (e) {
        switch (e.keyCode) {
        case 13: // enter
        case 32: // space
            clickHandler.call(this, e);

            break;

        default:
            break;
        }
    });

    content.addEventListener(TRANSITION_END, transitionendHandler); // アニメーションが終わったときに実行する処理を登録
}());

最終的な成果物

手順どおりにコードを記述しただけの実装例(前述の問題を解決した例)
タイトル

詳細テキスト詳細テキスト詳細テキスト詳細テキスト

開閉自体をJavaScriptで制御しただけのところから、以上のような修正を施すことでdetails要素とsummary要素をアニメーションさせることができます。

このような方法をとることで、JavaScriptは状態の管理に専念することができ、CSSではその動きの調整の管理を行うだけというきりわけができます。

本記事の例では、要素を取得する方法にquerySelectorを採用していますが、複数の要素を対象に実施できるように改修すれば、次のように1つの関数でクラスごとにCSSで異なる動きをさせることも可能です。

クラスごとに動きを変化させた実装例
linear

詳細テキスト詳細テキスト詳細テキスト詳細テキスト

ease-out

詳細テキスト詳細テキスト詳細テキスト詳細テキスト

ease-in

詳細テキスト詳細テキスト詳細テキスト詳細テキスト

今回はだいぶ長編になってしまいました。

CSSアニメーションを前提としたウィジェットの開発には細かい落とし穴がたくさんあるように見えますが、1度実装してみるとだんだん感覚がつかめてくると思います。

状況に応じて、ベストなアニメーションの実装方法を使い分けつつWeb制作ができるといいですね。

次回はJavaScriptによるスムーススクロールをつくっていきたいと思います。お楽しみに!