👷‍♀️

Reactでスクロール位置によって要素のスタイルを変える

2022/06/08に公開

以前、Webページでスクロール位置に応じて要素のスタイルを変えたいようなケースがありました。

上の例では、最初は60pxですが、現在の位置より上にスクロールされると少しずつサイズが小さくなり、ビューポート上部に達したときに20pxになるように実装されています。

これをReactで雑に実装したので備忘録として残しておきます。

1. 要素の位置を取得するカスタムフックを作る

まずビューポート内の要素の位置を取得する必要があります。複数の要素に対して同じことをやりたくなったときのために、要素の位置を取得する処理はカスタムフックに切り出しておきます。

useOffsetTop.ts
import React, { useEffect, useState, useCallback } from "react";

export function useOffsetTop(ref?: React.RefObject<HTMLElement>) {
  const [viewportTop, setViewportTop] = useState<number | undefined>(undefined);
  const [pageOffsetTop, setPageOffsetTop] = useState<number | undefined>(undefined);

  const handler = useThrottle(() => {
    if (!ref?.current) return;
    
    const clientRect = ref.current.getBoundingClientRect();
    setViewportTop(clientRect.top);
    const newPageOffsetTop = clientRect.top + window.pageYOffset;
    setPageOffsetTop(newPageOffsetTop);
  }, 100); // 100msに一度実行


  useEffect(() => {
    if (!ref?.current) return;
    
    // マウント時にも実行
    handler();
    window.addEventListener("scroll", handler);
    
    // アンマウント時にイベントリスナーを解除
    return () => window.removeEventListener("scroll", handler);
  }, [handler]);

  return { viewportTop, pageOffsetTop };
}
  • 引数には位置を調べたい要素のRefObjectを渡します。
  • 返り値のviewportTopは現在表示されているビューポートの上端を起点にしたオフセット位置を表します。スクロールで通りすぎたときには(ビューポート上端より上にある場合)はマイナスの値となります。
  • pageOffsetTopはスクロール量に関係なく、ページ上端からの位置を表します。
  • レンダリングの負荷を減らすために、イベントハンドラにはスロットルを設定して呼び出される頻度を減らすようにしています。
    useThrottle.ts
    // 受け取った関数をスロットリング
    export function useThrottle<T>(
      fn: (args?: T) => void,
      durationMS: number // スロットルする時間
    ) {
      const scrollingTimer = useRef<undefined | NodeJS.Timeout>();
      return useCallback(
        (args?: T) => {
          if (scrollingTimer.current) return; // すでにタイマーがセットされている場合は何もしない
          scrollingTimer.current = setTimeout(() => {
    	fn(args);
    	scrollingTimer.current = undefined; // タイマーをリセット
          }, durationMS);
        },
        [scrollingTimer, fn, durationMS]
      );
    }
    

2. 位置に応じてCSSのプロパティを変える

あとはuseOffsetTop()から取得した値に応じて幅や高さを計算するロジックを書き、その値を対象要素のstyleに渡します。ここでは対象要素がビューポートの上部に達したときに最小サイズになるようにしてみました。

ExampleHeader.tsx
import { useRef, useMemo } from "react";

const maxIconSize = 100; // 要素の最大サイズ
const minIconSize = 20; // 要素の最小サイズ

export const ExampleHeader: React.VFC = () => {
  const iconRef = useRef(null);
  const { pageOffsetTop, viewportTop } = useOffsetTop(iconRef);

  // 要素の位置をもとにサイズを計算
  const iconSize = useMemo(() => {
    // 位置を取得できなかったときは最大サイズとして表示
    if (pageOffsetTop === undefined || viewportTop === undefined) return maxIconSize;
    
    // 位置に応じてサイズ計算
    const size =
      minIconSize + (viewportTop / pageOffsetTop) * (maxIconSize - minIconSize);
    return size.toFixed(1);
  }, [pageOffsetTop, viewportTop]);

  return (
    <div
      ref={iconRef}
      style={{ width: `${iconSize}px`, height: `${iconSize}px` }}
    />
  );
};

まぁ大したことはやってないですね。

Discussion