Skip to content

[Android] Fix buttons firing press events when a scroll takes over the touch - #4441

Merged
m-bert merged 11 commits into
mainfrom
@mbert/fix-touchable-scroll
Aug 26, 2026
Merged

m-bert merged 11 commits into
mainfrom
@mbert/fix-touchable-scroll

Conversation

@m-bert

@m-bert m-bert commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Pressable without relation props presses natively through ButtonViewGroup, whose managed NativeViewGestureHandler is attached with ACTION_TYPE_NONE. RNGH delivers touches through the orchestrator regardless of what happens in the native dispatch, so when a native ScrollView takes the gesture over, nothing stops the handler - it reaches STATE_END on lift and fires a press. This shows up in three ways:

  • fling catch: the ScrollView intercepts DOWN while decelerating, the button never sees any native event, yet onPress fires on lift ([Android] Pressable fires onPress when the touch only stops a fling #4432)
  • drag: the ScrollView intercepts on MOVE when the finger starts scrolling from a row, and onPress still fires on lift (comment)
  • long press while scrolling: the content moves with the finger, so the pointer never leaves the row and the long-press timer posted on BEGAN fires mid-scroll (same comment)

In all three the ScrollView calls requestDisallowInterceptTouchEvent(true), but the existing sweep (cancelAllLegacyHandlers) only cancels action-driven handlers, and the ButtonViewGroup override from #4367 never runs since the request only bubbles up from the ScrollView.

Cancelling button handlers directly at request time (the #4433 approach) is not valid either: an eager disallow-intercept (react-native-pager-view's NestedScrollableHost requests it on DOWN whenever it's nested inside another ViewPager, without intercepting anything) is indistinguishable from a real interception at that moment, so every Pressable inside 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:

  • ButtonViewGroup tracks receivedNativeDown - set in dispatchTouchEvent (handler delivery bypasses it), reset on BEGAN, which the orchestrator dispatches before the native dispatch of the same DOWN.
  • RNGestureHandlerRootHelper records the disallow request, and once the root view finishes super.dispatchTouchEvent runs cancelHandlersOnNativeTouchGrab, cancelling handlers whose hook opts in.
  • The hook decides via shouldCancelOnNativeTouchGrab(grabbedMidGesture) = grabbedMidGesture || !receivedNativeDown: a grab on any pass after DOWN means actual dragging (cancel, matching what the legacy Pressable and RN's Pressable do), while a grab during the DOWN pass spares a button that received that DOWN (a defensive disallow lets the event through).

Only ButtonViewGroup opts 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 SectionList with Pressable rows (onPress + onLongPress), a Pressable and a long-press GestureDetector inside nested PagerViews (the eager-disallow setup from #2383), and an engine toggle (v3 / LegacyPressable / RN Pressable). All runs on the same emulator, main vs this PR:

scenario main this PR
fling the list, touch a row to stop it, lift phantom onPress nothing
put a finger on a row and drag-scroll, lift phantom onPress nothing
hold a row while drag-scrolling past 500 ms phantom onLongPress nothing
tap a row on a settled list onPress onPress
stationary long press on a row onLongPress onLongPress
tap the Pressable inside nested pagers onPress onPress
long press the detector box inside nested pagers (#2383) activates activates

LegacyPressable behaves 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's Pressable doesn't go through RNGH and is clean everywhere.

Repro
import React, { useState } from 'react';
import {
  Pressable as RNPressable,
  SectionList,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import PagerView from 'react-native-pager-view';
import {
  GestureDetector,
  LegacyPressable,
  Pressable,
  useLongPressGesture,
} from 'react-native-gesture-handler';

const SECTIONS = Array.from({ length: 8 }, (_, section) => ({
  title: `Section ${section}`,
  data: Array.from({ length: 10 }, (_, index) => `Item ${section}-${index}`),
}));

const ENGINES = ['Pressable (v3)', 'LegacyPressable', 'RN Pressable'] as const;
const COMPONENTS = [Pressable, LegacyPressable, RNPressable] as const;

function LongPressBox({ onLongPress }: { onLongPress: () => void }) {
  const longPress = useLongPressGesture({
    runOnJS: true,
    onActivate: onLongPress,
  });

  return (
    <GestureDetector gesture={longPress}>
      <View style={styles.gestureBox} />
    </GestureDetector>
  );
}

export default function EmptyExample() {
  const [engine, setEngine] = useState(0);
  const [lastEvent, setLastEvent] = useState('none');
  const [eventCount, setEventCount] = useState(0);

  const Row = COMPONENTS[engine] as typeof Pressable;

  const report = (kind: string, item: string) => {
    setLastEvent(`${kind} ${item}`);
    setEventCount((count) => count + 1);
  };

  return (
    <View style={styles.root}>
      <View style={styles.banner}>
        <Text style={styles.bannerText}>engine: {ENGINES[engine]}</Text>
        <Text style={styles.bannerText}>
          last: {lastEvent} (count: {eventCount})
        </Text>
        <Pressable
          style={styles.toggle}
          onPress={() => {
            setEngine((current) => (current + 1) % ENGINES.length);
            setLastEvent('none');
            setEventCount(0);
          }}>
          <Text style={styles.toggleText}>Toggle engine</Text>
        </Pressable>
      </View>
      {/* Nested pagers: the inner pager's NestedScrollableHost calls
          requestDisallowInterceptTouchEvent(true) on ACTION_DOWN only when it
          sits inside another ViewPager2 — the eager-disallow case from #4367. */}
      <PagerView style={styles.pager} initialPage={0}>
        <View key="outer-a" style={styles.page}>
          <PagerView style={styles.innerPager} initialPage={0}>
            <View key="a" style={[styles.page, styles.pageRow]}>
              <Row
                style={styles.pagerButton}
                onPress={() => report('press', 'pager-button')}>
                <Text style={styles.toggleText}>Pager button</Text>
              </Row>
              {/* The #2383 setup: a long-press gesture inside nested pagers
                  (material top tabs are pager-view underneath). */}
              <LongPressBox onLongPress={() => report('gesture', 'pager-box')} />
            </View>
            <View key="b" style={styles.page}>
              <Text>Page B</Text>
            </View>
          </PagerView>
        </View>
        <View key="outer-b" style={styles.page}>
          <Text>Outer page B</Text>
        </View>
      </PagerView>
      <SectionList
        sections={SECTIONS}
        keyExtractor={(item) => item}
        renderSectionHeader={({ section }) => (
          <Text style={styles.sectionHeader}>{section.title}</Text>
        )}
        renderItem={({ item }) => (
          <Row
            style={styles.row}
            onPress={() => report('press', item)}
            onLongPress={() => report('longPress', item)}>
            <Text>{item}</Text>
          </Row>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  root: {
    flex: 1,
  },
  banner: {
    padding: 16,
    gap: 8,
    backgroundColor: '#eee',
  },
  bannerText: {
    fontWeight: 'bold',
  },
  toggle: {
    alignSelf: 'flex-start',
    paddingVertical: 8,
    paddingHorizontal: 16,
    borderRadius: 8,
    backgroundColor: 'steelblue',
  },
  toggleText: {
    color: 'white',
  },
  sectionHeader: {
    paddingHorizontal: 24,
    paddingVertical: 8,
    fontWeight: 'bold',
    backgroundColor: '#ddd',
  },
  row: {
    padding: 24,
    borderBottomWidth: 1,
    borderBottomColor: '#ddd',
  },
  pager: {
    height: 110,
    borderBottomWidth: 2,
    borderBottomColor: '#bbb',
  },
  page: {
    alignItems: 'center',
    justifyContent: 'center',
  },
  pageRow: {
    flexDirection: 'row',
    gap: 16,
  },
  gestureBox: {
    width: 64,
    height: 44,
    borderRadius: 8,
    backgroundColor: 'crimson',
  },
  innerPager: {
    alignSelf: 'stretch',
    flex: 1,
  },
  pagerButton: {
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 8,
    backgroundColor: 'darkorange',
  },
});

Copilot AI lite review requested due to automatic review settings August 18, 2026 07:25
@coderabbitai

coderabbitai Bot commented Aug 18, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c5cda26b-b18e-4af2-b307-0483ad5a6d04

📥 Commits

Reviewing files that changed from the base of the PR and between 706cfa3 and 9dfc9a2.

📒 Files selected for processing (1)
  • packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt

Included review availability: Your plan includes up to 8 reviews per rolling hour; 5 remain after this review.


📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved Android gesture handling when native views take control of touch events.
    • Prevented conflicting gesture handlers from remaining active after native touch capture.
    • Preserved normal event delivery when gesture handling does not consume a touch event.
    • Improved tracking and cleanup of touch interactions across native dispatch.

Walkthrough

The 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.

Changes

Native touch-grab cancellation

Layer / File(s) Summary
Cancellation contract
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/core/NativeViewGestureHandler.kt
The orchestrator centralizes predicate-based cancellation. NativeViewGestureHandlerHook defines whether a handler cancels when a native view grabs the touch.
Root dispatch flow
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/react/RNGestureHandlerRootView.kt
The root helper tracks native touch-grab requests and cancels eligible handlers after native dispatch ends.
Button touch tracking
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt
ButtonViewGroup records native ACTION_DOWN delivery and applies cancellation for mid-gesture grabs or when no native down event was received.

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
Loading
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the Android button press fix when scrolling takes over the touch.
Linked Issues check ✅ Passed The changes address [#4432] by cancelling opted-in native button handlers when scrolling takes over while preserving normal taps.
Out of Scope Changes check ✅ Passed All changes support the linked issue by coordinating native touch takeover and cancelling affected button gesture handlers.

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.

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 NativeViewGestureHandler so specific native-backed handlers (currently the button-backed Pressable) can be cancelled when native DOWN never 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”, but requestDisallowInterceptTouchEvent() 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.

@m-bert m-bert changed the title [Android] Fix Pressable firing onPress when the touch only stops scroll [Android] Fix buttons firing press events when a scroll takes over the touch Aug 18, 2026
@m-bert
m-bert requested a balanced review from Copilot August 18, 2026 08:34
@m-bert

m-bert commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review again

@coderabbitai

coderabbitai Bot commented Aug 18, 2026 •

Copy link
Copy Markdown

@m-bert I will review the updated changes.

⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.

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_DOWN is 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 defensive DOWN disallow request while this root event is ACTION_POINTER_DOWN; this passes true and 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)

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.

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_DOWN occurs after the gesture's initial DOWN, so treating it as an initial pass leaves a button alive if an ancestor starts intercepting when an additional pointer lands. Because receivedNativeDown is already true from the first pointer, the hook then declines cancellation and the handler can still reach END and emit a press. Only ACTION_DOWN should be exempt from grabbedMidGesture.
      val grabbedMidGesture = event.actionMasked != MotionEvent.ACTION_DOWN &&
        event.actionMasked != MotionEvent.ACTION_POINTER_DOWN

@m-bert
m-bert requested a review from j-piasecki August 21, 2026 08:41
@m-bert
m-bert merged commit d3547ac into main Aug 26, 2026
4 checks passed
@m-bert
m-bert deleted the @mbert/fix-touchable-scroll branch August 26, 2026 14:13
m-bert pushed a commit that referenced this pull request Oct 12, 2026
…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.
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

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] Pressable fires onPress when the touch only stops a fling

3 participants