【React】下スワイプで閉じる Bottom Sheet を実装してみた

この記事は、[Systemi（株式会社システムアイ） Advent Calendar 2024](https://qiita.com/advent-calendar/2024/systemi24)、5日目の記事です。

---

皆さんは、ボトムシート（Bottom Sheet）をご存知でしょうか。UIコンポーネントの一つで、画面の下部からスライドして表示されるパネルです。
特にスマホアプリにおいては、画面サイズや操作性の良さから非常によく使われます。しかし、Webではどうでしょうか。小さいスマホ画面を大きく使える点は変わりませんが、操作性においてはスマホのネイティブアプリに比べたら一歩劣り[^web-bottom-sheet]、あまりWebアプリでは使われていないのではないでしょうか。

[^web-bottom-sheet]: Webのボトムシートは、ボトムシートと言いつつただの見た目違いモーダルだったり、下スワイプで閉じる際はジェスチャー終了のトリガーで閉じるアニメーションを発火するタイプでちょっとラグがあったり、ネイティブアプリのものを真似た劣化版と思っています（個人の感想です）

そんな中、ボトムシートのメリットの1つである（と私が考えている）「閉じやすさ＝下スワイプで閉じるアクション」を実装できたので、紹介したいと思います。

こんな感じに、ネイティブアプリのように下スワイプで閉じることができます。

![ボトムシートデモ.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149221/a0a60684-4c43-c0ec-9843-e01835549560.gif)


デモはこちら
※スマホ等で試してみてください

https://imasanari.github.io/react-bottom-sheet-demo/

ネイティブアプリのような「スワイプ中の指に追従し、慣性で閉じる」タイプです。ぜひ触ってみてください。

## そもそもなぜボトムシートを自作したのか

実は「下スワイプで閉じる」の実装が目的ではなく、iPhoneの戻るジェスチャーで閉じるを実装しようとしたのがきっかけでした。
その時は、ヘッドレスUIライブラリのモーダルを使用してモーダルボトムシートを実装していました。History API を使い、ページバックでボトムシートを閉じることはできたのですが、iPhoneで動作確認してみると、戻るジェスチャー（画面左端からスワイプ）してもページバックできない現象が発生しました。

これは、UIライブラリ側にiOSのbodyスクロールをロックするために、touchmoveイベントを無効化する処理が書かれていたためです。この影響で、戻るジェスチャーも無効化されていました。
なので、touchmoveイベント無効化以外の方法でbodyスクロールロックするボトムシートを作ろうとしたのがスタートで、後述する `overscroll-behavior` を使ったら「下スワイプで閉じる機能」がおまけで付いてきた、という感じです。

## 実装詳細

HTMLのdialog要素をベースに、基本的な動作はCSSで実現しています。JavaScriptは補助的にしか使用していないので、ネイティブアプリのようなスムーズなアクションになっているかと思います。

### `overscroll-behavior`

ジェスチャーを有効にしつつbodyのスクロールをロックするために、`overscroll-behavior: none;` というCSSを使用しました。これはスクロール領域の境界の動作を制御するもので、特定の条件下であればスクロールの連鎖（bodyのスクロール）を止めることができます。

その条件とは、スクロールバーによるスクロールができる状態であることです。`overflow-y: scroll;` を指定していても、子コンテンツ量が少なくスクロールが発生しない場合には `overscroll-behavior` は機能しません。

そのため、このCSSプロパティを使用してモーダルのbodyスクロールを止めたいのであれば、モーダル上に常にスクロール可能な領域を用意しておかなければなりません。そこで、今回実装したボトムシートでは、モーダルを閉じるアクションにスクロールを取り入れることで、必ずスクロールを発生させることにしました。

![スクロールロック.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/149221/b13bb0eb-9967-a1c5-a53b-d65cfa99b7b5.png)

### `scroll-snap-*`

上記の `overscroll-behavior` だけでは、単にスクロールが増えただけで下スワイプのアクション感がありません。また、中途半端な位置でスクロールが止まってほしくないです。そこで使用したのが、`scroll-snap-type` と `scroll-snap-align` です。スクロールする要素に `scroll-snap-type: y mandatory;` を設定することで、スクロール時に子要素の `scroll-snap-align` で指定したスナップ位置に吸い付くようにスナップされます。これによって、ボトムシートを開いた状態もしくは閉じた状態でスナップされるようになります。

### `scrollbar-width`

上記の下スワイプ用スクロールのスクロールバーは表示されたくないので、下記のCSSで消しておきました。

```css
/* CSSのイメージ */
dialog {
  -ms-overflow-style: none;
  scrollbar-width: none;
}
dialog::-webkit-scrollbar {
  display: none;
}
```

### その他CSS

dialog要素は通常、背景は `::backdrop` 、モーダルのコンテンツはdialog要素自身となります。しかし上記のスクロールギミックを用意する都合上、モーダルのコンテンツは子要素で作る必要があります。そのため、下記のCSSでdialog要素を裏方にまわしています。

```css
dialog {
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  max-width: none;
  max-height: none;
  padding: 80px 0 0;
  margin: 0;
  border: 0 none;
  background-color: transparent;
}
```

そして、dialogの子要素でボトムシートの形を作成しています。

```css
dialog .dialog-content {
  padding-top: 24px;
  border-radius: 16px 16px 0 0;
  background-color: #fff;
}
```

### JavaScript処理について

JavaScriptで行っている処理は、主に2つです。

dialog要素の表示時、スクロールギミックの都合でボトムシートが画面外になっていますので、 `scrollIntoView` で表示領域に持ってきます。

```js
dialog.showModal()
dialogBottomSheet.scrollIntoView(false)
```

逆に、スクロール位置がほぼ0になったときにdialog要素を閉じる処理を入れます。これにより、ボトムシートが下スワイプにより引っ込んだタイミングで閉じるようになります。

```js
const handleScroll = () => {
  // スナップポイント付近で減速するので、
  // 閉じるタイミングを0ピッタリではなく、0付近になったときにしている。
  // なお、9という数字は適当なので、要調整。
  if (dialog.scrollTop < 9) {
    dialog.close()
  }
}
```

実際は説明用に削った部分等々あるので多少違いはありますが、概ね上記の処理でボトムシートを実装しています。

### その他、React的な処理

dialog要素を使う場合、そのコンテンツ内に `autofocus` をつけるのはほぼ必須らしい[^requierd-autofocus]です。しかし、Reactでの `autoFocus` は独自処理が走るため、dialog要素内ではエラーになり使えません。そのため、useEffectで生DOM APIを使って `autofocus` を付けています。

[^requierd-autofocus]: https://zenn.dev/yusukehirao/articles/e5df3d60c99e91

```js
useEffect(() => {
  // https://github.com/facebook/react/issues/23301#issuecomment-1915324737
  dialogRef.current?.setAttribute('autofocus', 'true')
}, [])
```

また、dialog要素の開閉にも癖があります。dialog要素の `showModal()` は命令的なAPIのため、よくあるReactのモーダル風に `open` プロパティで扱おうとすると、`useEffect` で処理する必要があります。そして、`useEffect` は`<StrictMode>` 下では2回走るのですが、高速に開く閉じるを行うとなぜか反応しないため、ちょっと複雑になっています。


```js
useEffect(() => {
  const dialog = dialogRef.current
  if (!dialog) return

  if (open) {
    // 一部のブラウザでは、既にopen中のモーダルは再度開けず、
    // `<StrictMode>` で2回実行されることがあるので、チェックしている
    if (!dialog.open) {
      dialog.showModal()
    }

    return () => {
      // `<StrictMode>` 中の2回実行時、下記だとうまくいかない
      // dialog.close()
    }
  } else {
    dialog.close()
  }
}, [open])
```

dialog要素を使わないという手もありますが、下手に手動でアクセシビリティ対応等を行うよりも最初から対応しているものを使用したほうが良いと判断して、dialog要素を使用しています。

## 最後に

閉じるのジェスチャー動作をCSSで行っているので、ネイティブアプリのような操作感ではないでしょうか。ぜひ、ボトムシートを実装したいと思ったら、この記事を思い出してみてください。

今回デモに使った実装はこちら

https://github.com/iMasanari/react-bottom-sheet-demo