React + TypeScript | モーダルを開いているときに背面コンテンツのスクロールを抑制する方法(全デバイス対応)

22661 ワード

やりたいこと

モーダルを開いている時に背面コンテンツのスクロールを全デバイスで抑制したい

前提条件

  • "react": "18.2.0"
  • "react-dom": "18.2.0"
  • "next": "12.2.3"

解決方法

Modal Component に、以下の処理を仕込むとスクロールを抑制できる。

export const Modal = () => {
  const stopScrollingBackContent = () => {
    document.body.style.overflow = "hidden";

    return () => {
      document.body.style.overflow = "auto";
    }
  };

  useEffect(stopScrollingBackContent, []);
 
  ・・・省略・・・
}

はい。これで万事解決!!!

新たな問題点

と、思ったら、iOSデバイスで動作確認した場合、スクロールできてしまう問題に遭遇した。。。

iOSデバイスでもスクロールを抑制する方法

色々と調べた結果、以下のように実装を行えば、
iOSデバイスでもスクロールを抑制できることがわかった。

Modal Component

import { useModalScrollLock } from './use-modal-scroll-lock'

const MODAL_ID = 'modal'

type ModalProps = {
  isModalOpen : boolean
}

export const Modal = (props : ModalProps) => {
  const { isModalOpen } = props
  
  useModalScrollLock({ isModalOpen })
  
  return <div id={MODAL_ID}>モーダル</div>
}

useModalScrollLock

import { useEffect } from 'react';

import { MODAL_ID } from './Modal';

type UseModalScrollLockArgs = {
  isModalOpen: boolean;
};

/** スクロール可能な要素かどうかを判定する関数 */
const isScrollable = (element: Element) => element.clientHeight < element.scrollHeight;

export const useModalScrollLock = (args: UseModalScrollLockArgs) => {
  const { isModalOpen } = args;

  /** 指定した要素以外のスクロールを抑止する関数(iOS Safariの場合のみの制御) */
  const scrollLock = (event: TouchEvent) => {
    const canScrollElement = (event.target as HTMLDivElement)?.closest(`#${MODAL_ID}`);

    if (canScrollElement === null) {
      // MEMO : 対象の要素でなければスクロール禁止にする
      event.preventDefault();
      return;
    }

    if (canScrollElement !== null && isScrollable(canScrollElement)) {
      // MEMO : 対象の要素があり、その要素がスクロール可能であればスクロールを許可する
      event.stopPropagation();
    } else {
      // MEMO : 対象の要素はスクロール禁止にする
      event.preventDefault();
    }
  };

  const scrollLockFix = (event: Event) => {
    const element = event.target as HTMLDivElement;

    if (element === null) return;

    // 以下の手順で発生するスクロールのバグ対策。回避するため1pxだけスクロール量を減らす
    // 1. メニューを上下どちらかに最大までスクロールする
    // 2. 更にスクロールを行うとページ全体がスクロールする
    if (element.scrollTop + element.clientHeight === element.scrollHeight) {
      element.scrollTop = element.scrollTop - 1;
    }

    if (element.scrollTop === 0) {
      element.scrollTop = 1;
    }
  };

  /** スクロールのバグ対策を行うイベントを追加する関数 */
  const scrollLockFixAdd = (element: HTMLElement) => {
    const canScrollElement = element.querySelector<HTMLDivElement>(`#${MODAL_ID}`);

    if (canScrollElement === null) return;

    canScrollElement.addEventListener('scroll', scrollLockFix);
  };

  /** スクロールのバグ対策を行うイベントを削除する関数 */
  const scrollLockFixRemove = (element: HTMLElement) => {
    const canScrollElement = element.querySelector<HTMLDivElement>(`#${MODAL_ID}`);

    if (canScrollElement === null) return;

    canScrollElement.removeEventListener('scroll', scrollLockFix);
  };

  /** モーダルを開いているときに背面コンテンツのスクロールを全デバイスで抑制する関数 */
  const scrollStopBackContent = () => {
    const canScrollModalElement = document.querySelector<HTMLDivElement>(`#${MODAL_ID}`);

    if (canScrollModalElement === null || !isModalOpen) return;

    // デスクトップ向けの処理
    document.body.style.overflowY = 'hidden';
    // iOS向けの処理
    scrollLockFixAdd(canScrollModalElement);
    document.addEventListener('touchmove', scrollLock, { passive: false });

    return () => {
      document.body.style.overflowY = 'auto';
      scrollLockFixRemove(canScrollModalElement);
      document.removeEventListener('touchmove', scrollLock);
    };
  };

  useEffect(scrollStopBackContent, [isModalOpen]);
};

HTMLでモーダルUIを作るときに気をつけたいこと の記事の中で掲載されているGitHubのコードを参考に、自分でuseModalScrollLockというカスタムフックを作成して、それをModalComponent内部で呼び出してみた。

結論、実際にiOS端末でもModalの背面コンテンツのスクロールを抑制できた。

これで、全デバイスでモーダルの背面コンテンツのスクロール抑制が可能!

参考文献