並べかえの drag が取り残されて、React Native の Pressable まで全部効かなくなった
React Native で作った勤怠アプリに、「ホーム画面のボタンが全部押せなくなる。別の画面へ一度移って戻ると動く」という報告が届いた。手元では再現しない。コードと依存ライブラリのソースを読んで、react-native-sortables 1.10.0 に残る drag の活性化と、react-native-gesture-handler の Android root view が全タッチを飲み込む仕組みの組み合わせで、ライブラリと無関係な標準の Pressable まで死ぬ経路を特定した。
全部のボタンが効かない。別の画面へ移って戻ると直る
報告の要旨は2つだった。作業カテゴリのチップを切り替えられない。そのうち、チップだけでなく画面のボタン全部が押せなくなり、別のメニューへ移ってから戻らないと動かない。ホーム画面では、カテゴリのチップを react-native-sortables の Sortable.Flex で並べ、タップで選択、長押しで並べかえにしている。打刻ボタンやタブは React Native 標準の Pressable だ。チップの不具合が打刻ボタンにまで及ぶ理由が、最初は分からなかった。
エミュレータでは再現しなかった。以下は、動く画面ではなくコードから特定した経路であり、報告には端末の OS も書かれていないので、この報告がどの端末で起きたかは確定していない。それでも、コード上に「起きうる」ではなく「こう起きる」と言える経路が1本あった。
指を離しても drag の活性化が残る
react-native-sortables は、チップに指が触れると handleTouchStart で活性化中の状態に入り、dragActivationDelay 後に drag を始める。この状態を戻す handleDragEnd は onTouchesUp と onTouchesCancelled からしか呼ばれない。そして handleTouchStart は冒頭で、別のチップが活性化中のままなら fail() する。つまり、どこか1つのチップの活性化が戻らないと、以後どのチップも触った瞬間に fail する。「カテゴリを切り替えられない」はこれで説明がつく。
では、活性化はどういうときに戻らなくなるのか。上流には未マージの PR がある。MatiPl01/react-native-sortables の #610「fix: stop losing drag activations that no gesture callback will recover」で、本文にこうある。
“A touch that ends before the item activates never reaches onFinalize, and a handler detached mid-gesture reaches no terminal state and emits nothing at all.”
指が触れている最中に handler が付け替わると、そのタッチの終わりを受け取る相手がいない。同じ PR は「re-render のあとタイルが反応しなくなるが、見た目は普通に描画されている」という報告と一致する、とも書いている。npm の最新は 1.10.0 のままで、この PR は入っていない。
ACTIVE な handler が残る限り、root が全タッチを飲む
ここまでだと、死ぬのは sortables のチップだけのはずだ。標準の Pressable まで巻き込む部品は、react-native-gesture-handler の Android 側にあった。root view に付く handler の、この数行が芯になる。
// RNGestureHandlerRootHelper.kt(react-native-gesture-handler 2.32.0)
// RootViewGestureHandler.handleEvent
if (currentState == STATE_UNDETERMINED &&
(!shouldIntercept || orchestrator?.isAnyHandlerActive() != true)
) {
begin()
shouldIntercept = false
}
...
override fun onCancel() {
shouldIntercept = true
... rootView.onChildStartedNativeGesture(rootView, ACTION_CANCEL event)
}
...
fun dispatchTouchEvent(event: MotionEvent): Boolean {
passingTouch = true
orchestrator!!.onTouchEvent(event)
passingTouch = false
return shouldIntercept
}どれかの handler が ACTIVE になると root の handler は cancel され、shouldIntercept = true になる。それが false に戻る条件は、次のタッチで isAnyHandlerActive() が false であること。ACTIVE な handler が1つでも残っていれば dispatchTouchEvent は true を返し続け、タッチは gesture-handler の外へ出ない。標準の Pressable は gesture-handler の外にいるので、打刻ボタンにもタブにも何も届かなくなる。gesture-handler 製のジェスチャーだけは動く。
「別の画面へ移って戻ると直る」もここで説明がつく。並べかえのコンポーネントがアンマウントされれば ACTIVE な handler は消え、次のタッチから root が通常に戻る。公式ドキュメントも、handler が cancel された(=どれかが activate した)時点から、届くタッチはすべて消費される、と書いている。
busy で onTap を付け外ししていたのをやめる
上流の不具合を踏む条件は「指が触れている最中に handler が付け替わること」だった。アプリ側にそれを毎回起こす書き方があった。Sortable.Touchable は onTap などの有無を gesturesKey にして内側のコンポーネントの key にしている。ハンドラが undefined と関数のあいだで切り替わるたびに、GestureDetector ごと再マウントされる。そして画面では、保存中フラグの busy で onTap を外していた。チップをタップする→保存が始まって busy が true になる→onTap が外れて再マウント→保存が終わって busy が false になる→onTap が戻ってまた再マウント、という順なので、タップのたびに付け替えが2回起きる。付け替えの瞬間に次のタップの指が触れていれば、そのタッチの終わりは誰にも届かない。
気づけなかった理由もはっきりしている。持ち上がったチップは activeItemScale が 1.04、activeItemOpacity が 0.85 で、活性化が残っていても目ではほぼ分からない。ボタン側も busy で見た目を変えていない。画面に「何かが掴まれたまま」を示すものが1つもなかった。
// 修正前: 保存中は onTap を外していた
<Sortable.Touchable
onTap={busy ? undefined : () => onSelect(category.id)}
/>
// 修正後: onTap はいつも同じ関数にして、保存中かどうかは中で見る
const tapState = useRef({ busy, onSelect })
tapState.current = { busy, onSelect }
const tap = useCallback((categoryId: number) => {
const current = tapState.current
if (current.busy) return
current.onSelect(categoryId)
}, [])
<Sortable.Touchable onTap={() => tap(category.id)} />直し方は、onTap を常に同じ関数にして、保存中かどうかは関数の中で ref から見る、というだけだ。ハンドラの有無が変わらないので gesturesKey は動かず、再マウントは起きない。あわせて、親がインラインで渡していた callback を useCallback にして、この部品の memo が効くようにした。毎分の時計の更新で再レンダーしていたのを止めるためで、付け替えの機会そのものを減らしている。
捨てた案は、patch-package で PR #610 をそのまま当てることだった。根治はそちらなのだが、PR はまだレビュー中で動いている。動いている差分に追従し続けるコストより、アプリ側の誘発経路を塞いだうえでマージを待ち、マージされたら版を上げるほうを選んだ。PR のマージと npm の新版は cron で監視している。dragActivationDelay を延ばす案も考えたが、誤活性化が減るだけで、付け替えの経路は残る。
この修正は tsc とテストは通っているが、端末での動作確認は未実施で、元の報告が消えたかどうかもまだ分からない。そもそも手元で再現していないので、消えたことを手元で示す手段がない。
まとめ
ライブラリが持つ gesture を、busy のようなアプリの状態で付け外ししない。ハンドラの有無が変わると内部で再マウントが起き、指が触れている最中に当たると終わりの届かないジェスチャーが残る。無効にしたいなら、ハンドラは付けたままで中で弾く。
もう1つ。Android で「ライブラリと関係ないボタンまで全部効かない」が出たら、個々のボタンではなく gesture-handler の root が intercept したままになっていないかを疑う。root は ACTIVE な handler が1つ残るだけで、画面の全タッチを飲み込む。