【iOS WebKit】モーダル内に検索窓を置くとスクロールが固まる不具合の解決方法

モーダルなボトムシート(画面下からせり上がるパネル)の中にスクロールできるリストと検索窓を置いたところ、iPhone のときだけリストがスクロールできないという不具合に遭遇しました。原因が意外なところにあったので、問題・原因・解決策を記録しておきます。

問題

レシピを選ぶためのボトムシート(検索窓+カテゴリタブ+レシピ一覧)で、次の症状が出ました。

  • iPhone(Safari / Chrome 両方)でのみ発生。Android・PC では正常。
  • シート内のリストを指でスクロールしようとしても動かず、代わりに背景のページがスクロールしてしまう。
  • シート内のどこかを一度タップすると、以降は普通にスクロールできるようになる(ただし検索窓にフォーカスを当てただけでは復活しない)。

原因

このボトムシートは Base UI の Dialog をベースにしています。モーダルダイアログは開いた瞬間に内部へフォーカスを移す「フォーカストラップ」を行いますが、その開封時のフォーカス処理の最中にフォーカス可能な <input>(検索窓)が存在すると、iOS WebKit がそのシート内のスクロール領域をスクロール不能の状態に固めてしまうのが原因でした。

ポイントは次のとおりです。

  • 引き金は「ダイアログの開封時フォーカス処理」と「シート内のフォーカス可能な <input>」が同時に存在すること。Base UI を使わない素の position: fixed なシートでは、<input> があっても凍結しません。
  • 一度凍結すると、本物のタップ操作が来るまで解除されない(だから「タップすると直る」)。プログラム的な再描画では解除されません。
  • 入場アニメの transformvh 単位、flex の高さ計算、画像、GPU レイヤー化などは無関係。あくまでフォーカス管理が引き金です。

解決策

原因が「開封時のフォーカス処理の最中に <input> が存在すること」なので、対策はシンプルです。シートを開いた直後はまだ <input> を DOM に出さず、フォーカス処理が落ち着いた頃にマウントする。それまでは同じ見た目の空ボックスを置いてレイアウトのズレを防ぎます。

再利用できるよう、小さなフックにまとめました。

// use-sheet-input-ready.ts
"use client"
import { useEffect, useState } from "react"

/**
 * ボトムシート内に <input> を「いつマウントしてよいか」を返すフック。
 * iOS では、開封時にフォーカス可能な input が存在するとスクロール領域が
 * 凍結するため、開いてから少し遅らせてマウントする。
 */
export function useSheetInputReady(open: boolean, delayMs = 400): boolean {
  const [ready, setReady] = useState(false)
  useEffect(() => {
    if (!open) { setReady(false); return }
    const timer = setTimeout(() => setReady(true), delayMs)
    return () => clearTimeout(timer)
  }, [open, delayMs])
  return ready
}

使う側はこれだけです。

const inputReady = useSheetInputReady(open)

// ...シートの中...
{inputReady ? (
  <Input value={query} onChange={...} />
) : (
  // 同じ大きさの placeholder(フォーカス不可)でレイアウト維持
  <div className="h-11 flex-1 rounded border" aria-hidden />
)}

ユーザーから見れば、シートが開いた一瞬あとに検索窓が現れるだけで、体感的にはほぼ気づきません。これで iPhone でも最初からスクロールできるようになりました。

同じ構成(モーダルなボトムシート+検索窓)で「iPhone だけスクロールできない」に出くわしたら、まずシート内の <input> を疑ってみてください。

コメント