当サイトは、アフィリエイト広告を利用しています
このブログ(Gatsby)にZennブログみたいに記事の画面をスクロールすると目次が合わせてハイライトされる
ようにしたいと思いIntersection Observer APIを使って実装してみたので忘備録として残す。
Codesandboxを使って基本的な動作を確認する
目次をハイライトするにはWebAPIのintersection Observer API(交差オブザーバー API)か
react用のパッケージのreact-intersection-observerを使うのが良さそう。
今回はintersection Observer APIを使っていく。
監視対象(指定したHTMLの要素)が viewport 、または、特定の要素と交差した(要素と要素が重なった)場合
に実行するコールバック関数を登録できるAPI
ブラウザによってはサポートされていないものがある(IEなど)
詳細は交差オブザーバー API参照
まとめると監視領域と監視対象を決めて、スクロールなどで監視領域に監視対象が入る(交差する)タイミングで仕込んだ
コールバック関数を発火できる。
基本的にはIntersectionObserverに引数として
を渡してIntersectionObserverオブジェクトを作成し、
IntersectionObserverオブジェクトのobserveメソッドを使って
監視するエレメントを登録する。流れは下記のような形になる。
~//交差時に動くコールバックを定義const callback = (entries) => {console.log(entries);};//オプションを定義const options = {root: null,rootMargin: "0px",threshold: 0.5};//IntersectionObserverオブジェクトの作成const observer = new IntersectionObserver(callback, options);// // domから監視対象を取得let targets = Array.from(document.querySelectorAll(".target"));// 監視対象をオブジェクトにセットするtargets.forEach((terget) => observer.observe(terget, options));}, []);~
IntersectionObserverオブジェクト作成時に設定できるオプションは
三つある。
監視対象の要素が交差したかどうかを判定するための要素を指定します。
nullを指定した場合(デフォルト)は viewportが使用される。
監視範囲のこと。viewportは表示されている画面全体
rootの監視範囲を拡張、または縮小する設定。
交差の判定に使われる rootのマージンを指定できる。正の値を指定するとrootの監視範囲が広がり
監視対象要素が見えるよりも早くコールバック関数が実行される。負の値を指定するとrootの監視範囲が
監視対象要素が見え始めてから少し遅れてコールバックが実行される。
どのくらい監視対象要素が監視範囲に入った(交差した)らコールバックを実行するかを 0 〜 1 の間で指定する
目次ハイライトをする前にサンプル実装して動作を確認する
domを直接操作してすることで実現はできている。
/** @jsxImportSource @emotion/react */import { useEffect } from "react";import { css } from "@emotion/react";import "./styles.css";export default function App() {const array = new Array(1, 2, 3, 4, 5, 6);useEffect(() => {// 監視対象がviewPortに入った(交差した)時に実行するコールバック関数const callback = (entries) => {// 監視範囲に入った監視対象(IntersectionObserverEntity)の取得const entry = entries.find((entry) => entry.isIntersecting);if (entry) {// 監視対象(IntersectionObserverEntity)から監視対象(DOM要素)//のindexを取得const index = targets.indexOf(entry.target);// 監視対象(DOM要素)をループしてstyleを変更するtargets.forEach((item, i) => {i === index? // ? item.classList.add("active")// : item.classList.remove("active");(item.style.backgroundColor = "skyBlue"): (item.style.backgroundColor = "white");});}};//IntersectionObserverオブジェクトの作成const observer = new IntersectionObserver(callback, options);// domから監視対象を取得let targets = Array.from(document.querySelectorAll("#target"));// 監視対象をオブジェクトにセットするtargets.forEach((terget) => observer.observe(terget, options));}, []);const options = {// 監視範囲(viewPort)root: null,// 監視範囲のマージンrootMargin: "0px 0px -95% 0px",// どのくらい見えたらコールバック関数を実行するか(0~1の間)threshold: 0};return (<div className="App">{array.map((val) => (<liid="target"key={val}css={[css`height: 500px;`]}>{val}</li>))}</div>);}
下記のCodeSandBoxのサンプルの載せる
※ビューポートを監視エリアにしてるため高さを変えた場合は
全画面表示にしないと動作がおかしくなる
DOMを直接操作するのではなく、 useStateを使ってレンダリングさせるようにしたversion
/** @jsxImportSource @emotion/react */import { useEffect, useState } from "react";import { css } from "@emotion/react";import "./styles.css";export default function App() {// 記事リストconst article = [{ id: "1", title: "見出し1", contents: "テスト1", color: "block" },{ id: "2", title: "見出し2", contents: "テスト2", color: "block" },{ id: "3", title: "見出し3", contents: "テスト3", color: "block" },{ id: "4", title: "見出し4", contents: "テスト4", color: "block" },{ id: "5", title: "見出し5", contents: "テスト5", color: "block" }];// 記事リストをuseStateに設定する// スタイル変更時に再レンダリングさせるためconst [articles, setArticle] = useState(article);useEffect(() => {// 監視対象がviewPortに入った時のコールバック関数// 引数としてIntersectionObserverEntityを受け取るconst callback = (entries) => {// 監視領域に入った監視対象(IntersectionObserverEntity)を取得const entry = entries.find((entry) => entry.isIntersecting);// ある場合if (entry) {// useStateの記事を更新するsetArticle(// 記事リストから監視領域に入ったDOMのidと一致してる記事を特定し// 更新する。articles.map((prev) => {if (prev.id === entry.target.id) {return { ...prev, color: "skyBlue" };} else {return { ...prev, color: "white" };}}));}};//IntersectionObserverオブジェクトの作成const observer = new IntersectionObserver(callback, options);// // domから監視対象を取得let targets = Array.from(document.querySelectorAll(".target"));// 監視対象をオブジェクトにセットするtargets.forEach((terget) => observer.observe(terget, options));}, []);const options = {root: null,rootMargin: "0px 0px -95% 0px",threshold: 0};// let observer = new IntersectionObserver(callback, options);return (<div className="App"><div>{articles.map((contents) => (<ArticleCom article={contents} />))}</div></div>);}// 記事コンポーネントconst ArticleCom = ({ article }) => {return (<div key={article.id}>{/* idを記事オブジェクトから設定する(監視対象特定のため) */}{/* 監視対象に入れるためクラスを付与 */}{/* 記事オブジェクトのcolorで色を決めるようにする */}<div id={article.id} className="target" css={[style(article.color)]}>{article.title}</div></div>);};const style = (color) => [css`border: 1px solid black;background-color: ${color};height: 500px;`];
下記のCodeSandBoxのサンプルの載せる
※ビューポートを監視エリアにしてるため高さを変えた場合は
全画面表示にしないと動作がおかしくなる
EmotionとuseStateを使う場合のソースコードを解説する
監視対象となるDOM要素の元となるオブジェクトをhooksのuseStateにセットする。
理由は後述するが、こうしないとIntersectionObserverで交差を検知しコールバックを
実行しても要素のスタイルを書き換えることができないため。
~// 記事リストconst article = [{ id: "1", title: "見出し1", contents: "テスト1", color: "block" },{ id: "2", title: "見出し2", contents: "テスト2", color: "block" },{ id: "3", title: "見出し3", contents: "テスト3", color: "block" },{ id: "4", title: "見出し4", contents: "テスト4", color: "block" },{ id: "5", title: "見出し5", contents: "テスト5", color: "block" }];// 記事リストをuseStateに設定する// スタイル変更時に再レンダリングさせるためconst [articles, setArticle] = useState(article);~
監視対象にはDOM要素を設定する必要があるため、useEffectで一度画面描画された後に
IntersectionObserverオブジェクトを作成する。
またuseEffect内のコールバック関数でuseStateに設定した記事リストを更新することで
さらに更新結果を再レンダーさせるようにする必要がある。
~useEffect(() => {// 監視対象がviewPortに入った時のコールバック関数// 引数としてIntersectionObserverEntityを受け取るconst callback = (entries) => {// 監視領域に入った監視対象(IntersectionObserverEntity)を取得const entry = entries.find((entry) => entry.isIntersecting);// ある場合if (entry) {// useStateの記事を更新するsetArticle(// 記事リストから監視領域に入ったDOMのidと一致してる記事を特定し// 更新する。articles.map((prev) => {if (prev.id === entry.target.id) {return { ...prev, color: "red" };} else {return { ...prev, color: "black" };}}));}};//IntersectionObserverオブジェクトの作成const observer = new IntersectionObserver(callback, options);// // domから監視対象を取得let targets = Array.from(document.querySelectorAll(".target"));// 監視対象をオブジェクトにセットするtargets.forEach((terget) => observer.observe(terget, options));}, []);const options = {root: null,rootMargin: "0px",threshold: 1};~
useStateに記事リストを設定した理由は、IntersectionObserverに登録したコールバック関数の
結果を受けて画面を再レンダーさせる必要があるため。
Reactではコンポーネントが再レンダーはされるのは下記のパターンの時のため 記事リストをstateに設定した。
下記の記事を参考にさせて頂きました。
上記のサンプルで動作が確認できたので、実際にIntersectionObserverを使って
記事の目次をスクロールに合わせてハイライトするサンプルを作ってみる。
/** @jsxImportSource @emotion/react */import { css } from "@emotion/react";import { useState, useEffect } from "react";export default function App() {// 記事リストconst articleList = [{ id: "1", title: "見出し1", contents: "テスト1", color: "block" },{ id: "2", title: "見出し2", contents: "テスト2", color: "block" },{ id: "3", title: "見出し3", contents: "テスト3", color: "block" },{ id: "4", title: "見出し4", contents: "テスト4", color: "block" },{ id: "5", title: "見出し5", contents: "テスト5", color: "block" }];const [articles, setArticle] = useState(articleList);useEffect(() => {// 監視対象がviewPortに入った時のコールバック関数// 引数としてIntersectionObserverEntityを受け取るconst callback = (entries) => {// 監視領域に入った監視対象(IntersectionObserverEntity)を取得const entry = entries.find((entry) => entry.isIntersecting);// ある場合if (entry) {// useStateの記事を更新するsetArticle(// 記事リストから監視領域に入ったDOMのidと一致してる記事を特定し// 更新する。articles.map((prev) => {if (prev.id === entry.target.id) {return { ...prev, color: "red" };} else {return { ...prev, color: "black" };}}));console.log(articles);}};//IntersectionObserverオブジェクトの作成const observer = new IntersectionObserver(callback, options);// // domから監視対象を取得let targets = Array.from(document.querySelectorAll(".target"));// 監視対象をオブジェクトにセットするtargets.forEach((terget) => observer.observe(terget, options));return () => observer.disconnect();}, []);// オプションconst options = {root: null,// 下から-95%にして監視範囲を上部5%だけにするrootMargin: "0px 0px -95% 0px",// 0にするthreshold: 0};return (<div css={[container]}>{/* 記事 */}<div css={[articleArea]}>{articles.map(({ id, title, color }) => (<div css={[article]} id={id} className="target"><div css={[border]}>{id}</div><div css={[border]}>{title}</div></div>))}</div>{/* 目次 */}<div css={[indexArea]}>{articles.map(({ title, color }) => (<div css={[index]}><div css={[style(color)]}>{title}</div></div>))}</div></div>);}// 全体のスタイルconst container = () => [css`display: grid;grid-template-columns: minmax(500px, 4fr) minmax(200px, 1fr);column-gap: 50px;align-items: start;`];// 目次エリアのスタイルconst indexArea = () => [css`position: sticky;top: 10px;border: 1px solid black;`];// 目次のスタイルconst index = () => [css`display: grid;grid-template-columns: repeat(1, 100px);text-align: center;`];// 記事エリアのスタイルconst articleArea = () => [css`display: grid;grid-template-columns: repeat(1, minmax(300px, 1fr));gap: 2px;`];//記事のスタイルconst article = () => [css`display: grid;grid-template-columns: minmax(100px, 2fr) minmax(300px, 5fr);text-align: center;&:hover {/* 上に動かす */transform: translate(0px, -1px);box-shadow: 0 2px 3px rgba(0, 0, 0, 0.3);}transition-property: all;transition-duration: 0.3s;transition-delay: 0s;transition-timing-function: ease;height: 500px;`];const border = () => [css`border: 1px solid black;`];// 目次の色を変更const style = (color) => [css`color: ${color};`];
基本的にやってることはEmotionとuseStateを使う場合と同じですが
IntersectionObserverと交差した時のコールバック関数で色を変えるDOM要素を
目次にしている感じです。
codesandboxで動作確認した
※ビューポートを監視エリアにしてるため高さを変えた場合は
全画面表示にしないと動作がおかしくなる