※25/05/20追記
いつの間にやら overflow: clip や overscroll-behavior という素晴らしいCSSプロパティが追加されていましたのでこちらの記事の内容についてはお役御免かもしれません
https://caniuse.com/mdn-css_types_overflow_clip
https://caniuse.com/css-overscroll-behavior
body.is-fixed {
  overflow: clip; /* hiddenでもいいけど相変わらずsafariでは無効のはず */
  overscroll-behavior: none;
}
ただ iOS16以降 でないと機能しないのと、SafariやFirefoxでは body ではなく html に指定しないといけないらしく結局フォールバックが必要です。
html:has(body.is-fixed) {
  overscroll-behavior: none; /* バウンスありにしたければ contain でもいいかも */
}
古のSafari環境とバトルしないといけない場合は引き続き参考にしてやってください……。
概要
つい先日Win7のサポートが終わり、2020年4月以降にはEdgeもChromium製にアップデートされるとのことで、ようやくIEの壁から目を逸らせる雰囲気になってきた先にそり立つ第二の壁ことiOS Safari(個人の感想です)。
数多のトラップがiOSのアップデートのたびに仕込まれるこのブラウザですが、なまじ日本における(iPhoneの)シェア率が高いばかりに泣く泣く対応せねばなりません。どこかで聞いた話ですね
今回はその中でもモーダルウィンドウを用いるサイトの対応について、iOSかそれ以外かを区別することでうまいこと実装できた気がするので備忘録として投稿します。
あくまで差別ではなく“区別”です。
……iOS版のVivaldiはやく出ないかな。(Safariを虚ろな目で見つめつつ)
25/05/20追記:ほんとに出たけど結局webkitベースなのは変わらずですね……おのれ邪鬼王
要望と仕様
モーダルウィンドウなどのコンテンツに被る情報が表示されているあいだ、背景となったコンテンツ……つまり body 部のスクロールは動かないよう固定されるべきであると考えられます。
背景まる見えレベルのサイズ感であれば許されるかもしれませんが、画面をほとんど覆うような大きさのモーダルの場合、弄っている間にいつの間にかモーダルを開いた時とは全く違う場所までスクロールしていた……となるとユーザに混乱を招くことになりかねないので、固定する方が自然な挙動だと言えます。(お客様からの要望としてもよく言われます)
加えて、モーダルウィンドウで表示される情報が一画面内に収まるとは限りません。
PCのデザインや開発者ツール上では十分収まっている量でも、画面の小さいスマートフォンや横向きにした時(見辛いことこの上なしですが……)、またはヒョコヒョコとウザいツールバーの影響などによって想定よりも狭い表示領域で見られることを考えると、モーダルウィンドウ内もスクロールに対応するのが柔軟性のある実装だと言えるでしょう。
こうした場合、touchmove イベントをe.preventDefault(); などでキャンセルする暴力的方法は、モーダル内のスクロールも禁止してしまうため使えません。きちんと影響範囲を把握して実装すれば可能ではありますが、そのためだけにタッチイベントを制御するのは別の問題が発生しやすく、手間もリスクも多いと考えます。
よって、手早く背景 body のスクロールを固定する方法は、おおむね2つに絞られます。
- 
bodyにoverflow: hidden;をかける
- 
bodyをposition: fixed;にし、その時点のスクロール位置だけ表示をズラす
シンプルな実装
実装がシンプルかつ分かりやすいのは前者の overflow: hidden; です。
body {
  overflow: hidden;
}
body (表示領域)外のコンテンツが省略されるため、ほとんどのブラウザではこれだけでスクロールを固定することができます。
モーダルを閉じる時にも overflow:hidden; を解除するだけ。超超簡単……(カゴノトリ)
そう、iPhone(iOS)“以外”ならね。
面倒な実装
iOS(iPadOSを含む)のSafariのみ、先ほどの実装ではスクロールを止めることができません。
そのため、結局は後者の position: fixed; による実装が必要となります。
body {
  width: 100%; /* position:fixed;になった際に幅が変わるのを防ぐ */
  position: fixed;
  top: -XXXpx; /* モーダルを開いた地点のスクロール量(XXX)だけズラす */
}
body に position: fixed; をかけることでスクロールを無効化されるため、iOS Safariを含めた全てのブラウザで強制的にスクロールを固定することが可能です。(この際、 body の内容によっては表示幅が変わってしまうことがあるため、保険としてwidth: 100%;をかけておきましょう)
ただ、position: fixed; をかけた時点でこれまでのスクロール情報が失われてしまう(表示がページトップに戻ってしまう)ため、 top プロパティによってモーダルを開いた地点のスクロール量だけbodyをマイナス(ネガティブ)方向にズラすことで表示位置が変わらないように見せかける必要があります。手間ですね
また、先ほどの方法と同様モーダルを閉じた際には position: fixed; を解除するのですが、その際にも失われたスクロール情報は戻らないので、今度はスクロール位置をモーダルを開く前と同じ位置に自力で戻す必要があります。二度手間ですね
……愚痴を言っても仕方ないので、モーダルを開いた時点のスクロール量 XXX をJSのグローバル変数に逐一保持しておくことで、これらの問題を解決します。
さらに面倒な仕様
しかし、position: fixed; を使う方法にもいくつかの穴があります。
ひとつはスクロール情報が失われることにより、スクロール量に応じて表示が切り替わるコンテンツに影響が出ます。例えばスクロールすると表示されるページトップに戻るボタンであったり、position: sticky; ないしはそれに近い動きをJSで実装しているものはスクロールを固定した途端に表示が消えます。ただ、これらはスクロールの固定を解除した後にはきちんと元の状態に戻るはずなので、些細な問題ではあります。
別の問題としてはMacOS(PC)のSafariで見た場合に、position: fixed; によって「ページトップに戻った」瞬間が見えてしまうことがあります。つまりモーダルを開閉するたびにページトップが一瞬チラつくような表示が稀に発生するため、モーダルを開く箇所が多いサイトだと地味に鬱陶しく感じます。IEですら起きないのに
また、よく他サイトなどで紹介されている方法として overflow: hidden; と  position: fixed; を併用している(2つの実装が合体したような)ものをよく見ますが、そうするとiOSのSafariでモーダルの開閉時に一瞬白いチラつきが発生することがありました。これは overflow: hidden; がなければ発生しないようなので、上記のCSS例では省いています。同じSafariって名前なら挙動ぐらい統一してくれ
ともあれ、起こってしまうものは仕方ありません。それぞれの環境に適した実装対応を表にまとめてみました。
| iOS Safari | MacOS Safari | それ以外 | |
|---|---|---|---|
| overflow: hidden; | × | ○ | ◎ | 
| position: fixed; | ○ | △ | ○ | 
| (併用) | △ | △ | ○ | 
iOS, MacOS以外の環境(WinやAndroid等)はどちらの方法でもほぼ同じ挙動にはなるのですが、前述した通り overflow: hidden; の方がシンプルかつ確実な方法なので二重丸つけてます。
さらにまとめるとこうなります。
- iOS: position: fixed;で対応
- MacOS、それ以外: overflow: hidden;で対応
つまり、iOSか否かを区別できれば、それぞれに適した方法でスクロールを固定することができそうです。
iOSかどうかをチェックするUA(ユーザーエージェント)は以下を参考にさせていただきました。
https://qiita.com/mtdune/items/97abb9c0bd926d4c8a13
実装
position: fixed; 対策の width: 100%; は予めCSSの方で適用していてもあまり問題なさそうなので、先に書いておきます。この辺りは好みだと思います。
body {
  width: 100%; /* position:fixed;になった際に幅が変わるのを防ぐ */
}
準備が済んだところで、スクロール固定・解除用の関数を作成します。
//モーダルを開いた時のスクロール位置を保持
var scrollPosition;
//iOS(iPadOSを含む)かどうかのUA判定
var ua = window.navigator.userAgent.toLowerCase();
var isiOS = ua.indexOf('iphone') > -1 || ua.indexOf('ipad') > -1 || ua.indexOf('macintosh') > -1 && 'ontouchend' in document;
//bodyのスクロール固定
function bodyFixedOn() {
    if(isiOS){
        // iOSの場合
        scrollPosition = $(window).scrollTop();
        $('body').css('position', 'fixed');
        $('body').css('top', '-' + scrollPosition + 'px');
    }else {
        // それ以外
        $('body').css('overflow', 'hidden');
    }
}
//bodyのスクロール固定を解除
function bodyFixedOff() {
    if(isiOS){
        // iOSの場合
        $('body').css('position', '');
        $('body').css('top', '');
        $(window).scrollTop(scrollPosition);
    }else {
        // それ以外
        $('body').css('overflow', '');
    }
}
これでモーダルを開く際に bodyFixedOn() を読み込むことで、
- iOS(iPhone, iPad): position:fixed;による固定
- それ以外のPC(Win,Mac)やAndroid等: overflow:hidden;による固定
が成されるようになります。
また、モーダルを閉じる際には bodyFixedOff() で解除すれば元通り。
ちなみに、上記の条件だと端末部分のみの判定となるため、iOSアプリ版のChromeなどを使った場合であっても同じくiOSに該当するかと思います(iOSだとSafariしかちゃんと開発者ツールでの検証ができない……)。
ただ、そういった環境でもチェックした限り挙動としてはほとんど問題はないかなと思っています。
とはいえ、そもそもUAを廃止する流れやiOSのアップデートによってコロコロ仕様が変わることを考えるとこの方法も永久に使えるわけではなさそうなので、ひとまず現在(2020年1月)時点の暫定対応というつもりです。備えよう。無理……しんどい……
おまけ
ES6できちんと書くならこういう感じ……?(テンプレート文字列って便利ですね)
let scrollPosition;
const ua = window.navigator.userAgent.toLowerCase();
const isiOS = ua.indexOf('iphone') > -1 || ua.indexOf('ipad') > -1 || ua.indexOf('macintosh') > -1 && 'ontouchend' in document;
const body = document.querySelector('body');
function bodyFixedOn() {
    if(isiOS){
        scrollPosition = window.pageYOffset;
        body.style.position = 'fixed';
        body.style.top = `-${scrollPosition}px`;
    }else {
        body.style.overflow = 'hidden';
    }
}
function bodyFixedOff() {
    if(isiOS){
        body.style.removeProperty('position');
        body.style.removeProperty('top');
        window.scrollTo(0, scrollPosition);
    }else {
        body.style.removeProperty('overflow');
    }
}
余談
この記事ではSafariばかりを目の敵にしてしまいましたが、今回紹介したどの方法でも「スクロールを固定する」=「スクロールバーが非表示になる」ので、スクロールバーの常時表示がデフォルトであるWindows環境ではスクロール固定時に画面がスクロールバーの幅の分だけ横にガタつくなどの問題もあります。
挙動自体は正しいものなので気になるなら〜程度ですが、対応策としてはいちおうIE/Edge(レガシ)専用ですが -ms-overflow-style: -ms-autohiding-scrollbar; あたりで強制的にauto-hide(コンテンツに被るスクロールバー)に変えてやるのが良さげです。ただWin版のFireFoxやChrome、Chromium版Edgeなどはスクロールバーごと非表示にする以外どうしようもなさそう……?
結論としてはなんでもかんでもモーダルに頼るのをやめるのがよさそうというオチでした。やめて(懇願)
