You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Android] Cancel detector gestures when a native view above them takes over the touch - #4582
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.
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).
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.
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 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 PagerViews
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.
Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.
📝 Summary
Summary by CodeRabbit
Bug Fixes
Improved touch gesture handling when a native view takes control of a touch stream. Gesture cancellation now accounts for whether the initial touch reached the gesture detector and whether the native view takes control after a gesture has begun. This helps avoid unintended cancellations when native views such as buttons receive touch events, and makes cancellation behavior more consistent for gestures using native or virtual detectors.
Walkthrough
The Android native touch-grab cancellation sweep now checks inactive detector handlers. Detector views record the downTime of native ACTION_DOWN events. The root helper passes the event’s downTime to the orchestrator.
Changes
Android native touch-grab cancellation
Layer / File(s)
Summary
Track detector DOWN events packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt
The detector view records the downTime of each delivered native ACTION_DOWN event. Its new hook returns true when the grab occurs mid-gesture or the recorded downTime differs from the supplied downTime.
Use detector checks in the cancellation sweep packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt, packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt
The root helper passes the event’s downTime to the orchestrator. The cancellation sweep also checks eligible inactive native- or virtual-detector handlers through their host detector view’s hook.
Suggested reviewers:m-bert
Priority: ➖ Normal
Severity of issue fixed: Medium
Merge Risk:⚪ Minimal · up to 927e6
This change cancels pending taps inside scrolling lists on Android when a scroll or fling takes over the touch, and the reported trials show it working. No blocking risk was found.
Pre-merge checks | 3✅ Passed checks (3 passed)
Check name
Status
Explanation
Linked Issues check
The changes meet the coding requirements in [#4581]. The detector records a native DOWN. The root defers the grab decision until native dispatch ends. The orchestrator cancels inactive handlers attach…
Out of Scope Changes check
The changes are limited to native detector tracking, native-touch-grab classification, and cancellation of detector-attached handlers. These changes directly implement [#4581]. No unrelated product be…
Title check
The title clearly describes the main Android change: canceling detector gestures when a native view takes control of the touch stream.
Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
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.
This type check excludes detector-attached NativeViewGestureHandlers from the new rule. v3 useNativeGesture handlers are attached with ACTION_TYPE_NATIVE_DETECTOR/VIRTUAL_DETECTOR and a hostDetectorView, but their default hook returns false, so a non-active native gesture below the grabbing view is left running and can still activate on UP (for example, TextViewHook.canActivate always succeeds). Evaluate the detector condition independently, while retaining the hook path for buttons.
…r's DOWN is caught"
This reverts commit 6e034f3.
A detector tracking an earlier pointer receives a new pointer's POINTER_DOWN
as a split MOVE, so it never recorded that event's time. When the view under
the new pointer requested disallow-intercept on its DOWN without intercepting
(a nested pager), the sweep cancelled the held gesture even though its native
stream continued.
The sweep asked a `NativeViewGestureHandler` only its view's hook, which keeps
it by default, so a native gesture attached through a detector below the view
that took over stayed alive and could still activate on lift (e.g.
`useNativeGesture` on a `Text`, whose hook always lets it activate). Buttons
are still checked by their hook as well.
## 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
When a gesture activates during a pull-to-refresh on an RN `FlatList`
with an RN `RefreshControl`, the spinner parks half-pulled and
`onRefresh` fires only on the next touch. Reported with
`ReanimatedSwipeable` rows in #4563.
RN drives the pull through nested scrolling and `SwipeRefreshLayout`
finishes it only in `onStopNestedScroll`, which Android triggers from
`View.dispatchTouchEvent` by calling `stopNestedScroll()` after the
final UP or CANCEL. When the root view starts intercepting,
`cancelTouchesInInterceptedViews` feeds `ACTION_CANCEL` straight to
`onTouchEvent`, so that cleanup never runs. Same omission #4492 fixed on
the `NativeViewGestureHandler` path.
The sweep now calls `stopNestedScroll()` after `onTouchEvent`, as
[`View.dispatchTouchEvent`
does](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/view/View.java;l=16587).
It is a no-op without an open nested scroll. An interrupted pull now
snaps back, or fires `onRefresh` if it was past the threshold.
## Test plan
- Before #4582 (main at 993bbe7): RN `FlatList` + RN `RefreshControl`
with `ReanimatedSwipeable` rows and with bare v3 pan rows, drag a row
down then sideways. Short pull snaps back and the row still swipes, long
pull fires `onRefresh` immediately. Previously the spinner parked until
the next touch.
- On current main: single-finger drag leaves the pull to the scroll view
(gesture cancelled by #4582). Hold a pull with one finger and swipe
another row with a second finger: spinner snaps back or refreshes at
once instead of parking.
- GH `FlatList` + RN `RefreshControl`, GH `FlatList` + GH
`RefreshControl`, plain pulls and plain row swipes unchanged. iOS
unaffected.
<details>
<summary>Repro</summary>
```tsx
import { useCallback, useState } from 'react';
import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { GestureDetector, GestureHandlerRootView, usePanGesture } from 'react-native-gesture-handler';
const DATA = Array.from({ length: 30 }, (_, i) => `Row ${i}`);
function Row({ item }: { item: string }) {
const pan = usePanGesture({ activeOffsetX: [-10, 10] });
return (
<GestureDetector gesture={pan}>
<View style={styles.row}>
<Text>{item}</Text>
</View>
</GestureDetector>
);
}
export default function App() {
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => setRefreshing(false), 1000);
}, []);
return (
<GestureHandlerRootView style={styles.container}>
<FlatList
data={DATA}
keyExtractor={(item) => item}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
renderItem={({ item }) => <Row item={item} />}
/>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
row: { padding: 20, backgroundColor: 'white', borderBottomWidth: 1, borderColor: '#eee' },
});
```
<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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/FlatListthat 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
requestDisallowInterceptTouchEventonly bubbles up, and the deferred sweep from #4441 only handles buttons. This PR extends that sweep to detector gestures:GestureDetectornot working in android when used inside a nested TopTab from “@react-navigation/material-top-tabs” #2383) or aTextInput, leave gestures that got the DOWN alone. That includes gestures held by another finger.Like buttons since #4441 (and legacy handlers), this is stricter than
mainin two cases:hitSlop, outside its detector, is cancelled if the view under it requests on DOWN (e.g. aTextInput).Known limits, same as before:
Test plan
I compared
mainwith this branch on an Android 16 emulator using theexpo-exampledebug build, 5 tries each, driving the two-finger cases through the emulator console. The screen is the repro gist plus the multitouch repro from review.mainPagerViewsTextInput, or stops a list flinguseNativeGestureon aTextrow: drag-scroll / tap to stop a flingScrollViewcarousel catches a fling, then sideways driftRectButtontouched once, then taps to stop a flingTouchable/PressablerowshitSlopover a plainView/ over aTextInputyarn lint:androidpasses. 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.