Skip to content

[Android] Cancel detector gestures when a native view above them takes over the touch - #4582

Merged
m-bert merged 4 commits into
software-mansion:mainfrom
JacquesLeupin:fix-detector-native-touch-grab
Oct 12, 2026
Merged

m-bert merged 4 commits into
software-mansion:mainfrom
JacquesLeupin:fix-detector-native-touch-grab

Conversation

@JacquesLeupin

@JacquesLeupin JacquesLeupin commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

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 (GestureDetector not working in android when used inside a nested TopTab from “@react-navigation/material-top-tabs”  #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 [Android] Fix buttons firing press events when a scroll takes over the touch #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 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.

Copilot AI balanced review requested due to automatic review settings October 7, 2026 23:18
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a0ae59ec-ea37-4fb1-b73f-28954b5ba77b

📥 Commits

Reviewing files that changed from the base of the PR and between 6e034f3 and 927e6f2.


📒 Files selected for processing (3)
  • packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt
  • packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerDetectorView.kt
  • packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt

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 | Passed 3
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check Passed 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 Passed 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 Passed 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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Multi-pointer takeover can leave pending detector gestures uncancelled.

1 open finding
What changed in this PR

Extends Android native-touch takeover handling to cancel pending v3 detector gestures and prevent phantom activations.

Changes:

  • Records native DOWN timestamps on detector views.
  • Cancels inactive native/virtual detector handlers after native touch grabs.
  • Passes touch-stream timing into the cancellation sweep.
File Description
RNGestureHandlerRootHelper.kt Supplies DOWN timing to deferred cancellation.
RNGestureHandlerDetectorView.kt Tracks native DOWN delivery.
GestureHandlerOrchestrator.kt Expands cancellation to v3 detector handlers.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Detector-attached native gestures are skipped, and split pointer dispatch can incorrectly cancel unrelated gestures.

1 open finding
1 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Medium severity Detector-attached native gestures bypass cancellation checks

packages/​react-native-gesture-handler/​android/​src/​main/​java/​com/​swmansion/​gesturehandler/​core/​GestureHandlerOrchestrator.kt:403

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.

🧠 Review effort: Balanced

…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.
@JacquesLeupin
JacquesLeupin requested review from m-bert and a balanced review from Copilot October 10, 2026 00:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The root-wide sweep can cancel unrelated pending gestures, and the PR description contradicts the final timing implementation.

2 open findings
2 resolved since last review

🧠 Review effort: Balanced

@m-bert m-bert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@m-bert
m-bert merged commit 0f8b2ba into software-mansion:main Oct 12, 2026
5 checks passed
m-bert added a commit that referenced this pull request Oct 13, 2026
## 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>
m-bert added a commit that referenced this pull request Oct 14, 2026
## 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>
m-bert added a commit that referenced this pull request Oct 14, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android] v3 gestures below a native ScrollView still activate after the ScrollView takes over the touch

3 participants