この記事は、Systemi(株式会社システムアイ) Advent Calendar 2024、5日目の記事です。
皆さんは、ボトムシート(Bottom Sheet)をご存知でしょうか。UIコンポーネントの一つで、画面の下部からスライドして表示されるパネルです。
特にスマホアプリにおいては、画面サイズや操作性の良さから非常によく使われます。しかし、Webではどうでしょうか。小さいスマホ画面を大きく使える点は変わりませんが、操作性においてはスマホのネイティブアプリに比べたら一歩劣り1、あまりWebアプリでは使われていないのではないでしょうか。
そんな中、ボトムシートのメリットの1つである(と私が考えている)「閉じやすさ=下スワイプで閉じるアクション」を実装できたので、紹介したいと思います。
こんな感じに、ネイティブアプリのように下スワイプで閉じることができます。
デモはこちら
※スマホ等で試してみてください
ネイティブアプリのような「スワイプ中の指に追従し、慣性で閉じる」タイプです。ぜひ触ってみてください。
そもそもなぜボトムシートを自作したのか
実は「下スワイプで閉じる」の実装が目的ではなく、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スクロールを止めたいのであれば、モーダル上に常にスクロール可能な領域を用意しておかなければなりません。そこで、今回実装したボトムシートでは、モーダルを閉じるアクションにスクロールを取り入れることで、必ずスクロールを発生させることにしました。
scroll-snap-*
上記の overscroll-behavior
だけでは、単にスクロールが増えただけで下スワイプのアクション感がありません。また、中途半端な位置でスクロールが止まってほしくないです。そこで使用したのが、scroll-snap-type
と scroll-snap-align
です。スクロールする要素に scroll-snap-type: y mandatory;
を設定することで、スクロール時に子要素の scroll-snap-align
で指定したスナップ位置に吸い付くようにスナップされます。これによって、ボトムシートを開いた状態もしくは閉じた状態でスナップされるようになります。
scrollbar-width
上記の下スワイプ用スクロールのスクロールバーは表示されたくないので、下記のCSSで消しておきました。
/* CSSのイメージ */
dialog {
-ms-overflow-style: none;
scrollbar-width: none;
}
dialog::-webkit-scrollbar {
display: none;
}
その他CSS
dialog要素は通常、背景は ::backdrop
、モーダルのコンテンツはdialog要素自身となります。しかし上記のスクロールギミックを用意する都合上、モーダルのコンテンツは子要素で作る必要があります。そのため、下記のCSSでdialog要素を裏方にまわしています。
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の子要素でボトムシートの形を作成しています。
dialog .dialog-content {
padding-top: 24px;
border-radius: 16px 16px 0 0;
background-color: #fff;
}
JavaScript処理について
JavaScriptで行っている処理は、主に2つです。
dialog要素の表示時、スクロールギミックの都合でボトムシートが画面外になっていますので、 scrollIntoView
で表示領域に持ってきます。
dialog.showModal()
dialogBottomSheet.scrollIntoView(false)
逆に、スクロール位置がほぼ0になったときにdialog要素を閉じる処理を入れます。これにより、ボトムシートが下スワイプにより引っ込んだタイミングで閉じるようになります。
const handleScroll = () => {
// スナップポイント付近で減速するので、
// 閉じるタイミングを0ピッタリではなく、0付近になったときにしている。
// なお、9という数字は適当なので、要調整。
if (dialog.scrollTop < 9) {
dialog.close()
}
}
実際は説明用に削った部分等々あるので多少違いはありますが、概ね上記の処理でボトムシートを実装しています。
その他、React的な処理
dialog要素を使う場合、そのコンテンツ内に autofocus
をつけるのはほぼ必須らしい2です。しかし、Reactでの autoFocus
は独自処理が走るため、dialog要素内ではエラーになり使えません。そのため、useEffectで生DOM APIを使って autofocus
を付けています。
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回走るのですが、高速に開く閉じるを行うとなぜか反応しないため、ちょっと複雑になっています。
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で行っているので、ネイティブアプリのような操作感ではないでしょうか。ぜひ、ボトムシートを実装したいと思ったら、この記事を思い出してみてください。
今回デモに使った実装はこちら
-
Webのボトムシートは、ボトムシートと言いつつただの見た目違いモーダルだったり、下スワイプで閉じる際はジェスチャー終了のトリガーで閉じるアニメーションを発火するタイプでちょっとラグがあったり、ネイティブアプリのものを真似た劣化版と思っています(個人の感想です) ↩