jQuery無しでイージングアニメーションを伴う機能をつくろう!その4(スムーススクロール・後編)

UI開発者 宇賀

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

まずは前回のコードを振り返りましょう。

※ この記事は、以前執筆したjQuery無しでイージングアニメーションを伴う機能をつくろう!その3(スムーススクロール・前編)の続きです。

前回のコード

var smoothScroll = function (range) {
    var position = 0; // スクロールする位置
    var progress = 0; // 現在の進捗 0 ~ 100
    var easeOut = function (p) { // ease-out に当てはめた値を返す
        return p * (2 - p);
    };
    var move = function () { // 実際にスクロールを行う
        progress++; // 進捗を進める
        position = range * easeOut(progress / 100); // スクロールする位置を計算する

        window.scrollTo(0, position); // スクロールさせる

        if (position < range) { // 現在位置が目的位置より進んでいなければアニメーションを続行させる
            requestAnimationFrame(move);
        }
    };

    requestAnimationFrame(move); // 初回呼び出し
};

smoothScroll(800); // 800px の位置までスムーススクロールする

ease-outを実現するための計算式の紹介と、それを利用してスクロールを実施してみるところまでコードを書いてみました。今回も引き続きスムーススクロールをつくっていきます。

イージング自体はすでに組み込まれているため、おまけの色が強いですが、本稿では次の機能を実装していきましょう。

  • 現在位置からのスクロール(現状だとページトップからの計算)
  • スクロールが終わった後にコールバックを呼び出す機能

現在位置からのスクロール

現在のスクロール量を取得するには「window.pageYOffset」というプロパティが使えます。

任意の距離までスクロールして、開発者ツールのコンソールに「window.pageYOffset」と入力するとY軸方向のスクロール距離が取得できていることを確認できます(X軸はpageXOffset)。

window.pageYOffsetを利用してスタート位置を記憶し、目的距離までの差を加味した値を変数positionに格納するようにします。

