モーダル内の特定要素だけスクロールする処理を作る時のポイント

## はじめに

最近、スマホ向けのサイトを作っていて、モーダルをオープンした時に

  - モーダル内のコンテンツはスクロール可能
  - 背景側に配置される元の本文はスクロール不可

という機能を作っていて、PC環境では問題ないのにスマホの実機で動作確認すると、以下のような状態になってしまってハマってました。

![2019-04-04-swipe-issue.002.jpeg](https://qiita-image-store.s3.amazonaws.com/0/10187/a28ae4e0-cee7-64d7-6ec9-cb33f26068c9.jpeg)

解決方法知りたい人向けに先に触れておくと、ここでまとめた問題は[body-scroll-lock](https://github.com/willmcpo/body-scroll-lock) というライブラリで解決できました。

自分の備忘録も兼ねて、原因となった要因＆body-scroll-lockの実装についてちょっと触れておきます。


## 問題の要点

- モーダル要素の特定箇所をスクロールさせる時にiOS/Androidでscrollイベントの扱いが異なる。
  - そのためtouchmoveイベントを上手く使う必要ある
- touchmoveイベント発生した時に一定箇所まで到達していたらスクロールされないようにpreventDefaultを実行する必要ある
  - これを書いてなかったので、最初に図解したような症状が発生していた
- ブラウザ側のスクロールジャンク対策でリスナーで登録されたpreventDefaultは一切実行しなくなってるため **書き方を工夫しないとスクロール禁止が有効にならず** 結果としてスクロールされ続けたような状態になる


これ以降は、何故こういう状態になったのか個別の要素について調べたことについてまとめていきます。


## iOSのscrollイベントについての挙動

2013年とだいぶ古いですが、＠ITの[AndroidとiOSでは、イベントの発生タイミングが異なる](https://www.atmarkit.co.jp/ait/articles/1301/16/news029_3.html)で以下のような記述があります。

> コンテンツがスクロールされたときに発生するイベントです。最近、画面のスクロールに合わせて、サイドメニューが追従するような動きをするWebサイトが存在しますが、こういった動きを実現するときに利用するイベントになります。このイベントの発生タイミングが、iOSとAndroidで異なります。
> 
> iOSでは、スクロールが完了したタイミングで1回だけイベントが発生します。スクロールを行っている間は、イベントは発生しません。


モーダル内の要素に対して、連続的に処理を行いたい時に、scrollイベントだと上記の仕組み上期待した処理にならないかもしれないのでtouchmoveイベントで処理することも考慮しておかないといけないようです。

※ ちなみに、私はこの辺りの仕組みについて正しく把握しておらず、scrollイベントだけが発生してるのかと思っていたので問題解決に中々繋がらなかった :sweat: 


## スクロールイベント呼び出しに関連するスクロールジャンクについて

2017年とやや古いですがGoogleの[Making touch scrolling fast by default](https://developers.google.com/web/updates/2017/01/scrolling-intervention)のBackground: Cancelable Events slow your page downで以下のようなことが書かれてます。


> If you call preventDefault() in the touchstart or first touchmove events then you will prevent scrolling. The problem is that most often listeners will not call preventDefault(), but the browser needs to wait for the event to finish to be sure of that. Developer-defined "passive event listeners" solve this. 


上記の説明だと

- ほんどのイベントリスナーはpreventDefaultの呼び出しをしない。
- でもブラウザは **それ（preventDefaultの呼び出し）が無いこと確認するまで**イベントを待つ必要がある。

という状況のようでJavaScriptが止まってしまう状況が発生するようです。

### 英語では単にjankと言ってるけど関連する日本語の情報だとスクロールジャンクと読んでる

Googleの[Find and Fix Web App Performance Issues](https://developers.google.com/web/fundamentals/codelabs/web-perf/?hl=en)によると、jank というようです。

ただ、日本語の情報で関連する情報を検索すると、例えば[Qiitaの2019年、JavaScriptでのスクロール一時禁止はこれだ！（スマートフォン）](https://qiita.com/yukiTTT/items/773356c2483b96c9d4e0)で


> スクロールする時、登録されたすべてのイベントの中にpreventdefault();がないかを確認するまで、JavaScriptが止まっちゃう。
> だから遅延が発生しパフォーマンスが下がってしまうというわけか。
> こういうのをスクロールジャンクと呼ぶらしいです。


に代表されるようにスクロールジャンクという言い方をしてるサイトが大半のようです。

この問題について色々調べる時には、スクロールジャンクという言い回しの方が関連する情報見つけやすいのでその言い方を使うことにします。

## スクロールジャンク発生しないようにブラウザが気を利かせてる

[MDNのパッシブリスナーを用いたスクロールの性能改善で](https://developer.mozilla.org/ja/docs/Web/API/EventTarget/addEventListener#Improving_scrolling_performance_with_passive_listeners)


> According to the specification, the default value for the passive option is always false. 
〜中略〜
> To prevent this problem, some browsers (specifically, Chrome and Firefox) have changed the default value of > the passive option to true for the touchstart and touchmove events on the document-level nodes Window, Document, and Document.body.


とあります。

ポイントまとめると

- ブラウザが持ってるイベントリスナーの仕様の中にpassive optionという項目があり **仕様だと false** になってる
- でもスクロールジャンク発生しないようにデフォルトでブラウザが気を利かせてて **passive optionをtrue** にしてる
- このpassive optionのtrue設定によってリスナーで登録されたpreventDefaultは一切実行しなくなる

ということになります。

## 結局、スマホ向けでモーダル内の特定要素だけスクロールする処理を作る時に考えないといけないことは？

箇条書きにすると、以下を考慮する必要があるかと思います。

1. モーダル要素以外を何らかの形で固定する
2. モーダル要素内は[CSSのoverflow](https://developer.mozilla.org/ja/docs/Web/CSS/overflow)などを使ってスクロール可能にする
3. モーダルの特定要素に対して適切な制御をするためにtouchmove/touchstartイベント発火時の処理を記述する。

上記の１と３の部分は冒頭で触れたbody-scroll-lock使うことで解決できました。

上記で上げてるような問題点をbody-scroll-lockがどのように対処してるのか該当コードについて触れていきます。

## body-scroll-lockでのtouchmoveイベントに関する処理

2019/04/05時点の実装ですが、[body-scroll-lockの119行目から138行目](https://github.com/willmcpo/body-scroll-lock/blob/master/src/bodyScrollLock.js#L119-L138)あたりで

1. touchmoveイベント発火
2. 該当のHTML要素がスクロール可能な上限/下限に達してるかどうかを判定
3. 判定結果に応じて内部で定義してるpreventDefaultメソッドを呼び出す

ということが行われてます。

実装を以下引用しておきます

```javascript
const handleScroll = (event: HandleScrollEvent, targetElement: any): boolean => {
  const clientY = event.targetTouches[0].clientY - initialClientY;

  if (allowTouchMove(event.target)) {
    return false;
  }

  if (targetElement && targetElement.scrollTop === 0 && clientY > 0) {
    // element is at the top of its scroll
    return preventDefault(event);
  }

  if (isTargetElementTotallyScrolled(targetElement) && clientY < 0) {
    // element is at the top of its scroll
    return preventDefault(event);
  }

  event.stopPropagation();
  return true;
};
```

## body-scroll-lockでのpassive optionを上手く使ってすぐにスクロールを停止するかどうか制御

[body-scroll-lockの140行目から187行目](https://github.com/willmcpo/body-scroll-lock/blob/master/src/bodyScrollLock.js#L140-L187)

になりますが、一部余計なコメントを削除&修正して以下引用します。

```javascript
export const disableBodyScroll = (targetElement: any, options?: BodyScrollOptions): void => {
  if (isIosDevice) {

    // 省略

    if (targetElement && !locks.some(lock => lock.targetElement === targetElement)) {
      const lock = {
        targetElement,
        options: options || {},
      };

      locks = [...locks, lock];

      targetElement.ontouchstart = (event: HandleScrollEvent) => {
        if (event.targetTouches.length === 1) {
          initialClientY = event.targetTouches[0].clientY;
        }
      };
      targetElement.ontouchmove = (event: HandleScrollEvent) => {
        if (event.targetTouches.length === 1) {
          handleScroll(event, targetElement);
        }
      };

      if (!documentListenerAdded) {
        document.addEventListener('touchmove', preventDefault, hasPassiveEvents ? { passive: false } : undefined);
        documentListenerAdded = true;
      }
      // 省略
```

上記の実装の中で

- ontouchmoveの中でhandleScrollメソッドが呼ばれて、そちらで現在のスクロール量をチェックしつつ、スクロール可能かどうか制御してる
- スクロールジャンク対応でpassiveをfalseに設定

のようなことが対応されてます。


## 参考情報

本文中で引用してる参考情報を改めて以下まとめておきます

- [Qiita：2019年、JavaScriptでのスクロール一時禁止はこれだ！（スマートフォン）](https://qiita.com/yukiTTT/items/773356c2483b96c9d4e0)
  - とてもお世話になりました 
- [@IT：AndroidとiOSでは、イベントの発生タイミングが異なる](https://www.atmarkit.co.jp/ait/articles/1301/16/news029_3.html)
- [Google：Find and Fix Web App Performance Issues](https://developers.google.com/web/fundamentals/codelabs/web-perf/?hl=en)
- [Google：Making touch scrolling fast by default](https://developers.google.com/web/updates/2017/01/scrolling-intervention)
- [MDN：パッシブリスナーを用いたスクロールの性能改善](https://developer.mozilla.org/ja/docs/Web/API/EventTarget/addEventListener#Improving_scrolling_performance_with_passive_listeners)