From 41eddcc83216f470800019b4a2fc7bf6b61972d5 Mon Sep 17 00:00:00 2001 From: Lucca Boas <86315612+Luccacvb@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:51:39 -0300 Subject: [PATCH] fix(android): cancel the JS touch when the pager starts dragging --- .../reactnativepagerview/ComposePagerView.kt | 39 ++++++++++ .../issue-424-swipe-cancels-press.e2e.ts | 59 ++++++++++++++ example/src/App.tsx | 5 ++ .../Issue424SwipeCancelsPressRepro.tsx | 76 +++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 e2e/tests/issues/issue-424-swipe-cancels-press.e2e.ts create mode 100644 example/src/gh-issues/Issue424SwipeCancelsPressRepro.tsx diff --git a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt index f6d1f6c0..814b7df3 100644 --- a/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt +++ b/android/src/main/java/com/reactnativepagerview/ComposePagerView.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeLifecycleOwner import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.NativeGestureUtil import com.reactnativepagerview.event.PageScrollEvent import com.reactnativepagerview.event.PageScrollStateChangedEvent import com.reactnativepagerview.event.PageSelectedEvent @@ -75,6 +76,7 @@ class ComposePagerView(context: Context) : FrameLayout(context) { private var initialTouchX = 0f private var initialTouchY = 0f private var isGestureResolved = false + private var didStartNativeGesture = false private val scrollCommandState = mutableStateOf(null) private var lastEmittedScrollState: String? = null private var lastEmittedPageSelected: Int? = null @@ -147,10 +149,26 @@ class ComposePagerView(context: Context) : FrameLayout(context) { isGestureResolved = false } MotionEvent.ACTION_MOVE -> if (!isGestureResolved) resolveGesture(event) + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + if (didStartNativeGesture && event.actionMasked == MotionEvent.ACTION_UP) { + NativeGestureUtil.notifyNativeGestureEnded(this, event) + } + didStartNativeGesture = false + } } return super.dispatchTouchEvent(event) } + // Not called once a child, like a nested ScrollView, has claimed the touch, so the pager + // only notifies when it is the one dragging. + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_MOVE) { + notifyNativeGestureStartedForMove(event) + } + return super.onInterceptTouchEvent(event) + } + override fun canScrollHorizontally(direction: Int): Boolean { if (orientationState.value != Orientation.Horizontal) { return false @@ -368,6 +386,27 @@ class ComposePagerView(context: Context) : FrameLayout(context) { return if (direction < 0) view.scrollX > 0 else view.scrollX < maxScrollX } + // Compose starts dragging once the touch moves touchSlop along the pager axis. Tell + // React Native then, like ScrollView does, so the JS touch is cancelled instead of + // ending as a press. + private fun notifyNativeGestureStartedForMove(event: MotionEvent) { + // Compose never drags with a mouse. + val isMouse = event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE + if (didStartNativeGesture || !scrollEnabledState.value || isMouse) { + return + } + + val delta = if (orientationState.value == Orientation.Horizontal) { + event.rawX - initialTouchX + } else { + event.rawY - initialTouchY + } + if (delta.absoluteValue >= touchSlop) { + didStartNativeGesture = true + NativeGestureUtil.notifyNativeGestureStarted(this, event) + } + } + private fun canScrollInDirection(direction: Int): Boolean { if (!scrollEnabledState.value || pages.size <= 1 || direction == 0) { return false diff --git a/e2e/tests/issues/issue-424-swipe-cancels-press.e2e.ts b/e2e/tests/issues/issue-424-swipe-cancels-press.e2e.ts new file mode 100644 index 00000000..39d4d3fc --- /dev/null +++ b/e2e/tests/issues/issue-424-swipe-cancels-press.e2e.ts @@ -0,0 +1,59 @@ +import { freshAppBeforeEach, expect, test } from '../support/test.ts'; +import type { App, Screen } from '../support/test.ts'; +import { openExample } from '../support/app.ts'; + +freshAppBeforeEach(); + +// Android-only: the Compose pager has to tell React Native when it takes the +// touch, as ScrollView does. +const options = { tags: ['regression', 'issue-424'], platforms: ['android'] }; + +/** Opens the repro and waits for the first page's button. */ +async function openRepro(app: App, screen: Screen): Promise { + await openExample(app, screen, 'Issue #424 Swipe Cancels Press Repro'); + await expect(screen.getByTestId('issue-424-button-0')).toBeVisible(); +} + +test( + 'a tap on a button in a page presses it', + options, + async ({ app, screen }) => { + await openRepro(app, screen); + await screen.getByTestId('issue-424-button-0').tap(); + await expect(screen.getByTestId('issue-424-status')).toHaveText( + 'Page: 0 | Presses: 1 | Touch: end' + ); + } +); + +test( + 'a swipe that starts on a button in a page cancels its touch', + options, + async ({ app, screen }) => { + await openRepro(app, screen); + // Before the fix JS never got a cancel: the touch always ended, and it + // could press the button when the dragging event reached JS too late. + await screen + .getByTestId('issue-424-button-0') + .swipe({ direction: 'right', momentum: 'fast' }); + await expect(screen.getByTestId('issue-424-status')).toHaveText( + 'Page: 1 | Presses: 0 | Touch: cancel' + ); + } +); + +test( + 'a swipe while the JS thread is busy does not press the button', + options, + async ({ app, screen }) => { + await openRepro(app, screen); + await screen.getByTestId('issue-424-block-js').tap(); + await screen + .getByTestId('issue-424-button-0') + .swipe({ direction: 'right', momentum: 'fast' }); + await expect(screen.getByTestId('issue-424-status')).toHaveText( + 'Page: 1 | Presses: 0 | Touch: cancel', + { timeout: 15_000 } + ); + } +); diff --git a/example/src/App.tsx b/example/src/App.tsx index c9113eb8..4af71ee7 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -53,6 +53,7 @@ import { } from './gh-issues/Issue1142SearchBarInsetRepro'; import { Issue1154PageChangeFocusRepro } from './gh-issues/Issue1154PageChangeFocusRepro'; import { Issue1156NestedHorizontalScrollRepro } from './gh-issues/Issue1156NestedHorizontalScrollRepro'; +import { Issue424SwipeCancelsPressRepro } from './gh-issues/Issue424SwipeCancelsPressRepro'; import { colors, radius } from './theme'; function BasicPagerViewExampleScreen() { @@ -169,6 +170,10 @@ const sections: { title: string; data: Example[] }[] = [ component: Issue1156NestedHorizontalScrollRepro, name: 'Issue #1156 Nested Horizontal Scroll Repro', }, + { + component: Issue424SwipeCancelsPressRepro, + name: 'Issue #424 Swipe Cancels Press Repro', + }, ], }, ]; diff --git a/example/src/gh-issues/Issue424SwipeCancelsPressRepro.tsx b/example/src/gh-issues/Issue424SwipeCancelsPressRepro.tsx new file mode 100644 index 00000000..3ac1a6b2 --- /dev/null +++ b/example/src/gh-issues/Issue424SwipeCancelsPressRepro.tsx @@ -0,0 +1,76 @@ +/** + * Repro for #424 on Android: a swipe that starts on a Pressable in a page + * must cancel the touch, so the Pressable does not fire onPress, while a tap + * still presses it. "Block JS" keeps the JS thread busy for 5 s; a swipe in + * that window could fire the press before the fix. + */ +import React, { useState } from 'react'; +import { Button, Pressable, StyleSheet, Text, View } from 'react-native'; +import PagerView from 'react-native-pager-view'; + +const PAGES = ['Page 0', 'Page 1']; +const BUSY_MS = 5000; + +export function Issue424SwipeCancelsPressRepro() { + const [selectedPage, setSelectedPage] = useState(0); + const [presses, setPresses] = useState(0); + const [touch, setTouch] = useState('none'); + + return ( + + + {`Page: ${selectedPage} | Presses: ${presses} | Touch: ${touch}`} + +