Repository navigation
[Android] Fix buttons firing press events when a scroll takes over the touch - #4441
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
Included review availability: Your plan includes up to 8 reviews per rolling hour; 5 remain after this review. 📝 WalkthroughSummary by CodeRabbit
WalkthroughThe Android touch flow now tracks native dispatch, defers native touch-grab cancellation until dispatch ends, and cancels eligible native view handlers. Existing legacy-handler cancellation keeps its previous filters. ChangesNative touch-grab cancellation
Sequence Diagram(s)sequenceDiagram
participant NativeView
participant RNGestureHandlerRootHelper
participant RNGestureHandlerRootView
participant GestureHandlerOrchestrator
participant ButtonViewGroup
NativeView->>RNGestureHandlerRootHelper: requestDisallowInterceptTouchEvent
RNGestureHandlerRootHelper->>GestureHandlerOrchestrator: cancelAllLegacyHandlers
NativeView->>RNGestureHandlerRootView: dispatchTouchEvent
RNGestureHandlerRootView->>RNGestureHandlerRootHelper: onNativeDispatchEnd
RNGestureHandlerRootHelper->>GestureHandlerOrchestrator: cancelHandlersOnNativeTouchGrab
GestureHandlerOrchestrator->>ButtonViewGroup: evaluate native touch-grab cancellation
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR addresses an Android-specific v3 Pressable regression where onPress could fire when a touch only stops a fling/scroll, by deferring certain gesture cancellations until after native dispatch completes so the system can distinguish “defensive” disallow-intercept calls from real native interception.
Changes:
- Track disallow-intercept requests during native dispatch and perform a post-dispatch cancellation pass for opted-in handlers.
- Add an opt-in hook on
NativeViewGestureHandlerso specific native-backed handlers (currently the button-backedPressable) can be cancelled when nativeDOWNnever reached them. - Refactor orchestrator cancellation logic to share a common predicate-based cancellation helper.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt | Calls onNativeDispatchEnd() after super.dispatchTouchEvent to enable post-dispatch cancellation. |
| packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt | Tracks native touch-grab requests and triggers orchestrator cancellation after native dispatch completes. |
| packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt | Makes button-backed Pressable opt into post-dispatch cancellation when it didn’t receive native DOWN. |
| packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt | Exposes a hook-driven shouldCancelOnNativeTouchGrab() decision point. |
| packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt | Adds cancelHandlersOnNativeTouchGrab() and refactors cancellation into a shared predicate helper. |
Suppressed comments (1)
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt:133
- The KDoc for
onNativeDispatchEnd()says cancellation runs here “not at request time”, butrequestDisallowInterceptTouchEvent()still cancels legacy handlers immediately. Consider clarifying that this method performs the additional opt-in cancellation after native dispatch completes.
/**
* A disallow-intercept request may mean a real interception, but it may also be just a
* defensive call from a view that lets the event through (e.g. a nested pager). The two can only
* be told apart after the native dispatch completes, so cancellation runs here, not at request time.
*/
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Pressable firing onPress when the touch only stops scroll|
@coderabbitai review again |
|
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt:137
ACTION_POINTER_DOWNis classified as a mid-gesture grab even when it is the first/down event delivered to a split child touch target. For example, while one pointer holds a button, a second pointer entering the nested pager can produce the pager's defensiveDOWNdisallow request while this root event isACTION_POINTER_DOWN; this passestrueand the root-wide sweep cancels the unrelated button (and any button under the second pointer) despite native dispatch continuing normally. The decision needs to be tied to the affected pointer/handler's native delivery rather than only the root event action.
orchestrator?.cancelHandlersOnNativeTouchGrab(event.actionMasked != MotionEvent.ACTION_DOWN)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt:139
ACTION_POINTER_DOWNoccurs after the gesture's initialDOWN, so treating it as an initial pass leaves a button alive if an ancestor starts intercepting when an additional pointer lands. BecausereceivedNativeDownis already true from the first pointer, the hook then declines cancellation and the handler can still reachENDand emit a press. OnlyACTION_DOWNshould be exempt fromgrabbedMidGesture.
val grabbedMidGesture = event.actionMasked != MotionEvent.ACTION_DOWN &&
event.actionMasked != MotionEvent.ACTION_POINTER_DOWN
…s over the touch (#4582) ## Description On Android, a v3 gesture below a native view that takes over the touch keeps running and activates on lift. That native view might be a `ScrollView`/`FlatList` that starts dragging, or one that catches a fling on DOWN. So scrolling from a list row, or tapping a row to stop a fling, fires the row's tap. The list also loses its fling, because the root starts intercepting when the tap activates. Fixes #4581. The scroller's `requestDisallowInterceptTouchEvent` only bubbles up, and the deferred sweep from #4441 only handles buttons. This PR extends that sweep to detector gestures: - The detector remembers the down time of the last DOWN it got through native dispatch. - When a native view takes over, pending detector gestures (native ones included) are cancelled in two cases: the takeover happened after DOWN, or their detector never got the DOWN (a fling catch). - Eager requests on DOWN that still let the event through, like a nested pager (#2383) or a `TextInput`, leave gestures that got the DOWN alone. That includes gestures held by another finger. - Active gestures are left alone, and buttons keep their own check. Like buttons since #4441 (and legacy handlers), this is stricter than `main` in two cases: - A pending gesture under one finger is cancelled when another finger starts scrolling. The root doesn't know which view took over or which pointers it took, so the sweep can't be narrower. - A touch in a gesture's `hitSlop`, outside its detector, is cancelled if the view under it requests on DOWN (e.g. a `TextInput`). Known limits, same as before: - If an ancestor already requested disallow-intercept on DOWN, later requests from below stop there. So swiping a nested pager that started on a tap still fires the tap. Buttons have the same limit. - An ancestor taking over on a later pointer's POINTER_DOWN isn't caught for gestures on earlier pointers. That's the same trade-off as in #4441, and none of the stock scrollers I checked do it. ## Test plan I compared `main` with this branch on an Android 16 emulator using the `expo-example` debug build, 5 tries each, driving the two-finger cases through the emulator console. The screen is the [repro gist](https://gist.github.com/JacquesLeupin/0a30f72fcc02c82144f4b374e55d19a3) plus the multitouch repro from review. | Scenario | `main` | This PR | | --- | --- | --- | | Drag-scroll from a list row, lift | Phantom tap 4/5, list flings 1/5 | No tap, list flings 5/5 | | Tap a row to stop a fling | Phantom tap 5/5 | No tap | | Tap or swipe a row at rest | Works 5/5 | Works 5/5 | | Tap or long press inside nested `PagerView`s | Works 5/5 | Works 5/5 | | Swipe the inner pager starting on a tap | Phantom tap 5/5 | Phantom tap 5/5 (known limit) | | Hold a tap; a second finger taps a nested pager or a `TextInput`, or stops a list fling | Tap fires 5/5 | Tap fires 5/5 | | Hold a tap; a second finger drags another list | Tap fires 5/5 | Cancelled (stricter) | | `useNativeGesture` on a `Text` row: drag-scroll / tap to stop a fling | Activates 3/5 / 5/5 | No activation | | Gesture Handler `ScrollView` carousel catches a fling, then sideways drift | Takes over 5/5 | List keeps scrolling | | `RectButton` touched once, then taps to stop a fling | Presses 5/5 | No press | | `Touchable` / `Pressable` rows | No change | No change | | Tap in a `hitSlop` over a plain `View` / over a `TextInput` | Fires / fires | Fires / cancelled (stricter) | `yarn lint:android` passes. We hit this in a transaction list in our app (React Native 0.87.1), where we carry an earlier version of this fix as a patch on 3.3.0.
## Description `ButtonViewGroup` tracks whether the native dispatch delivered DOWN for the current gesture and resets it when the handler goes BEGAN. The reset sat behind the `managedHandlerTag` guard, so it only ran for buttons that manage their own handler (`Touchable`, `Pressable`). A button attached through a detector, like `RectButton` with `useNativeGesture`, set the flag on its first touch and never cleared it. From then on the #4441 rule degraded to the mid-gesture check only, and a fling caught on such a button was not cancelled. The reset now runs before the guard, so every handler on the view gets the per-gesture rule. Since #4582 the detector rule already covers this case for detector-attached buttons, so there is no visible change today; this fixes the stale state so the hook's rule holds on its own. ## Test plan <details> <summary>Repro</summary> ```tsx import { useState } from 'react'; import { FlatList, StyleSheet, Text, View } from 'react-native'; import { GestureHandlerRootView, RectButton } from 'react-native-gesture-handler'; export default function App() { const [presses, setPresses] = useState(0); return ( <GestureHandlerRootView style={styles.flex}> <Text style={styles.counter}>{`presses=${presses}`}</Text> <FlatList data={Array.from({ length: 500 }, (_, i) => i)} keyExtractor={(item) => String(item)} renderItem={({ item }) => ( <RectButton style={styles.row} onPress={() => setPresses((count) => count + 1)}> <Text>Row {item}</Text> </RectButton> )} /> </GestureHandlerRootView> ); } const styles = StyleSheet.create({ flex: { flex: 1 }, counter: { padding: 16, paddingTop: 48, fontFamily: 'monospace' }, row: { height: 80, justifyContent: 'center', paddingLeft: 16, borderBottomWidth: 1 }, }); ``` </details>
## Description On Android, a v3 gesture still fires on lift when a native ancestor takes over the touch without the root hearing about it. Example: a nested `PagerView`. Its host requests disallow-intercept on DOWN, so the pager's own request on MOVE stops at the already flagged ancestor and never reaches the root. Swiping the pager starting on a tap counts as a tap. #4582 lists this as a known limit. The sweep from #4441 and #4582 runs only on a disallow-intercept request, but the framework also sends `ACTION_CANCEL` down the old touch chain when an ancestor intercepts, and that always reaches the gesture's detector. The detector now records the event time of a native `ACTION_CANCEL` and reports it to the root helper, which arms a second deferred sweep, only during a native pass so RNGH's own CANCELs are ignored. At the end of the pass the orchestrator cancels non-active handlers of the detectors that got the CANCEL for that event. The match is per detector: unlike a disallow request, a CANCEL names its target, so a gesture held by another finger elsewhere survives. ## Test plan - Nested `PagerView`: swipe the inner pager starting on a tap box, the page moves and the tap does not fire (was 5/5 phantom). - `DrawerLayoutAndroid`: swipe the drawer open starting on a tap box, the drawer opens and the tap does not fire (was 5/5 phantom; needs 3-button navigation, with gesture navigation the system back swipe takes the edge). - A plain tap on the box still fires. - RN `FlatList` with v3 rows: drag-scroll from a row and tap to stop a fling still produce no tap (#4582 unchanged). - `yarn lint:android` passes. <details> <summary>Repro screen</summary> ```tsx // Repro for the #4582 follow-up on Android: a native view takes over the touch without the root // ever seeing a disallow-intercept request, so the sweep from #4582 never runs and the v3 tap // below still fires on lift. In both cases the framework delivers ACTION_CANCEL to the tap's // detector when the view intercepts on MOVE - the hook for the follow-up listener. // - Pager: swipe the inner pager starting on the Tap box. The inner pager host asked its ancestors // not to intercept on DOWN (#2383), so the pager's own request on MOVE stops at the already // flagged page view (#4582 "known limit"). // - Drawer: swipe the DrawerLayoutAndroid open from the right edge starting on the Tap box. DrawerLayout // intercepts on MOVE and never requests at all. With gesture navigation the system back swipe // eats the edge, use 3-button navigation for this one. // Expected after the fix: the pager / drawer moves and `tapbox` stays 0. import { useCallback, useState } from 'react'; import { DrawerLayoutAndroid, Pressable, StyleSheet, Text, View, } from 'react-native'; import { GestureDetector, GestureHandlerRootView, useTapGesture, } from 'react-native-gesture-handler'; import PagerView from 'react-native-pager-view'; type Mode = 'pager' | 'drawer'; function TapBox({ onTap }: { onTap: () => void }) { const tap = useTapGesture({ runOnJS: true, onActivate: onTap }); return ( <View style={styles.center}> <GestureDetector gesture={tap}> <View style={styles.box}> <Text>Tap</Text> </View> </GestureDetector> </View> ); } function PagerMode({ onTap }: { onTap: () => void }) { return ( <PagerView style={styles.flex} initialPage={0}> <View key="outer-1" style={styles.flex}> <PagerView style={styles.flex} initialPage={0}> <View key="inner-1" style={styles.flex}> <TapBox onTap={onTap} /> </View> <View key="inner-2" style={[styles.flex, styles.center]}> <Text>Inner page 2</Text> </View> </PagerView> </View> <View key="outer-2" style={[styles.flex, styles.center]}> <Text>Outer page 2</Text> </View> </PagerView> ); } function DrawerMode({ onTap }: { onTap: () => void }) { return ( <DrawerLayoutAndroid style={styles.flex} drawerWidth={300} drawerPosition="right" renderNavigationView={() => ( <View style={[styles.flex, styles.center]}> <Text>Drawer</Text> </View> )}> <TapBox onTap={onTap} /> </DrawerLayoutAndroid> ); } export default function NativeTakeover() { const [mode, setMode] = useState<Mode>('pager'); const [taps, setTaps] = useState(0); const onTap = useCallback(() => setTaps((count) => count + 1), []); return ( <GestureHandlerRootView style={styles.flex}> <View style={styles.header}> <Text testID="counts" style={styles.counts}>{`tapbox=${taps}`}</Text> <View style={styles.buttons}> <Pressable testID="mode-pager" style={[styles.button, mode === 'pager' && styles.buttonActive]} onPress={() => setMode('pager')}> <Text>Pager</Text> </Pressable> <Pressable testID="mode-drawer" style={[styles.button, mode === 'drawer' && styles.buttonActive]} onPress={() => setMode('drawer')}> <Text>Drawer</Text> </Pressable> <Pressable testID="reset" style={styles.button} onPress={() => setTaps(0)}> <Text>Reset</Text> </Pressable> </View> </View> {mode === 'pager' ? ( <PagerMode key={mode} onTap={onTap} /> ) : ( <DrawerMode key={mode} onTap={onTap} /> )} </GestureHandlerRootView> ); } const styles = StyleSheet.create({ flex: { flex: 1 }, center: { flex: 1, alignItems: 'center', justifyContent: 'center' }, header: { paddingTop: 48, paddingHorizontal: 12, paddingBottom: 8, backgroundColor: '#eee', }, counts: { fontSize: 16, fontFamily: 'monospace' }, buttons: { flexDirection: 'row', gap: 8, marginTop: 8 }, button: { paddingVertical: 8, paddingHorizontal: 14, backgroundColor: '#ccc', borderRadius: 6, }, buttonActive: { backgroundColor: '#9ad' }, box: { width: '100%', height: 200, alignItems: 'center', justifyContent: 'center', backgroundColor: '#2a9d8f', }, }); ``` </details>
Description
Pressablewithout relation props presses natively throughButtonViewGroup, whose managedNativeViewGestureHandleris attached withACTION_TYPE_NONE. RNGH delivers touches through the orchestrator regardless of what happens in the native dispatch, so when a nativeScrollViewtakes the gesture over, nothing stops the handler - it reachesSTATE_ENDon lift and fires a press. This shows up in three ways:ScrollViewinterceptsDOWNwhile decelerating, the button never sees any native event, yetonPressfires on lift ([Android] Pressable fires onPress when the touch only stops a fling #4432)ScrollViewintercepts onMOVEwhen the finger starts scrolling from a row, andonPressstill fires on lift (comment)BEGANfires mid-scroll (same comment)In all three the
ScrollViewcallsrequestDisallowInterceptTouchEvent(true), but the existing sweep (cancelAllLegacyHandlers) only cancels action-driven handlers, and theButtonViewGroupoverride from #4367 never runs since the request only bubbles up from theScrollView.Cancelling button handlers directly at request time (the #4433 approach) is not valid either: an eager disallow-intercept (
react-native-pager-view'sNestedScrollableHostrequests it onDOWNwhenever it's nested inside anotherViewPager, without intercepting anything) is indistinguishable from a real interception at that moment, so everyPressableinside nested pagers (e.g. material top tabs in a pager) would go dead - the regression class #4367 fixed.The two can be told apart by when the grab happened and whether the native dispatch still reached the button:
ButtonViewGrouptracksreceivedNativeDown- set indispatchTouchEvent(handler delivery bypasses it), reset onBEGAN, which the orchestrator dispatches before the native dispatch of the sameDOWN.RNGestureHandlerRootHelperrecords the disallow request, and once the root view finishessuper.dispatchTouchEventrunscancelHandlersOnNativeTouchGrab, cancelling handlers whose hook opts in.shouldCancelOnNativeTouchGrab(grabbedMidGesture) = grabbedMidGesture || !receivedNativeDown: a grab on any pass afterDOWNmeans actual dragging (cancel, matching what the legacyPressableand RN'sPressabledo), while a grab during theDOWNpass spares a button that received thatDOWN(a defensive disallow lets the event through).Only
ButtonViewGroupopts into the hook, so handlers attached to detectors, scrollables and text inputs are unaffected. The cost on passes without a disallow request is a single boolean check.Fixes #4432
Supersedes #4433
Test plan
Repro below: a
SectionListwithPressablerows (onPress+onLongPress), aPressableand a long-pressGestureDetectorinside nestedPagerViews (the eager-disallow setup from #2383), and an engine toggle (v3 /LegacyPressable/ RNPressable). All runs on the same emulator, main vs this PR:onPressonPressonLongPressonPressonPressonLongPressonLongPressPressableinside nested pagersonPressonPressLegacyPressablebehaves the same in the list scenarios; inside nested pagers it doesn't fire on main either - its handlers are cancelled on any disallow-intercept request, which is the pre-existing legacy behavior this PR doesn't change. RN'sPressabledoesn't go through RNGH and is clean everywhere.Repro