var smoothScroll = function (range) {
    var position = 0; // スクロールする位置
    var progress = 0; // 現在の進捗 0 ~ 100
    var start = window.pageYOffset; // スクロール開始時の位置
    var diff = range - start; // 目的の位置までの差分
    var easeOut = function (p) { // ease-out に当てはめた値を返す
        return p * (2 - p);
    };
    var move = function () { // 実際にスクロールを行う
        progress++; // 進捗を進める
        position = start + (diff * easeOut(progress / 100)); // スクロールする位置を計算する
    // 以下略

このようにすることで、現在の位置からスクロールができるようになりました。現状では下方向スクロールしか対応できないため、上方向スクロールも対応します。

現在位置からの差を見ることでどちらにスクロールするのかを判定することができます。

あとは変数isUpの値に応じて条件式を切り替えてあげることで、上下どちらにも対応させることができました。

var smoothScroll = function (range) {
    var position = 0; // スクロールする位置
    var progress = 0; // 現在の進捗 0 ~ 100
    var start = window.pageYOffset; // スクロール開始時の位置
    var diff = range - start; // 目的の位置までの差分
    var isUp = diff <= 0; // 上下どちらのスクロールを行うのかは、差分が負の値かどうかで判断することができる
    var easeOut = function (p) { // ease-out に当てはめた値を返す
        return p * (2 - p);
    };
    var move = function () { // 実際にスクロールを行う
        progress++; // 進捗を進める
        position = start + (diff * easeOut(progress / 100)); // スクロールする位置を計算する

        window.scrollTo(0, position); // スクロールさせる

        if (
            (
                isUp &&
                range < position
            ) ||
            (
                !isUp &&
                position < range
            )
        ) {
            // 現在位置が目的位置より進んでいなければアニメーションを続行させる
            requestAnimationFrame(move);
        }
    };

    requestAnimationFrame(move); // 初回呼び出し
};

続いてスムーススクロールが終わったのかどうかの判定を組み込んでいきましょう。

スクロールが終わった後にコールバックを呼び出す機能

今回は、スクロールが終了した後に何かしらの処理を実行したいとします。ここでは、アラートダイアログを出したいとしましょう。

smoothScroll(800, function () {
    alert('スクロール終了');
});

こちらのように第2引数にコールバック関数を渡して、スクロール量が目的位置に達したら実行する、という方法もありますが今回はPromiseを利用した実装方法を考えます。

Promiseを利用することで次のようにコードを書くことが可能になります(jQueryを前提としたPromiseを用いるスムーススクロールについては2017年11月27日の記事「document.scrollingElementでスムーズスクロール」で紹介されていますので、併せてご一読いただければと思います)。

smoothScroll(800).then(function () {
    alert('スクロール終了');
});

先ほどまでのコードにPromiseを組み込んだ例を示します。

var smoothScroll = function (range) {
    var position = 0; // スクロールする位置
    var progress = 0; // 現在の進捗 0 ~ 100
    var start = window.pageYOffset; // スクロール開始時の位置
    var diff = range - start; // 目的の位置までの差分
    var isUp = diff <= 0; // 上下どちらのスクロールを行うのかは、差分が負の値かどうかで判断することができる
    var easeOut = function (p) { // ease-out に当てはめた値を返す
        return p * (2 - p);
    };

    return new Promise(function (resolve, reject) {
        var move = function () { // 実際にスクロールを行う
            progress++; // 進捗を進める
            position = start + (diff * easeOut(progress / 100)); // スクロールする位置を計算する

            window.scrollTo(0, position); // スクロールさせる

            if (
                (
                    isUp &&
                    range < position
                ) ||
                (
                    !isUp &&
                    position < range
                )
            ) {
                // 現在位置が目的位置より進んでいなければアニメーションを続行させる
                requestAnimationFrame(move);

                return;
            }

            resolve(); // 処理の完了
        };

        requestAnimationFrame(move); // 初回呼び出し
    });
};

smoothScroll(800).then(function () {
    alert('スクロール終了');
});

そのままコードをコピー&ペーストすると、上から800pxの位置までスクロールしてアラートダイアログが表示されたのではないでしょうか。

ここまでで無事にスクロールが終了したパターンが書けたので、最後に「無事に終了しなかったパターン」を検出できるようにしてみます。

スムーススクロールの中断

今のままでは、1度走り出したスムーススクロールを途中でキャンセルすることができません。

キャンセルできる手段を実装する方法として、次のような状況を考えてみました。

  • マウスホイールが操作されたとき
  • キーボードの方向キーが操作されたとき
  • キーボードのスペースキーが操作されたとき
  • キーボードのESCキーが操作されたとき

これらは全てaddEventListenerで感知することができる挙動です。早速組み込んでみましょう。

var smoothScroll = function (range) {
    var position = 0; // スクロールする位置
    var progress = 0; // 現在の進捗 0 ~ 100
    var start = window.pageYOffset; // スクロール開始時の位置
    var diff = range - start; // 目的の位置までの差分
    var isUp = diff <= 0; // 上下どちらのスクロールを行うのかは、差分が負の値かどうかで判断することができる
    var easeOut = function (p) { // ease-out に当てはめた値を返す
        return p * (2 - p);
    };
    var isMovable = true; // スクロール可能

    return new Promise(function (resolve, reject) {
        var stopScrollHandler = function (e) {
            if (
                e.type !== 'wheel' &&
                e.type !== 'keydown' &&
                e.keyCode === 27 && // ESC
                e.keyCode === 32 && // スペース
                e.keyCode === 38 && // 上
                e.keyCode === 40 // 下
            ) {
                return;
            }

            e.preventDefault();
            isMovable = false;
            reject(); // 中断
            window.removeEventListener('wheel', stopScrollHandler);
            window.removeEventListener('keydown', stopScrollHandler);
        };
        var move = function () { // 実際にスクロールを行う
            progress++; // 進捗を進める
            position = start + (diff * easeOut(progress / 100)); // スクロールする位置を計算する

            window.scrollTo(0, position); // スクロールさせる

            if (
                isMovable &&
                (
                    (
                        isUp &&
                        range < position
                    ) ||
                    (
                        !isUp &&
                        position < range
                    )
                )
            ) {
                // 現在位置が目的位置より進んでいなければアニメーションを続行させる
                requestAnimationFrame(move);

                return;
            }

            resolve(); // 処理の完了
            window.removeEventListener('wheel', stopScrollHandler);
            window.removeEventListener('keydown', stopScrollHandler);
        };

        window.addEventListener('wheel', stopScrollHandler);
        window.addEventListener('keydown', stopScrollHandler);
        requestAnimationFrame(move); // 初回呼び出し
    });
};

smoothScroll(800).then(function () {
    alert('スクロール終了');
}).catch(function () {
    alert('スクロール失敗');
});

関数smoothScrollが実行されたとき、windowのwheelイベント、keydownイベントにスクロールを停止させるためのハンドラを登録します。

Promiseが終了する(スクロールが終了、あるいは中断された)タイミングで、不要になったハンドラを削除しています。

まとめ

スムーススクロール1つをとってもなかなかに奥深いですね。近い将来、JavaScriptでスムーススクロールを実装しなくてもいい日がくることが2017年9月26日の記事「Google Chrome 61で追加されたscroll-behaviorを考える」で紹介されていますが、それまでの間はまだまだ実用的な機能の1つです。他にも組み込むことができる機能やサポートできることを探したり、パフォーマンスを考慮したりして自分の考える最高のスムーススクロールを生み出してみるのもいいかもしれません。

さて、イージングの紹介に始まり、トグル(ディスクロージャーウィジェット)、スムーススクロールの実装方法についてお話してきましたが、jQuery無しでイージングアニメーションを伴う機能をつくろう!シリーズはいかがだったでしょうか。

ここ数年でフロントエンド技術で実現できる動きの表現方法は爆発的に増えてきました。今後も動きを伴う表現はますますJavaScriptの力を借りずとも実現できるようになってくるでしょう。その時代を見越して、できるところはCSSでアニメーションを実現することに慣れておくことも大切かもしれないですね。

最近では脱jQueryの記事も見かけなくなってきましたがこれを機会に、日々続々と実装されていく新しい仕様にも向き合いながらフロントエンド技術者としての腕を磨いていっていただければと思います。