LoginSignup
20
16

More than 3 years have passed since last update.

スクロールに連動して現在地を示す目次を作りたい

Last updated at Posted at 2020-07-12

ブログみたいにWEB上で長い文章を読ませるときは、いまどこを読んでいるのか教えてくれるような目次があるほうが便利です。
目次をクリックするとそのセクションに飛んでくれるとさらに便利です。

イメージとしてはangular.jpみたいなこれ↓↓とか、Qiitaの横にもあるアレとか。
なんかのライブラリが探せばありそうだけど普通にJavaScript書けば十分そう、ということで作ってみましょう。

20200712.gif

HTML構造

サンプル。
目次(Table of contents)になるul要素と、記事の表示領域になるwrapperoverflow: autoでスクロールさせる)を作ります。
中身はdivで区切ってありますが特に意味はないです。heightで疑似的に記事の長さを出したり背景色をつけて見やすくするためのものです。

<ul>
  <li class="toc" scrollTo="content1">AAAA</li>
  <li class="toc" scrollTo="content2">BBBB</li>
  <li class="toc" scrollTo="content3">CCCC</li>
  <li class="toc" scrollTo="content4">DDDD</li>
</ul>
<div class="wrapper">
  <div class="content content1">
    <h1>AAAA</h1>
  </div>
  <div class="content content2">
    <h1>BBBB</h1>
  </div>
  <div class="content content3">
    <h1>CCCC</h1>
  </div>
  <div class="content content4">
    <h1>DDDD</h1>
  </div>
</div>

CSS

目次上の現在地は.activeクラスで丸印を付けて判別しやすくします。
他はオマケ。

ul {
  list-style: none;
}
li {
  border-left: solid 1px silver;
  padding-left: 1rem;
  position: relative;
  box-sizing: border-box;
}
.toc {
  cursor: pointer;
  width: 100px;
}
.toc:hover {
  background: whitesmoke;
  color: royalblue;
}
.toc:hover:not(.active)::before {
  content: '';
  position: absolute;
  top: 0.5rem;
  left: -3px;
  border-radius: 50%;
  width: 5px;
  height: 5px;
  background: silver;
}
.toc.active:before {
  content: '';
  position: absolute;
  top: 0.5rem;
  left: -3px;
  border-radius: 50%;
  width: 5px;
  height: 5px;
  background: skyblue;
}
.wrapper {
  width: 400px;
  height: 300px;
  overflow: auto;
}
.content {
  overflow: hidden;
}
.content1 {
  width: 100%;
  height: 500px;
  background: skyblue;
}
.content2 {
  width: 100%;
  height: 500px;
  background: royalblue;
}
.content3 {
  width: 100%;
  height: 500px;
  background: lightblue;
}
.content4 {
  width: 100%;
  height: 500px;
  background: aliceblue;
}

JavaScript

本題。
アプローチとしては以下の通りです。

  1. 記事中の各セクションの開始位置、終了位置を覚えておく。
  2. スクロールイベントリスナで、現在のスクロール位置と各セクションの範囲を比較して.activeクラスを付ける。
  3. 最後までスクロールしたら最後のセクションに.activeクラスを付ける。
  4. 目次をクリックしたら、対応するセクションの開始位置を取得してスクロール位置を変更する。

3は、セクションの高さがスクロール領域よりも小さい場合にスクロール位置が最後のセクションの範囲に入らないことがあるので、その対策です。
たとえば、スクロール可能なwrapper領域が500pxあったとして、コンテンツの合計heightが2000px、最後のセクションのheightが300pxであった場合、wrapperを最後までスクロールさせても最大1500までしか行けない(1500~2000が表示された状態)ので、単純な比較では最後のセクション(1700~2000)には永遠に入らないことになります。

