結論
-
ScrollView全体を<Pressable onPress={Keyboard.dismiss}>で包む実装は、ラベルや余白の上からのドラッグでスクロールを効かなくする -
keyboardShouldPersistTaps="handled"を指定したScrollViewは、子要素が処理しなかったタッチに対して自分でキーボードを閉じる機能を標準で持っている - つまり外側の
Pressableは機能の二重実装であり、しかも responder を先に確定させるので害しかない - 症状が似ている別問題(
Pressableが responder を即確定する)も同時に存在しうるので、切り分けが必要
症状
個人開発している iOS アプリ(クロス手帳)の入力フォーム(モーダル表示)で、こういう現象が起きていました。
- ボタンやピッカーの上から指を置いてドラッグ → スクロールする
-
ラベルや余白の上から指を置いてドラッグ → スクロールしない
「まあ一応使えるし」で長らく放置していたのですが、入力速度が売りのアプリでこれは致命的だと思い直して調査しました。
厄介なのは、タッチを開始した位置によって挙動が変わるという点です。「スクロールできない」ではなく「たまにできる」なので、原因の見当がつけにくい。
原因は2つ重なっていた
結論から書くと、まったく別レイヤーの問題が2つ同時に存在していました。
原因1: Pressable が responder を即座に確定する (JS レイヤー)
React Native の Responder システムでは、タッチが始まると「誰がこのタッチを処理するか」の交渉が行われます。Pressable は delayPressIn が既定値 0 のため、タッチした瞬間に押下状態を確定してしまい、親の ScrollView がドラッグをスクロールとして横取りするタイミングを失います。
同じ画面の中に、これを回避できていた要素がありました。**横スクロールの ScrollView でラップされたチップ選択(株数の「100株」「200株」など)**です。
// ChipSelector: これは偶然スクロールできていた
<ScrollView horizontal keyboardShouldPersistTaps="handled">
<Pressable onPress={...}>100株</Pressable>
<Pressable onPress={...}>200株</Pressable>
</ScrollView>
ネストされた ScrollView 同士は iOS 上で自動的にスクロールを譲り合う仕組みが働くため、たまたま問題を回避していました。「正しい実装」だったのではなく「偶然」だったわけです。
対処は unstable_pressDelay の付与です。
<Pressable
onPress={...}
unstable_pressDelay={SCROLL_FRIENDLY_PRESS_DELAY_MS} // 100ms
>
押下確定を少し遅らせることで、ScrollView 側のドラッグ検出が成立します。新しいライブラリは不要で、Pressable の標準プロパティだけで解決できます。
これでボタンの上からのドラッグは直りました。しかしラベルや余白の上からは依然としてスクロールできませんでした。
原因2: ScrollView を Pressable で包んでいた (これが本命)
コードはこうなっていました。
// 問題のあった実装
<Pressable style={styles.dismissLayer} onPress={() => Keyboard.dismiss()}>
<ScrollView keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag">
{/* フォームの中身 */}
</ScrollView>
</Pressable>
「入力欄の外をタップしたらキーボードを閉じたい」という、よくある要求への実装です。
React Native の responder 探索は、タッチ開始位置の直下に responder を引き受ける要素がない場合、ツリーを祖先方向に遡ります。
-
ボタンの上 → 直下の
Pressableが responder を取る → 原因1の対処で解決済み -
ラベルや余白の上 → 直下に何もない → 祖先の
dismissLayerが取る → スクロールが死ぬ
つまり「ボタンのない領域」はすべてこのdismissLayerに吸われていました。症状が「ラベルの上だけ効かない」だった理由がこれです。
遅延を足すだけでは解決しなかった
原因1と同じ発想で、dismissLayer 自体にも unstable_pressDelay を付与する対処を一度試しました。しかし解決しませんでした。
unstable_pressDelay は押下確定を遅らせるだけで、最終的に responder を取ること自体は変わりません。ボタンの場合は「押下確定が遅れている間に ScrollView がドラッグを検出して横取りする」という競合の勝敗が変わりますが、dismissLayer は ScrollView の祖先なので、遅延させても結局こちらが responder を確定させてしまいます。
遅延ではなく、そもそも responder を取らせないことが必要でした。
実は ScrollView が既に同じ機能を持っている
ここが今回の一番の学びでした。
React Native の ScrollView の実装を確認すると、keyboardShouldPersistTaps="handled" が指定されている場合、ScrollView 自身が「フォーカス中の入力欄以外の場所」をタップされたときにキーボードを閉じる処理を内部に持っています。
子要素が何も受け止めなかったタッチに対して、ScrollView がバブルフェーズで responder を引き受けてキーボードを閉じる。まさに dismissLayer でやろうとしていたことです。
外側の Pressable は最初から不要でした。 必要な機能はすでに ScrollView の props で実現されていて、その上に手動実装を重ねたせいでスクロールが死んでいた、という構図です。
修正は引き算だけで済みました。
// 修正後
<ScrollView keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag">
{/* フォームの中身 */}
</ScrollView>
Pressable を外し、Keyboard の import とスタイル定義を削除。props の値は変更していません。 追加した行はすべて既存 JSX の再インデントで、新規のロジックはゼロです。
ただし正直に書いておくと、この keyboardShouldPersistTaps="handled" は先に入っていたものですが、keyboardDismissMode="on-drag" のほうは**dismissLayer を追加したのと同じコミットで一緒に入っていました**。つまり「昔から正しく設定されていた」わけではなく、「キーボードを閉じたい」という同じ試行錯誤の中で両方が追加され、片方(props)だけで十分だったのに片方(Pressable)も残ってしまった、というのが実際の経緯です。
keyboardShouldPersistTaps の値の選択
ついでに整理しておきます。この props には3つの値があります。
| 値 | 挙動 |
|---|---|
"never" (既定値) |
空白タップで常にキーボードだけを閉じ、子要素へタッチが渡らない |
"always" |
子要素へ常にタッチを渡す。キーボードは自動で閉じない |
"handled" |
子要素が処理したらその動作を実行。処理されなければ ScrollView がキーボードを閉じる |
"never" を選ぶと、キーボード表示中の1回目のタップがキーボードを閉じるだけで消費されます。 つまりボタンを押すのに2回タップが必要になる。既定値がこれなので、意識せず使うと入力体験が悪化します。
"handled" にしておけば、ボタンの上をタップしたときは1回で反応し、余白をタップしたときだけキーボードが閉じます。
なお、ScrollView をネストしている場合は内側の ScrollView にも指定が必要です。前述の ChipSelector も横スクロールの ScrollView 側に keyboardShouldPersistTaps="handled" を指定しています。
効かなかった対処: fullScreenModal
回り道の記録も残しておきます。
この画面は presentation: 'modal' のモーダルでした。iOS のモーダルには下スワイプで閉じるジェスチャがあり、「そのジェスチャとスクロールが競合しているのでは」と疑いました。
調べると、gestureEnabled: false は react-native-screens 内部で UIViewController.isModalInPresentation に変換されており(ios/RNSScreen.mm に _controller.modalInPresentation = !gestureEnabled; の実装があります)、これは実際の dismiss をブロックするだけでジェスチャレコグナイザ自体は消えないことが分かりました。つまり「閉じないのに競合だけ起きる」状態です。
この挙動は react-native-screens の issue としても報告されています。
-
{ presentation: 'modal', gestureEnabled: false } is swipable on iOS · Issue #1410
そこでpresentation: 'fullScreenModal'に変更してジェスチャごと消す、という対処を試しました。結果は、 -
スクロール問題は解消しなかった
-
iPhone でも全画面表示になり、上部に親画面が見える見た目が失われた
効果ゼロで副作用だけという結果で、この変更は取り消しました。モーダルのジェスチャは主因ではなかったわけです。
ちなみに gestureResponseDistance(スワイプの反応領域を制限する props)も検討しましたが、react-native-screens のソースを読むとこれは presentation: 'card' のエッジスワイプ(pop ジェスチャ)専用の実装で、モーダルのシート下スワイプとは別経路であることが分かりました。RNSScreenStack.mm を見ると、反応距離の判定は gestureRecognizerShouldBegin: の中でのみ使われており、スタックの pop ジェスチャに紐づいています。
教訓
「標準で持っている機能を、手動で再実装していないか」を疑う。
今回の dismissLayer は、動作としては正しく機能していました。タップすればキーボードは閉じる。だから誰も疑わなかった。しかし裏で別の機能(スクロール)を壊していた。
同種の実装は「入力欄の外をタップでキーボードを閉じる」という要求に対して、web の記事でもよく見かけます。ScrollView の中で使うなら、まず keyboardShouldPersistTaps で足りないかを確認するのが先です。
あと、症状が似ていても原因が複数あることを疑う。 今回は JS の Responder システムと、ScrollView の標準機能の二重実装という、別レイヤーの問題が2つ重なっていました。1つ目を直したときに「改善したが完治しない」という状態になり、そこで「まだ別の原因がある」と考えられたのが早期解決につながりました。
この不具合を見つけたアプリについて
クロス手帳 は、株主優待のクロス取引(つなぎ売り)の進行管理に特化した iOS アプリです。個人開発しています。
- 権利付き最終日・権利落ち日を営業日ベースで自動計算
- 現渡し・現引きのタイミングをローカル通知でリマインド
- 同一名義・同一銘柄・同一権利月の二重クロスを登録時に警告
-
完全ローカル動作。サーバーを持たず、入力したデータは端末の外に出ません
クロス手帳 - App Store
「入力がスプレッドシートより速いこと」を設計思想の中心に置いているため、今回のスクロール不具合は看過できないものでした。地味な操作性の問題ほど、後回しにすると効いてきます。
環境
- Expo SDK 54
- React Native 0.81.5
- New Architecture 有効