iOSを“区別”したモーダル背景のスクロール固定

# ※25/05/20追記

いつの間にやら `overflow: clip` や `overscroll-behavior` という素晴らしいCSSプロパティが追加されていましたのでこちらの記事の内容についてはお役御免かもしれません:pray:

https://caniuse.com/mdn-css_types_overflow_clip
https://caniuse.com/css-overscroll-behavior

```css:CSS
body.is-fixed {
  overflow: clip; /* hiddenでもいいけど相変わらずsafariでは無効のはず */
  overscroll-behavior: none;
}
```

ただ **iOS16以降** でないと機能しないのと、SafariやFirefoxでは `body` ではなく `html` に指定しないといけないらしく結局フォールバックが必要です。

```css:CSS
html:has(body.is-fixed) {
  overscroll-behavior: none; /* バウンスありにしたければ contain でもいいかも */
}
```

古のSafari環境とバトルしないといけない場合は引き続き参考にしてやってください……。

# 概要

つい先日Win7のサポートが終わり、2020年4月以降にはEdgeもChromium製にアップデートされるとのことで、ようやくIEの壁から目を逸らせる雰囲気になってきた先にそり立つ第二の壁ことiOS Safari（個人の感想です）。

数多のトラップがiOSのアップデートのたびに仕込まれるこのブラウザですが、なまじ日本における（iPhoneの）シェア率が高いばかりに泣く泣く対応せねばなりません。どこかで聞いた話ですね:thinking_face:

今回はその中でもモーダルウィンドウを用いるサイトの対応について、iOSかそれ以外かを区別することでうまいこと実装できた気がするので備忘録として投稿します。

**あくまで差別ではなく&ldquo;区別&rdquo;です。**

~~……iOS版の[Vivaldi](https://vivaldi.com/ja/)はやく出ないかな。（Safariを虚ろな目で見つめつつ）~~
25/05/20追記：[ほんとに出た](https://vivaldi.com/ja/ios/
)けど結局webkitベースなのは変わらずですね……おのれ邪鬼王

# 要望と仕様

モーダルウィンドウなどのコンテンツに被る情報が表示されているあいだ、背景となったコンテンツ……つまり `body` 部のスクロールは**動かないよう固定される**べきであると考えられます。
背景まる見えレベルのサイズ感であれば許されるかもしれませんが、画面をほとんど覆うような大きさのモーダルの場合、弄っている間にいつの間にかモーダルを開いた時とは全く違う場所までスクロールしていた……となるとユーザに混乱を招くことになりかねないので、固定する方が自然な挙動だと言えます。（お客様からの要望としてもよく言われます）

加えて、モーダルウィンドウで表示される情報が一画面内に収まるとは限りません。
PCのデザインや開発者ツール上では十分収まっている量でも、画面の小さいスマートフォンや横向きにした時（見辛いことこの上なしですが……）、または~~ヒョコヒョコとウザい~~ツールバーの影響などによって想定よりも狭い表示領域で見られることを考えると、**モーダルウィンドウ内もスクロールに対応**するのが柔軟性のある実装だと言えるでしょう。

こうした場合、`touchmove` イベントを`e.preventDefault();` などでキャンセルする~~暴力的~~方法は、モーダル内のスクロールも禁止してしまうため使えません。きちんと影響範囲を把握して実装すれば可能ではありますが、**そのためだけにタッチイベントを制御する**のは別の問題が発生しやすく、手間もリスクも多いと考えます。

よって、手早く背景 `body` のスクロールを固定する方法は、おおむね2つに絞られます。

- `body` に `overflow: hidden;` をかける
- `body` を `position: fixed;` にし、その時点のスクロール位置だけ表示をズラす

## シンプルな実装

実装がシンプルかつ分かりやすいのは前者の `overflow: hidden;` です。

```css:CSS
body {
  overflow: hidden;
}
```

 `body` （表示領域）外のコンテンツが省略されるため、ほとんどのブラウザではこれだけでスクロールを固定することができます。
モーダルを閉じる時にも `overflow:hidden;` を解除するだけ。超超簡単……（カゴノトリ）

**そう、iPhone（iOS）&ldquo;以外&rdquo;ならね。**

## 面倒な実装

**iOS（iPadOSを含む）のSafariのみ**、先ほどの実装ではスクロールを止めることができません。
そのため、結局は後者の `position: fixed;` による実装が必要となります。

```css:CSS
body {
  width: 100%; /* position:fixed;になった際に幅が変わるのを防ぐ */
  position: fixed;
  top: -XXXpx; /* モーダルを開いた地点のスクロール量（XXX）だけズラす */
}
```

`body` に `position: fixed;` をかけることでスクロールを無効化されるため、iOS Safariを含めた全てのブラウザで強制的にスクロールを固定することが可能です。（この際、 `body` の内容によっては表示幅が変わってしまうことがあるため、保険として`width: 100%;`をかけておきましょう）

ただ、`position: fixed;` をかけた時点でこれまでのスクロール情報が失われてしまう（表示がページトップに戻ってしまう）ため、 `top` プロパティによって**モーダルを開いた地点のスクロール量だけbodyをマイナス（ネガティブ）方向にズラす**ことで表示位置が変わらないように見せかける必要があります。手間ですね:cry:

また、先ほどの方法と同様モーダルを閉じた際には `position: fixed;` を解除するのですが、その際にも失われたスクロール情報は戻らないので、今度は**スクロール位置をモーダルを開く前と同じ位置に自力で戻す**必要があります。二度手間ですね:sob:

……愚痴を言っても仕方ないので、モーダルを開いた時点のスクロール量 `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;|×|<font color="Red">○</font>|<font color="Red">◎</font>|
|position: fixed;|<font color="Red">○</font>|△|<font color="Red">○</font>|
|（併用）|△|△|<font color="Red">○</font>|

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の方で適用していてもあまり問題なさそうなので、先に書いておきます。この辺りは好みだと思います。

```css:CSS
body {
  width: 100%; /* position:fixed;になった際に幅が変わるのを防ぐ */
}
```

準備が済んだところで、スクロール固定・解除用の関数を作成します。

```js:jQuery
//モーダルを開いた時のスクロール位置を保持
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できちんと書くならこういう感じ……？（テンプレート文字列って便利ですね）

```js:JavaScript(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**などはスクロールバーごと非表示にする以外どうしようもなさそう……？

結論としては**なんでもかんでもモーダルに頼るのをやめる**のがよさそうというオチでした。やめて（懇願）