// これをbody.onLoadとかで動かす
function onLoad() {
  const wrapper = document.querySelector('.wrapper'); // ラッパー(スクロール領域)
  const contents = document.querySelectorAll('.content'); // 各セクションのコンテンツ
  const toc = document.querySelectorAll('.toc'); // 目次(クリックしたらそのセクションにスクロール)
  const contentsPosition = [];
  contents.forEach((content, i) => {
    const startPosition =
      content.getBoundingClientRect().top -
      wrapper.getBoundingClientRect().top +
      wrapper.scrollTop;
    const endPosition = contents.item(i + 1)
      ? contents.item(i + 1).getBoundingClientRect().top -
        wrapper.getBoundingClientRect().top +
        wrapper.scrollTop
      : wrapper.scrollHeight;
    contentsPosition.push({ startPosition, endPosition });
  });

  // スクロール位置に応じてTOCの現在位置を変更する
  const calcCurrentPosition = () => {
    toc.forEach((item, i) => {
      const { startPosition, endPosition } = contentsPosition[i];
      item.classList.remove('active');
      if (
        wrapper.scrollTop + wrapper.getBoundingClientRect().height ===
        wrapper.scrollHeight
      ) {
        toc.item(toc.length - 1).classList.add('active');
      } else if (
        wrapper.scrollTop >= startPosition &&
        wrapper.scrollTop < endPosition
      ) {
        item.classList.add('active');
      }
    });
  };

  // スクロールイベントリスナを登録
  wrapper.addEventListener('scroll', calcCurrentPosition);

  // 目次にクリックイベントリスナを登録
  toc.forEach((item) => {
    item.addEventListener('click', () => {
      const destination = event.target.getAttribute('scrollTo');
      wrapper.scrollTop =
        document.querySelector(`.${destination}`).getBoundingClientRect().top -
        wrapper.getBoundingClientRect().top +
        wrapper.scrollTop;
    });
  });

  calcCurrentPosition();
}

セクションの位置を求めるのにはElement.getBoundingClientRect()を利用してます。
Element.getBoundingClientRect()で指定した要素のビューポート上の位置を取得できるので、wrapperのtop位置やscrollTopを足し引きして「wrapper内での絶対位置」を計算しています。
各セクションの位置がわかれば、あとはwrapper.scrollTopとの比較で現在地を特定することができます。

Element.getBoundingClientRect() - Web API | MDN
Element.scrollTop - Web API | MDN

目次に対応するセクションを特定するのはscrollToみたいなワケワカラン属性で対象クラスを指定させていますが、ちゃんとやるならたぶんidとかを使う方がいい気がします。

結果

20200712-1.gif

追記:IntersectionObserver APIとElement.scrollIntoView

コメントでJavaScriptのイケてるAPIを教えていただきました。
こういうのがほしかったんです。ありがとうございます。

要素が画面に入ったタイミングなどを検知できる、IntersectionObserverという、まさにドンピシャなAPIがあります。
まだ草案ではあるのですが、IE以外のモダンブラウザでは利用できるので、IEが動作対象外だったり、ポリフィルを入れてよいのであれば、こちらを使うとパフォーマンス高く記述量低く実装できます。
また、特定の要素に対してスクロールするscrollIntoViewというこれまたドンピシャなAPIもあります。
こちらもまだ草案のようですが、IE含むモダンブラウザで動作します。

Intersection Observer API - Web API | MDN
Element.scrollIntoView() - Web API | MDN

これらを使って書き直すとこんな感じ↓↓。
53行だったスクリプトが34行になりました。やったね!

function onLoad() {
  const contents = document.querySelectorAll('.content');
  const toc = document.querySelectorAll('.toc');
  const tocMap = new Map();

  // IntersectionObserverでコンテンツの出入りを監視
  const intersectCallback = (entries) => {
    entries.forEach((element) => {
      if (element.intersectionRatio) {
        tocMap.get(element.target).classList.add('active');
      } else {
        tocMap.get(element.target).classList.remove('active');
      }
    });
  };
  // wrapperの上辺を現在地の基準点にしたいので、rootMarginで微調整
  const options = {
    root: document.querySelector('.wrapper'),
    rootMargin: '-1px 0px -99% 0px',
  };
  const observer = new IntersectionObserver(intersectCallback, options);

  // コンテンツをIntersectionObserverに登録
  contents.forEach((content, i) => {
    observer.observe(content);
    tocMap.set(content, toc.item(i));
    tocMap.set(toc.item(i), content);
  });
  // 目次にクリックイベントリスナを登録
  toc.forEach((item) => {
    item.addEventListener('click', (event) => {
      tocMap.get(event.target).scrollIntoView();
    });
  });
}

本題とはあんまり関係ないけどTOCとコンテンツのマッピングをMapにしました。
IntersectionObserverを使うことで「イベントが発生したコンテンツ要素からTOCを求める」ことが必要になりますが、添字でアクセスするのは面倒だったからです。

20200715.gif

20
16
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
16