スクロールに合わせたカレント表示を設定する

以前、カレント表示を実装する機会があったので、覚え書きついでに解説していきたいと思います。
java script でカレント表示を実装する必要がある方のお役に立てれば幸いです。

まず、必須である要件は以下の3点です。

  • セクションと目次を紐づけてカレント表示する
  • 上部固定のヘッダーあり。メニューをクリックしてセクション移動する時はヘッダーとコンテンツが重ならないようにする
  • ページが表示された時から1つ目のメニューにカレント表示をつける

参考にさせていただいたのがこちらのサイトです。

こちらのサイトでは、セクションとメニューを紐づけてカレント表示させるためのコードが解説されています。

ただ、先述したように他にも必須の要件があったため、諸々改良して完成したものがこちら。
ぜひ大きなウィンドウで開いてご確認ください。

ちなみにレスポンシブ対応はありません。

デモ

See the Pen カレント表示_01 by Michi (@Michi_) on CodePen.

コードの解説

HTML

まず、HTMLがこちら。
メニューとそれに対応するセクションを表示しています。
ポイントは、対応するセクションとTOCに同じクラス名をつけることです。このクラス名によってセクションとカレント表示させるTOCが紐づけられます。

カレント表示に使うクラス名は、jsで制御しているので、HTMLには記述していません。

<header class="js-header">ヘッダー</header>
<div class="title">タイトル</div>

<div class="js-currentWrapper">
  <div class="content js-current js-section-1"><h1>AAAA</h1></div>
  <div class="content js-current js-section-2"><h1>BBBB</h1></div>
  <div class="content js-current js-section-3"><h1>CCCC</h1></div>
  <div class="content js-current js-section-4"><h1>DDDD</h1></div>
</div>

<ul>
  <li class="js-toc js-section-1">AAAA</li>
  <li class="js-toc js-section-2">BBBB</li>
  <li class="js-toc js-section-3">CCCC</li>
  <li class="js-toc js-section-4">DDDD</li>
</ul>

java script

jsコードです。
全体の流れとしては以下の通りです。

  1. .js-current クラスが付いた各セクションの 位置を計算して記録する
  2. スクロールイベントごとに現在の位置をチェックし、対応する .js-toc に ‘.current ‘ を付ける(cssで ‘.current ‘ にカレント表示用のスタイルを当てておく)
  3. TOC(目次)をクリックすると、そのセクションにスムーズにスクロールする

各行にコメントをつけましたので、そちらを参考にしていただければと思います。

