Vue.jsでフォーカス機能つきのスクロールスパイを実装する

こんにちは、あかしぃです。

スクロールスパイ機能つきの追従ヘッダーで、かつ、該当のセクションにスクロールしたときにナビのアイテムがフォーカスされる、という機能を作ったので、メモがてらに記事にしました。

多分、文章で見てもなんのこっちゃ?って感じだと思いますが、こちらのページを見るとイメージがつくかと(スマホで見るか、開発者モードでスマホのwidthにして見てください)。

意外に似たようなライブラリがなかったので、Javascript(vue.js)でスクラッチな感じで書いています。

コードの全体像

まず最初に、コード例を以下に記します。

<template>
  <nav>
    <ul id="scroll-bar"
        class="scroll-nav">
      <li v-for="(navItem, index) in navItems"
          :key="index"
          :index-id="index">
        <a href="#"
           :id="`spy-${navItem.name}`">
          {{ navItem.name }}
        </a>
      </li>
    </ul>
  </nav>
</template>

<script>
import _ from 'lodash';

export default {
  data() {
    navItems: [
      { id: 0, name: 'vue' },
      { id: 1, name: 'react' },
      { id: 2, name: 'angular' },
      { id: 3, name: 'typescript' },
      { id: 4, name: 'javascript' },
    ],
    currentIndex: 0,
    positions: [],
  },

  mounted() {
    // 1. スクロールイベントにscrollSpyイベントを紐づける
    window.addEventListener('scroll', _.throttle(this.scrollSpy, 50));
    this.calculatePosition();
  },

  destroyed() {
    window.removeEventListener('scroll', this.scrollSpy);
  },

  methods: {
    // 3. スクロールが行われるたびに、現在位置が各セクションの中かどうかを判定する。
    scrollSpy() {
      const els = document.getElementsByClassName('scroll-spy');
      els.forEach(el => {
        const elTop = el.getBoundingClientRect().top;
        const elBottom = el.getBoundingClientRect().bottom;

        // セクションの持つheightより外の場合
        if (elTop > 0 || elBottom < 0) {
          const navItem = this.$el.querySelector(`#spy-${el.id}`);
          navItem.classList.remove('active');
        }

        // セクションの持つheightより中の場合
        if (elTop <= 0 && elBottom >= 0) {
          const navItem = this.$el.querySelector(`#spy-${el.id}`);
          navItem.classList.add('active');

          this.currentIndex = navItem.parentNode.getAttribute('index-id');
        }
      });
    },

    // 2. それぞれのnavItemの画面左端からの距離を取得し、positionsに格納しておく
    calculatePosition() {
      const els = this.$el.querySelector('data-id');
      els.forEach(el => {
        const position = el.getBoundingClientRect().left;
        this.positions.push(position);
      });
    },
  },

  watch: {
    // 4. currentIndexが更新されるたびに、navを移動させる(actionクラスが付与されているnavItemを左端に移動させる)
    currentIndex(newIndex) {
      const scrollBar = this.$el.querySelector('#scroll-bar');
      scrollBar.scrollLeft = this.positions[newIndex];
    },
  },
}
</script>

<style scoped>
.scroll-bar {
  position: relative;
  display: flex;
  flex-wrap: nowrap;
  list-style-type: none;
  overflow-x: scroll;
}

.active {
  color: #f08000;
}
</style>

上記が、スクロールスパイ機能を搭載したヘッダーナビコンポーネントです。このコンポーネントの設置イメージはこんな感じ。

<template>
  <div>
    <fixed-header class="sticky" />

    <div id="vue" class="scroll-spy">
      <h2>Vue</h2>
      .
      .
      .   
    </div>

    <div id="react" class="scroll-spy">
      <h2>React</h2>
        .
        .
        .   
    </div>

    <div id="typescript" class="scroll-spy">
      <h2>Typescript</h2>
        .
        .
        .   
    </div>
  </div>
</template>

<script>
import FixedHeader from 'components/FixedHeader.vue'

export default {
  components: {
    FixedHeader
  },
}
</script>

<style scoped>
.sticky {
  position: sticky;
}
</style>

nav要素は直接dataに記述していますが、複数のページで使い回す場合は、サーバー側からnavItemsとして受け取り、propsで渡すようにすると再利用性が増すかなと思います。

コードの解説

簡単にですが、各コードについて解説していきます。

コードのコメント部分で、1、2、3、4とナンバリングしているのは、処理が行われる順番で、その通りに見ていくと理解しやすいかと思います。解説もその順番で進めます。

1. スクロールイベントにscrollSpyメソッドを紐づける

スクロールスパイを実装するには、リアルタイムでスクロール位置を取得し、各処理を行う必要があります。そのために、addEventListenerでスクロールイベントにscrollSpyメソッドを紐づけています。

ただ、スクロールイベントが発生するたびにメソッドを実行するのは高負荷なので、lodashライブラリのthrottleメソッドを使い、最短でも50ミリ秒に1回しかメソッドが実行されないようにしています。

2. それぞれのnavItemの、画面左端からの位置をpositionsに格納しておく

今回のキモは、ページの各セクション内にスクロールしたときに、一致するnavItemを左端にスクロール表示させることです。

それを実現するために、getBoundingClientRect().leftで、それぞれのnavItemの最初のポジションを取得し、positionsに格納しておきます。

あとは、スクロール位置が各セクションに移動したタイミングで、スクロールバーを一致するnavItemのところまでスクロールしてあげればいいわけですね。

3. スクロールが行われるたびに、現在位置が各セクションの中であるかどうかを判定する

scrollSpyメソッドは、スクロールが行われるたびに実行され、ページにおける現在位置が各セクションの中かどうかを判定するメソッドです。

この例では、各セクションにはscroll-spyクラスが付与されているので、document.getElementsByClassNameを使って対象となるelementをまとめて取得しています。

getElementsByClassNameは、elementが格納された配列を返すので、forEachでループを行い、各elementに対して判定、処理を実行します。

elTop、elBottomは、画面端からのelementのtop位置とbottom位置です。少しややこしいですが、getBoundingClientRectはページの頂点を基準にして距離を返すのではなく、今の表示位置からの相対的な距離を返します。

なので、elTopがマイナスの値で、かつelBottomよりプラスの値であれば、現在位置はそのセクション内(element内)である、ということになります。

そして、element内であれば、該当のnavItemにactiveクラスを付与しています。activeクラスは単純に色をつけるだけのクラスで、ちょうど今そのセクションを読んでいるよ、ということをユーザーに知らせるためのものです。

最後に、currentIndexに現在のセクションindexを格納します。

4. currentIndexが更新されるたびに、navItemを移動させる

currentIndexの値が変わる=ユーザーが次のセクションに移動した、ということなので、この変化をキャッチしてnavItemを移動させます。

Vueには値が更新されると実行されるwatchオプションがあるので、これを使います。

やっていることは単純で、positionsにはnavItemごとの移動させるべきポジションが格納されているので、currentIndexで目的のポジションを参照し、その位置にスクロールさせているだけです。

まとめ

今回の例はスマホ特化のスクロールスパイなので、PCなどの大画面から見ると違和感バリバリの機能になる・・・と思います。参考サイトのように、大画面の場合は違った挙動をするヘッダーを実装する必要があります。

また、smooth-scroll.jsといったライブラリを使えば、ほぼ修正なしでスムーススクロールも実装できます。

activeクラスのcssを変更すれば、結構いろんなことができるので、そちらもぜひ試してみてくださいね。

May 15, 2019 - posted by akashixi