Skip to content

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

Open
m-bert wants to merge 5 commits into
mainfrom
@mbert/fix-touchable-scroll
Open

[Android] Fix buttons firing press events when a scroll takes over the touch#4441
m-bert wants to merge 5 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

Merge Risk: ⚪ Minimal · up to 9dfc9

The change prevents press events during native scrolling while preserving normal taps and long presses; no actionable merge-blocking risk remains after normal checks and review.

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

Comment on lines 119 to 123
// If this method gets called it means that some native view is attempting to grab lock for
// touch event delivery. In that case we cancel all gesture recognizers
if (orchestrator != null && !passingTouch) {
// if we are in the process of delivering touch events via GH orchestrator, we don't want to
// treat it as a native gesture capturing the lock
@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 18, 2026 08:55
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

2 participants