function onLoad() {
  const header = document.querySelector('.js-header'); // 固定ヘッダーを取得
  const contents = document.querySelectorAll('.js-current'); // 各セクション(見出しなど)を取得
  const toc = document.querySelectorAll('.js-toc'); // TOC(目次)リストを取得

  const TITLE_HEIGHT = 100; // タイトルの高さ

  // ヘッダーの高さを動的に取得する関数
  function getHeaderHeight() {
    return header ? header.getBoundingClientRect().height : 0;
  }

  const contentsPosition = []; // セクションの位置を保存する配列

  // 各セクションの開始位置と終了位置を記録
  contents.forEach((content, i) => {
    const headerHeight = getHeaderHeight();

    // 開始位置はセクションの開始位置(Top)。最初のセクションだけヘッダーとタイトル分を引く
    const startPosition =
      i === 0
        ? content.offsetTop - headerHeight - TITLE_HEIGHT
        : content.offsetTop;

    // 終了位置は次のセクションの開始位置。最後だけドキュメントの高さ。
    const endPosition = (contents[i + 1]
      ? contents[i + 1].offsetTop
      : document.body.scrollHeight);

    contentsPosition.push({ startPosition, endPosition });
  });

  //現在のスクロール位置に応じて TOC をハイライトする処理
  const calcCurrentPosition = () => {
    const scrollY = window.scrollY;
    const headerHeight = getHeaderHeight();

    // まずすべての TOC から .current を外す
    toc.forEach(item => item.classList.remove('current'));

    let found = false;

    contentsPosition.forEach((pos, i) => {
      const { startPosition, endPosition } = pos;

      // 現在位置がこのセクションの範囲内に入っているか
      if (
        scrollY + headerHeight >= startPosition &&
        scrollY + headerHeight < endPosition
      ) {

        // 'js-currentSection-' で始まるクラス名を抽出
        const sectionClass = Array.from(contents[i].classList).find(cls =>
          cls.startsWith('js-section-')
        );
        if (!sectionClass) return;

        // 対応する TOC に .current を付ける
        const activeTocs = document.querySelectorAll(`.js-toc.${sectionClass}`);
        activeTocs.forEach(item => item.classList.add('current'));

        found = true;
      }
    });

    // 最上部なら強制的に section-1 をカレント表示
    if (!found && scrollY === 0) {
      const defaultTocs = document.querySelectorAll('.js-toc.js-section-1');
      defaultTocs.forEach(item => item.classList.add('current'));
    }

    // 最下部まで到達した時、最後のセクションの TOC に current を付ける
    if (scrollY + window.innerHeight >= document.documentElement.scrollHeight) {
      const lastSectionClass = Array.from(contents[contents.length - 1].classList).find(cls =>
        cls.startsWith('js-section-')
      );
      if (!lastSectionClass) return;

      const lastTocs = document.querySelectorAll(`.js-toc.${lastSectionClass}`);
      lastTocs.forEach(item => item.classList.add('current'));
    }
  };

  // TOCクリック時のスムーズスクロール処理
  toc.forEach(item => {
    item.addEventListener('click', event => {
      const headerHeight = getHeaderHeight();
      const classList = Array.from(event.target.classList);
      const destinationClass = classList.find(cls => cls.startsWith('js-section-'));
      const target = document.querySelector(`.js-current.${destinationClass}`);
      if (!target) return;

      // スクロール先の座標を計算(ヘッダー分補正)
      const targetPosition = target.offsetTop - headerHeight;

      window.scrollTo({
        top: targetPosition,
        behavior: 'smooth'
      });
    });
  });

  window.addEventListener('scroll', calcCurrentPosition); // スクロールで実行
  window.addEventListener('resize', calcCurrentPosition); // リサイズでも再計算
  calcCurrentPosition(); // 初期表示時にも実行
}

document.addEventListener('DOMContentLoaded', onLoad); // DOM読み込み後に onLoad 実行

補足説明

少し補足で説明を足しておきます。

  const TITLE_HEIGHT = 100; // タイトルの高さ

  // ヘッダーの高さを動的に取得する関数
  function getHeaderHeight() {
    return header ? header.getBoundingClientRect().height : 0;
  }

こちらのコードでは、最初のセクションよりも上に表示されるヘッダーとタイトルの高さを指定しています。今回は高さ固定のタイトルセクションだったので固定値としていますが、可変する場合はヘッダーと同様に動的に高さを取得する必要があります。

    // 開始位置はセクションの開始位置(Top)。最初のセクションだけヘッダーとタイトル分を引く
    const startPosition =
      i === 0
        ? content.offsetTop - headerHeight - TITLE_HEIGHT
        : content.offsetTop;

次にこちらの部分。単純に startPosition = content.offsetTop; としてしまうと、スクロール位置が最初のセクションに到達するまでの間、つまりスクロール位置がヘッダーとタイトル部分にある間はカレント表示がどこにもついていない状態になります。

そのため、最初のセクションの時だけヘッダーとタイトル分の高さを引き、スクロール位置がヘッダーとタイトルセクションにある間も最初のTOCにカレント表示されるようにしています。

      // スクロール先の座標を計算(ヘッダー分補正)
      const targetPosition = target.offsetTop - headerHeight;

それから、TOCをクリックして該当セクションへスクロールさせる際に、ヘッダーの高さ分をマイナスすることで、固定ヘッダーとコンテンツが重ならないように調整しています。

まとめ

ちょっと長いコードになってしまいましたが、これでスクロールに合わせて目次にカレント表示を設定することができます。
アレンジすればいろいろな状況に対応できそうなのもいいですね!

それに、実を言うと、今まで条件 (三項) 演算子とかあまり馴染みがなかったので、今回勉強して理解が深まったのもよかったです。記述がちょっと楽になりそうで嬉しい!

コメント

タイトルとURLをコピーしました