🦴

【2023年】iOSにおけるモーダルウィンドウの背景固定(スクロール抑止)&下までスクロールできない問題の解決方法

2023/01/25に公開

はじめに

今回は、モーダルウィンドウが開かれた時に背景を固定(背面コンテンツのスクロールを抑止)する方法を改めて紹介したいと思います。

以前まで、PCやAndroidでは比較的簡単に実現させることができましたが、iPhoneもしくはiPad(iOS or iPadOS)では別の方法で実装しなければ、実現できませんでした。

しかし、それはもう過去の話。今ではもう、簡単に実現できます🙆‍♂️

※ただし考慮すべき点はあるので、最後まで読み進めていただければ幸いです。

iOSにおけるモーダルの背景固定が楽になっている!?

PCやAndroidでは容易に実現できるものの、つい数年前までiOS端末では同じ方法で実現できない背景がありました。しかし現在では、以下いずれかの方法を使えばiOSでも容易に実現することができます。

bodyにoverflow: hiddenを設定

モーダルウィンドウが開かれたときに、bodyoverflow: hidden;を設定することができれば、背景のスクロールを無効にできます。

body {
  overflow: hidden; /* モーダルが開かれた場合にのみ動的に設定する必要有り */
}

今まで、上記方法ではiOS端末だと背景固定できず、別の実装が必要でした(詳細は後述)が、iOS16からは上記設定でもiOSで背景固定できるようになりました。最高です。

スクロールを有効させたい要素にoverscroll-behaviorを設定

もしくは、overscroll-behaviorを使えば、開かれているモーダル内のスクロールのみを有効にできるようになりました。モーダルが開かれたときに、上記方法のように背景を固定するためのスタイルを動的に付与する必要もありません。神です。

<div class="modal">
  <div class="modal-contents"></div>
</div>
.modal {
  height: 300px;
  overflow-y: auto;
  overscroll-behavior-y: contain; /* スクロール可能な要素に付与する必要有り */
}
.modal-contents {
  height: 1000px;
}

【参考】モーダルUI等のスクロール連鎖を防ぐ待望のCSS

ちなみに、これもiOS16から利用できる方法です。

2023年8月現在、下記サイトを確認するとiOS16未満のiPhoneを使っている人はまだ20%程度存在するとのデータが出ているため、上記方法に頼り切るのは難しいところもありそうです。
https://gs.statcounter.com/ios-version-market-share/mobile/worldwide/#monthly-202212-202301-bar

iOS16未満のiPhoneを考慮する場合

iOS16未満のiPhoneを考慮して実装する必要がある場合、以下2つの方法のいずれかで背景固定(背面コンテンツのスクロール抑止)を実現できます。

スクロール位置を保持した上でbodyposition: fixedを設定する

多くのWebサイトでは、下記記事で紹介されているような方法が使われています。私自身、以前勤めていたWeb制作会社でWebサイトを量産していたときは、共通処理化して汎用的に使えるようにしていました。
https://myscreate.com/optional-modal/

React(Next.js)で実現したい場合は、以下のようなカスタムフックuseBodyFixedを作成するのも良いかもしれません。

hooks/useBodyFixed.ts
import { useEffect, useRef, useState } from "react";

const isIOSUserAgent = () => {
  const ua = window.navigator.userAgent.toLowerCase();
  const isIOS =
    ua.indexOf("iphone") > -1 ||
    ua.indexOf("ipad") > -1 ||
    (ua.indexOf("macintosh") > -1 && "ontouchend" in document);
  return isIOS;
};

export const useBodyFixed = () => {
  const [bodyFixed, setBodyFixed] = useState<boolean>(false);
  const [scrollPosition, setScrollPosition] = useState<number>(0);
  const isFirstRender = useRef(false);

  useEffect(() => {
    // 初回レンダリング時は発火しないようにする
    if (!isFirstRender.current) {
      isFirstRender.current = true;
      return;
    }

    const body = document.querySelector("body");
    if (!body) return;

    const isIOS = isIOSUserAgent();

    if (bodyFixed) {
      if (isIOS) {
        setScrollPosition(window.pageYOffset);
        body.style.position = "fixed";
        body.style.top = `-${scrollPosition}px`;
      } else {
        body.style.overflow = "hidden";
      }
    } else {
      if (isIOS) {
        body.style.removeProperty("position");
        body.style.removeProperty("top");
        window.scrollTo(0, scrollPosition);
      } else {
        body.style.removeProperty("overflow");
      }
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [bodyFixed]);

  return { bodyFixed, setBodyFixed };
};

利用例は以下の通りです。今回は、Next.jsでサポートされているstyled-jsxを使って、モーダルの表示非表示を動的に変更していますが、利用CSSライブラリに応じて適宜書き換えてください。

pages/index.ts
import { useBodyFixed } from "@/lib/common/useBodyFixed";

export const Menu = () => {
  const { bodyFixed, setBodyFixed } = useBodyFixed();
  return (
    <>
      <button onClick={() => setBodyFixed(true)}>
        モーダルウィンドウを開く
      </button>
      <div className="modal">
        <button onClick={() => setBodyFixed(false)}>
          モーダルウィンドウを閉じる
        </button>
      </div>
      <style jsx>{`
        .modal {
          position: fixed;
          top: 0;
          right: 0;
          bottom: 0;
          left: 0;
          display: ${bodyFixed ? "block" : "none"};
        }
      `}</style>
    </>
  );
};

モーダル内を下までスクロールできない問題の解決

iOSによるモーダルウィンドウの表示に関する問題は、背景固定方法がややこしいだけではありません。iOSの場合、下記実装では、全画面で表示させたいモーダル内の最下部にある項目までスクロールできませんでした。

<div class="modal">
  ここにモーダルで表示させたい要素が入ります。
</div>
.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%
  height: 100vh;
  overflow-y: auto;
}

モーダルを全画面表示にすることを目的にheight: 100vhを設定すると、iPhoneのアドレスバーの表示・非表示によって動的に変化するビューポートの高さを超えてしまうことが原因です。

この問題を解決するためにはCSSでの実装を工夫したり、JavaScriptで画面の高さを動的に取得する必要がありました。しかし現在では、CSSに1行記述を追加するだけで解決できます。

.modal {
  height: 100vh;
+ height: 100dvh;
  overflow-y: auto;
}

モーダル要素にheight: 100dvhを設定することで、動的に変化するビューポートの高さに応じて、モーダルウィンドウの高さも変化するようになります。結果的に、下までスクロールできない問題が解決できます。神です。

ただし、このdvhという単位も、iOS15.4未満では対応していないことに注意してください。未対応の端末で見られることを想定して、height: 100vhはそのまま設定しておくことで、せめて高さは画面いっぱいに表示されるように設定しておきましょう。

iOS15.4未満を考慮する場合

以下のようにCSSを変更してみてください。モーダルの幅や高さを設定せず、親要素からの相対位置に設定することで、ビューポートの動的なサイズ変更にも対応できるようになります。

.modal {
  position: fixed;
  top: 0;
  left: 0;
- width: 100%
- height: 100vh;
+ bottom: 0;
+ right: 0;
  overflow-y: auto;
}

ただし、本方法はposition: fixedの設定が必要になるため、モーダル以外で全画面表示させたい要素がある場合は、下記記事で紹介されているようなウィンドウの高さをJavaScriptで動的に取得する方法などをオススメします。
https://zenn.dev/tak_dcxi/articles/2ac77656aa94c2cd40bf

今回は以上です。ここまで読んでいただき、ありがとうございます🙏

LCL Engineers

Discussion