ブログ

requestAnimationFrameで実現する、スクロール方向に応じたUI切り替え

スクロールの上下方向に応じて、UIの見た目を変化させたい場面は意外と多くあります。

その都度スクロール方向を判定する方法もありますが、body要素にカスタムデータ属性でグローバルにスクロール方向を保持するという手法が、複数箇所で使いまわせて便利だと最近気がつきました。

コードの全体像

さっそくですが、スクロール方向をdata-scroll-directionというカスタムデータ属性で管理するコード例を紹介します。

script.js
const initScrollDirectionDetection = () => {
let lastScrollY = window.scrollY;
let isScheduled = false;
const updateScrollDirection = () => {
const currentScrollY = window.scrollY;
const direction = currentScrollY > lastScrollY ? "down" : "up";
if (document.body.dataset.scrollDirection !== direction) {
document.body.dataset.scrollDirection = direction;
}
lastScrollY = currentScrollY;
isScheduled = false;
};
window.addEventListener("scroll", () => {
if (!isScheduled) {
requestAnimationFrame(updateScrollDirection);
isScheduled = true;
}
});
};
initScrollDirectionDetection();

スクロール方向に応じて、body要素のdata-scroll-direction属性が次のように動的に変化します:

  • 下方向にスクロール:down
  • 上方向にスクロール:up

ヘッダーをスクロール方向に応じて表示/非表示を切り替える

実務でよくあるユースケースとして、「スクロール方向に応じてヘッダーを見え隠れさせる」といった実装があります。

以下の例では、下方向にスクロールするとヘッダーが非表示になり、上方向にスクロールすると再び表示されるという動きを実現しています。

スクロールに応じたUI切り替えの例

この挙動は、スクロール時にbodydata-scroll-directionに応じて、CSSで.l-headertransformの値を変更することで実現しています。

style.css
/* スクロール時 */
body[data-scroll-direction="down"] .l-header {
transform: translateY(calc(-100% - 1px));
}

このような実装は、ヘッダーが比較的大きい場合や、コンテンツの閲覧領域を少しでも広く確保したい場合に有効だと感じます。

requestAnimationFrameとは

今回の実装ではrequestAnimationFrameを使用しています。
時々見かけるけどもあまり深くまで理解できていないメソッドの代表格ではないでしょうか。

MDNでは以下のように説明されています。

ブラウザーにアニメーションを行いたいことを知らせ、指定した関数を呼び出して次の再描画の前にアニメーションを更新することを要求します。

つまり、次の再描画の直前にコールバック関数を実行してくれる仕組みです。

スクロール処理での動き

次のコードでは、一見すると6行目でisScheduled = true;にしているため、2回目以降のスクロールではrequestAnimationFrameは実行されないのでは?と思うかもしれません。

script.js
let isScheduled = false;
window.addEventListener("scroll", () => {
if (!isScheduled) {
requestAnimationFrame(updateScrollDirection);
isScheduled = true;
}
});

しかし実際には2回目以降のスクロールでも適切にコールバックが登録されて実行されます。

その理由はrequestAnimationFrameのコールバック内でフラグをリセットしているからです。以下のような順序で処理が実行されます。

1回目のスクロール:
  1. スクロールイベント発生
  2. ticking = falseなので条件通過
  3. requestAnimationFrameupdateScrollDirectionを予約
  4. ticking = trueに設定
  5. 次のフレームでupdateScrollDirection実行
  6. updateScrollDirection内でticking = falseにリセット
2回目のスクロール
(同一フレーム内):
  1. スクロールイベント発生
  2. ticking = trueなので条件をスキップ(実行されない)
2回目のスクロール
(次のフレーム):
  1. 前フレームでticking = falseにリセット済み
  2. スクロールイベント発生
  3. ticking = falseなので再び条件通過可能

行ごとの処理を追うと、実行タイミングのイメージは以下のようになります。ポイントとしてはupdateScrollDirectionの実行タイミングが次のフレームの直前である点です。

script.js
window.addEventListener("scroll", () => {
if (!ticking) { // ①
requestAnimationFrame(updateScrollDirection); // ②
ticking = true; // ③
}
});
const updateScrollDirection = () => {
// ... DOM更新処理 ...
ticking = false; // ④ ここでフラグをリセット!
}

以上のことからrequestAnimationFrameはコールバック関数を登録し、すぐには実行せず次の再描写の直前に処理が行われることがわかります。

requestAnimationFrameを使用するメリット

ではrequestAnimationFrameを使うと何が良いのか?

ここでは単純にスクロールイベントのコールバック関数としてupdateScrollDirectionをそのまま渡した例と比較してみます。

requestAnimationFrameなしの例
window.addEventListener("scroll", updateScrollDirection);
requestAnimationFrameなしrequestAnimationFrameあり
  • スクロールイベントが発生するたびに直接実行される。
  • 1秒間に数百回〜1000回以上実行される可能性もある。
  • 最大でも1秒間に約60回に制限される(デバイスにより異なる)

高速スクロール時の実行回数が、この例では約13分の1まで削減できています。つまり、数百〜数千回発生していた処理を、最大でも60回程度にまで抑えることができ、ページ全体のパフォーマンス向上に直結するようです。

私自身、requestAnimationFrameを完全に理解したとは言えませんが、今回の実装を通じて「何ができるのか」「どう使えば良いのか」の感覚がつかめた気がします。

スクロールイベントの最適化や、DOM操作の負荷軽減に今後もぜひ活用してみたいです。

参考サイト