From cd015052c326e87d22c6205a0d01c2d3768617d3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 16:10:26 +0300 Subject: [PATCH 01/11] [Android] Press buttons from hardware keys and accessibility services - ButtonViewGroup records a click's source while View's own onKeyDown, onKeyUp or performAccessibilityAction runs, admits that press while no child button is pressed, activates its native gesture as KEY or OTHER, and calls super.performClick(), which plays the click sound and sends TYPE_VIEW_CLICKED; receivedKeyEvent and the touch-exploration branch are gone. - An activation no pointer drives reports no pointer and numberOfPointers 0, at the view's visible center mapped through every view's transform, the root view's included; makeActive asks React Native to cancel its touch only for an activation reporting a pointer, and activateNativeHandlersForView skips a disabled handler and takes POINTER_TYPE_OTHER by default. - LegacyPressable and StatefulPressable take a native gesture reporting no pointer around their touch-based state machine, at the position it reports, while a tvOS focus press keeps the layout center; NativeViewGestureHandlerPayload declares the coordinates #4302 emits. - isKeyboardDismissingTap takes the press's pointer count, and StatefulPressable's long press clears a touch's keyboard verdict as it finalizes. - The Keyboard activation example with keyboard-activation-test.yaml and keyboard-touchable-test.yaml, pressableClickWithoutTouch.test.tsx and the api_v3 cases. --- .argent/flows/keyboard-activation-test.yaml | 145 ++++ .argent/flows/keyboard-touchable-test.yaml | 49 ++ apps/common-app/src/new_api/index.tsx | 2 + .../tests/keyboardActivation/index.tsx | 218 ++++++ .../gesturehandler/core/GestureHandler.kt | 42 ++ .../core/GestureHandlerOrchestrator.kt | 11 +- .../RNGestureHandlerButtonViewManager.kt | 77 ++- .../react/RNGestureHandlerRootHelper.kt | 4 +- .../react/RNGestureHandlerRootView.kt | 4 +- .../src/__tests__/api_v3.test.tsx | 96 ++- .../pressableClickWithoutTouch.test.tsx | 653 ++++++++++++++++++ .../src/components/Pressable/Pressable.tsx | 24 +- .../src/components/Pressable/utils.ts | 41 +- .../handlers/GestureHandlerEventPayload.ts | 12 + .../src/jestUtils/jestUtils.ts | 4 + .../src/v3/components/StatefulPressable.tsx | 44 +- .../src/v3/components/Touchable/Touchable.tsx | 6 +- .../src/v3/scrollViewInterop.ts | 6 +- 18 files changed, 1363 insertions(+), 75 deletions(-) create mode 100644 .argent/flows/keyboard-activation-test.yaml create mode 100644 .argent/flows/keyboard-touchable-test.yaml create mode 100644 apps/common-app/src/new_api/tests/keyboardActivation/index.tsx create mode 100644 packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx diff --git a/.argent/flows/keyboard-activation-test.yaml b/.argent/flows/keyboard-activation-test.yaml new file mode 100644 index 0000000000..dd5fd4bf41 --- /dev/null +++ b/.argent/flows/keyboard-activation-test.yaml @@ -0,0 +1,145 @@ +# Android only: a hardware Enter presses each kind of focused gesture-handler +# control once, as a key with no pointer, where a tap would land. Tab moves +# focus in view order, so after the taps and the console clear the counts below +# reach the inner default Pressable, then each control after it. +steps: + - echo: Launching Expo Example + - launch: com.example.ExpoExample + - await: { visible: { text: "Empty Example" }, timeout: 15000 } + - when: { platform: android } + steps: + - echo: "On the Home list, search for the keyboard activation example" + - type: { text: "keyboard activation", into: { id: "search-examples" } } + - await: { visible: { text: "Keyboard activation" } } + - echo: "Tap the example to open it" + - tap: { text: "Keyboard activation" } + - await: { visible: { id: "scaled-touchable" } } + - await: { idle: true } + + - echo: "Clear console" + - await: { visible: { id: "open-console-button" } } + - tap: { id: "open-console-button" } + - await: { visible: { id: "clear-console-button" } } + - tap: { id: "clear-console-button" } + - tap: { id: "close-console-button" } + - await: { hidden: { id: "close-console-button" } } + - await: { idle: true } + + - echo: "Tap the inner state-machine and legacy Pressables, the RectButton and the scaled Touchable" + - tap: { id: "stateful-inner" } + - tap: { id: "legacy-inner" } + - tap: { id: "rect-button" } + - tap: { id: "scaled-touchable" } + - await: { idle: true } + + - echo: "Each tap presses only its own control" + - tap: { id: "open-console-button" } + - await: { idle: true } + - await: { visible: { id: "close-console-button" } } + - assert: { visible: { text: { matches: '^3\. \[stateful inner\] onPress$' } } } + - assert: { visible: { text: { matches: '^6\. \[legacy inner\] onPress$' } } } + - assert: { visible: { text: { matches: '^7\. \[rect button\] onBegin TOUCH with 1 pointers$' } } } + - assert: { visible: { text: { matches: '^9\. \[scaled\] tap$' } } } + - assert: { hidden: { text: "outer]" } } + + - echo: "Clear console" + - tap: { id: "clear-console-button" } + - tap: { id: "close-console-button" } + - await: { hidden: { id: "close-console-button" } } + - await: { idle: true } + + - echo: "Tab to the inner default Pressable, which no pointer has touched, and press Enter" + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: enter } + - echo: "Tab to the state-machine Pressable whose inner was tapped, and press Enter" + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: enter } + - echo: "Tab past its inner to the legacy Pressable whose inner was tapped, and press Enter" + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: enter } + - echo: "Each Enter presses its Pressable once, the parent of a tapped one included" + - tap: { id: "open-console-button" } + - await: { idle: true } + - await: { visible: { id: "close-console-button" } } + - assert: { visible: { text: { matches: '^10\. \[pressable inner\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^11\. \[pressable inner\] onPressOut$' } } } + - assert: { visible: { text: { matches: '^12\. \[pressable inner\] onPress$' } } } + - assert: { visible: { text: { matches: '^13\. \[stateful outer\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^14\. \[stateful outer\] onPressOut$' } } } + - assert: { visible: { text: { matches: '^15\. \[stateful outer\] onPress$' } } } + - assert: { visible: { text: { matches: '^16\. \[legacy outer\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^17\. \[legacy outer\] onPressOut$' } } } + - assert: { visible: { text: { matches: '^18\. \[legacy outer\] onPress$' } } } + - assert: { hidden: { text: "[pressable outer]" } } + - assert: { hidden: { text: { matches: '^19\. ' } } } + + - echo: "Clear console" + - tap: { id: "clear-console-button" } + - tap: { id: "close-console-button" } + - await: { hidden: { id: "close-console-button" } } + - await: { idle: true } + + - echo: "Tab to the RectButton, the seventh control, and press Enter" + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: enter } + - echo: "Tab to the scaled Touchable and press Enter" + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: enter } + + - echo: "Enter reports a key with no pointer, and lands where the tap did" + - tap: { id: "open-console-button" } + - await: { idle: true } + - await: { visible: { id: "close-console-button" } } + - assert: { visible: { text: { matches: '^19\. \[rect button\] onBegin KEY with 0 pointers$' } } } + - assert: { visible: { text: { matches: '^20\. \[rect button\] onPress$' } } } + - assert: { visible: { text: { matches: '^21\. \[scaled\] key press lands on the tap$' } } } + - assert: { hidden: { text: { matches: '^22\. ' } } } + + - echo: "Clear console" + - tap: { id: "clear-console-button" } + - tap: { id: "close-console-button" } + - await: { hidden: { id: "close-console-button" } } + - await: { idle: true } + + - echo: "Tap the input inside the ScrollView, so the soft keyboard is up for it, then Tab to the Pressable beside it and press Enter" + - tap: { id: "typing-input" } + - await: { idle: true } + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: enter } + + - echo: "A key press is no tap dismissing the keyboard, so it presses the Pressable once" + - tap: { id: "open-console-button" } + - await: { idle: true } + - await: { visible: { id: "close-console-button" } } + - assert: { visible: { text: { matches: '^22\. \[typing pressable\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^23\. \[typing pressable\] onPressOut$' } } } + - assert: { visible: { text: { matches: '^24\. \[typing pressable\] onPress$' } } } + - assert: { hidden: { text: { matches: '^25\. ' } } } diff --git a/.argent/flows/keyboard-touchable-test.yaml b/.argent/flows/keyboard-touchable-test.yaml new file mode 100644 index 0000000000..ebdee31f73 --- /dev/null +++ b/.argent/flows/keyboard-touchable-test.yaml @@ -0,0 +1,49 @@ +# Android only: a hardware Enter or Space presses the focused touchable. Tab +# moves focus in view order, so the second Tab reaches the level 4 touchable. +steps: + - echo: Launching Expo Example + - launch: com.example.ExpoExample + - await: { visible: { text: "Empty Example" }, timeout: 15000 } + - when: { platform: android } + steps: + - echo: "On the Home list, search for the nested touchables example" + - type: { text: "nested touchables", into: { id: "search-examples" } } + - await: { visible: { text: "Nested touchables" } } + - echo: "Tap the example to open it" + - tap: { text: "Nested touchables" } + - await: { visible: { id: "level-4" } } + - await: { idle: true } + + - echo: "Clear console" + - await: { visible: { id: "open-console-button" } } + - tap: { id: "open-console-button" } + - await: { visible: { id: "clear-console-button" } } + - tap: { id: "clear-console-button" } + - tap: { id: "close-console-button" } + - await: { hidden: { id: "close-console-button" } } + - await: { idle: true } + + - echo: "Tab twice to focus the level 4 touchable, then press Enter and Space" + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: enter } + - tool: keyboard + args: { key: space } + + - echo: "Each key presses level 4 once and no other level logs" + - tap: { id: "open-console-button" } + - await: { idle: true } + - await: { visible: { id: "close-console-button" } } + - assert: { visible: { text: { matches: '^1\. L4 onPressIn$' } } } + - assert: { visible: { text: { matches: '^2\. L4 onPressOut$' } } } + - assert: { visible: { text: { matches: '^3\. L4 onPress$' } } } + - assert: { visible: { text: { matches: '^4\. L4 onPressIn$' } } } + - assert: { visible: { text: { matches: '^5\. L4 onPressOut$' } } } + - assert: { visible: { text: { matches: '^6\. L4 onPress$' } } } + - assert: { hidden: { text: "L1 on" } } + - assert: { hidden: { text: "L2 on" } } + - assert: { hidden: { text: "L3 on" } } + - assert: { hidden: { text: "7. L" } } diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index 1b62332eaf..2115543860 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -35,6 +35,7 @@ import PinchExample from './simple/pinch'; import RotationExample from './simple/rotation'; import TapExample from './simple/tap'; import ButtonChildOrderExample from './tests/buttonChildOrder'; +import KeyboardActivationExample from './tests/keyboardActivation'; import KeyboardShouldPersistTapsExample from './tests/keyboardShouldPersistTaps'; import NativeInterceptCancellationExample from './tests/nativeInterceptCancellation'; import NestedPressablesExample from './tests/nestedPressables'; @@ -133,6 +134,7 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ { name: 'Nested pressables', component: NestedPressablesExample }, { name: 'Nested touchables', component: NestedTouchablesExample }, { name: 'Button child order', component: ButtonChildOrderExample }, + { name: 'Keyboard activation', component: KeyboardActivationExample }, { name: 'Native intercept cancellation', component: NativeInterceptCancellationExample, diff --git a/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx b/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx new file mode 100644 index 0000000000..a41758e053 --- /dev/null +++ b/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx @@ -0,0 +1,218 @@ +import React, { useRef } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { + LegacyPressable, + PointerType, + Pressable, + RectButton, + ScrollView, + TextInput, + Touchable, +} from 'react-native-gesture-handler'; + +import { COLORS, useIndexedLogger } from '../../../common'; + +type Log = (message: string) => void; + +type Position = { absoluteX: number; absoluteY: number }; + +const pressHandlers = (log: Log, token: string) => ({ + onPressIn: () => log(`${token} onPressIn`), + onPressOut: () => log(`${token} onPressOut`), + onPress: () => log(`${token} onPress`), +}); + +// The first press is a tap; the next one, a key's, has to land where the tap did. +function useTapComparison(log: Log) { + const tap = useRef(null); + + return ({ absoluteX, absoluteY }: Position) => { + if (tap.current === null) { + tap.current = { absoluteX, absoluteY }; + log('[scaled] tap'); + return; + } + + const offsetX = Math.round(absoluteX - tap.current.absoluteX); + const offsetY = Math.round(absoluteY - tap.current.absoluteY); + tap.current = null; + log( + Math.abs(offsetX) <= 1 && Math.abs(offsetY) <= 1 + ? '[scaled] key press lands on the tap' + : `[scaled] key press misses the tap by ${offsetX}, ${offsetY}` + ); + }; +} + +// Keep the bracketed tokens out of on-screen text: the screen stays in the +// native tree behind the console modal, and keyboard-activation-test.yaml +// asserts those tokens are hidden while the modal covers it. +export default function KeyboardActivationExample() { + const log = useIndexedLogger(); + const compareWithTap = useTapComparison(log); + + return ( + + + Focus a control with Tab, then press Enter or Space. + + + + + + + + {/* A relation prop routes Pressable to its state-machine engine. */} + + + + + + + + + + + log( + `[rect button] onBegin ${PointerType[event.pointerType]} with ${event.numberOfPointers} pointers` + ) + } + onPress={() => log('[rect button] onPress')} + /> + + + + + + {/* A tap here while the input holds the soft keyboard only dismisses it; a key press is no such tap. */} + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 16, + gap: 16, + }, + hint: { + textAlign: 'center', + opacity: 0.6, + fontSize: 14, + }, + row: { + flexDirection: 'row', + gap: 8, + }, + outer: { + width: 88, + height: 80, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 12, + backgroundColor: COLORS.KINDA_BLUE, + }, + inner: { + width: 48, + height: 40, + borderRadius: 8, + backgroundColor: COLORS.NAVY, + }, + button: { + width: 200, + height: 48, + backgroundColor: COLORS.KINDA_GREEN, + }, + scaled: { + transform: [{ scale: 0.5 }], + }, + wide: { + width: 240, + height: 56, + borderRadius: 12, + backgroundColor: COLORS.YELLOW, + }, + typing: { + flexGrow: 0, + alignSelf: 'stretch', + }, + typingContent: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + input: { + flex: 1, + height: 40, + paddingHorizontal: 8, + borderWidth: 1, + borderRadius: 8, + borderColor: COLORS.GRAY, + }, + typingButton: { + width: 80, + height: 40, + borderRadius: 8, + backgroundColor: COLORS.NAVY, + }, + disabledButton: { + width: 200, + height: 32, + borderRadius: 8, + backgroundColor: COLORS.GRAY, + }, +}); diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 2f1a12b83a..b7d6a5cd2d 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -881,6 +881,21 @@ open class GestureHandler { isWithinBounds = false } + // An activation no pointer drives, a confirm key's or an accessibility service's click, reports no pointer + // (https://www.w3.org/TR/pointerevents3/#event-attributes) and happens at the view's visible center. + fun prepareActivationWithoutPointer(pointerType: Int) { + numberOfPointers = 0 + this.pointerType = pointerType + val centerView = coordinateView ?: return + lastAbsolutePositionX = centerView.width / 2f + lastAbsolutePositionY = centerView.height / 2f + tempPoint[0] = lastAbsolutePositionX + tempPoint[1] = lastAbsolutePositionY + mapPointToScreen(centerView, tempPoint) + lastEventOffsetX = tempPoint[0] - lastAbsolutePositionX + lastEventOffsetY = tempPoint[1] - lastAbsolutePositionY + } + private fun setPointerType(event: MotionEvent) { pointerType = event.getPointerType(event.actionIndex) } @@ -1044,6 +1059,9 @@ open class GestureHandler { const val POINTER_TYPE_KEY = 3 const val POINTER_TYPE_OTHER = 4 private const val MAX_POINTERS_COUNT = 17 + private val tempPoint = FloatArray(2) + private val tempRootOrigin = FloatArray(2) + private val tempLocationOnScreen = IntArray(2) private lateinit var pointerProps: Array private lateinit var pointerCoords: Array private fun initPointerProps(size: Int) { @@ -1062,6 +1080,30 @@ open class GestureHandler { private var nextEventCoalescingKey: Short = 0 private fun hitSlopSet(value: Float): Boolean = !java.lang.Float.isNaN(value) + // Maps a point in the view's coordinates to the screen through every ancestor's transform and scroll, the way + // View.getLocationOnScreen maps the view's origin; the root view's own screen location carries the window's offset. + private fun mapPointToScreen(view: View, point: FloatArray) { + var current = view + while (true) { + if (!current.matrix.isIdentity) { + current.matrix.mapPoints(point) + } + val parent = current.parent as? View ?: break + point[0] += current.left - parent.scrollX + point[1] += current.top - parent.scrollY + current = parent + } + // `getLocationOnScreen` maps the root view's origin through the same matrix the point took. + tempRootOrigin[0] = 0f + tempRootOrigin[1] = 0f + if (!current.matrix.isIdentity) { + current.matrix.mapPoints(tempRootOrigin) + } + current.getLocationOnScreen(tempLocationOnScreen) + point[0] += tempLocationOnScreen[0] - tempRootOrigin[0] + point[1] += tempLocationOnScreen[1] - tempRootOrigin[1] + } + fun stateToString(state: Int): String? { when (state) { STATE_UNDETERMINED -> return "UNDETERMINED" diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 71ace5fd8b..f05bc34d9a 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -267,7 +267,8 @@ class GestureHandlerOrchestrator( return } - if (handler.cancelsJSResponder) { + // An activation reporting no pointer, a key's or an accessibility service's, shares no touch with the JS responder. + if (handler.cancelsJSResponder && handler.numberOfPointers > 0) { onJSResponderCancelListener?.onCancelJSResponderRequested(handler) } @@ -946,13 +947,17 @@ class GestureHandlerOrchestrator( // be turned on and also confirm with the ViewConfigHelper implementation private fun isClipping(view: View) = view !is ViewGroup || viewConfigHelper.isViewClippingChildren(view) - fun activateNativeHandlersForView(view: View) { + fun activateNativeHandlersForView(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER) { handlerRegistry.getHandlersForView(view)?.forEach { - if (it !is NativeViewGestureHandler) { + if (it !is NativeViewGestureHandler || !it.isEnabled) { return@forEach } this.recordHandlerIfNotPresent(it, view) + if (!it.hasTrackedPointers) { + it.prepareActivationWithoutPointer(pointerType) + } + it.withMarkedAsInBounds { it.begin() it.activate() diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index a042563fdc..5d36575ce4 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -15,6 +15,7 @@ import android.graphics.drawable.RippleDrawable import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RectShape import android.os.Build +import android.os.Bundle import android.os.SystemClock import android.util.TypedValue import android.view.Choreographer @@ -610,7 +611,7 @@ class RNGestureHandlerButtonViewManager : private var needBackgroundUpdate = false private var lastEventTime = -1L private var lastAction = -1 - private var receivedKeyEvent = false + private var clickWithoutPointer: ClickWithoutPointer? = null private var currentAnimator: AnimatorSet? = null private var underlayDrawable: PaintDrawable? = null private var pressInTimestamp = 0L @@ -683,6 +684,16 @@ class RNGestureHandlerButtonViewManager : managedHandlerNeedsUpdate = true } + private inline fun withClickWithoutPointer(source: ClickWithoutPointer, block: () -> Boolean): Boolean { + val enclosingSource = clickWithoutPointer + clickWithoutPointer = source + try { + return block() + } finally { + clickWithoutPointer = enclosingSource + } + } + fun setOverflow(overflow: String?) { clipChildrenToShape = overflow == "hidden" invalidate() @@ -1570,35 +1581,49 @@ class RNGestureHandlerButtonViewManager : return false } - override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { - receivedKeyEvent = true - return super.onKeyUp(keyCode, event) + // View presses a focused view on a confirm key's down and clicks it on the key's up, from inside these calls + // (https://developer.android.com/reference/android/view/View#onKeyUp(int,%20android.view.KeyEvent)). + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = withClickWithoutPointer(ClickWithoutPointer.Key) { + super.onKeyDown(keyCode, event) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean = withClickWithoutPointer(ClickWithoutPointer.Key) { + super.onKeyUp(keyCode, event) } + // An accessibility service's click action reaches the view through this call, with touch exploration on or off + // (https://developer.android.com/reference/android/view/View#performAccessibilityAction(int,%20android.os.Bundle)). + override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean = + withClickWithoutPointer(ClickWithoutPointer.AccessibilityAction) { + super.performAccessibilityAction(action, arguments) + } + override fun performClick(): Boolean { // don't perform click when a child button is pressed (mainly to prevent sound effect of // a parent button from playing) - return if (!isChildTouched()) { - // Don't activate native handlers when isPressed is true (motion events are passing through) - if (context.isScreenReaderOn() && !isPressed) { - RNGestureHandlerRootView.findGestureHandlerRootView(this)?.activateNativeHandlers(this) - } else if (receivedKeyEvent) { - RNGestureHandlerRootView.findGestureHandlerRootView(this)?.activateNativeHandlers(this) - receivedKeyEvent = false - } + if (isChildTouched()) { + return false + } - if (soundResponder === this) { - tryFreeingResponder() - soundResponder = null - super.performClick() - } else { - false - } + val source = clickWithoutPointer + if (source != null) { + activateNativeHandlers(source.pointerType) + return super.performClick() + } + + return if (soundResponder === this) { + tryFreeingResponder() + soundResponder = null + super.performClick() } else { false } } + private fun activateNativeHandlers(pointerType: Int) { + RNGestureHandlerRootView.findGestureHandlerRootView(this)?.activateNativeHandlers(this, pointerType) + } + override fun setEnabled(enabled: Boolean) { val changed = enabled != isEnabled super.setEnabled(enabled) @@ -1631,10 +1656,13 @@ class RNGestureHandlerButtonViewManager : // button can be pressed alongside other button if both are non-exclusive and it doesn't have // any pressed children (to prevent pressing the parent when children is pressed). val canBePressedAlongsideOther = !exclusive && touchResponder?.exclusive != true && !isChildTouched() + // `performClick` refuses while a child is pressed, so the press that click would end is refused too. + val canBePressedWithoutPointer = clickWithoutPointer != null && !isChildTouched() - if (!pressed || touchResponder === this || canBePressedAlongsideOther) { + if (!pressed || touchResponder === this || canBePressedAlongsideOther || canBePressedWithoutPointer) { // we set pressed state only for current responder or any non-exclusive button when responder - // is null or non-exclusive, assuming it doesn't have pressed children + // is null or non-exclusive, assuming it doesn't have pressed children, and for a press no + // pointer drives, which competes with no touch isTouched = pressed super.setPressed(pressed) @@ -1702,6 +1730,13 @@ class RNGestureHandlerButtonViewManager : HoverOut, InteractionFinished, } + + // A click View makes with no pointer (https://www.w3.org/TR/pointerevents3/#event-attributes). + // It reports a key's pointer type, as web does for a key, or an unknown one; web also counts the key's pointer. + private enum class ClickWithoutPointer(val pointerType: Int) { + Key(GestureHandler.POINTER_TYPE_KEY), + AccessibilityAction(GestureHandler.POINTER_TYPE_OTHER), + } } companion object { diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt index baba360c10..00a1a08568 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt @@ -195,8 +195,8 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: return shouldIntercept } - fun activateNativeHandlers(view: View) { - orchestrator?.activateNativeHandlersForView(view) + fun activateNativeHandlers(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER) { + orchestrator?.activateNativeHandlersForView(view, pointerType) } fun recordHandlerIfNotPresent(handler: GestureHandler) { diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt index 297c60740b..e4aad9646a 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt @@ -85,8 +85,8 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { super.requestDisallowInterceptTouchEvent(disallowIntercept) } - fun activateNativeHandlers(view: View) { - rootHelper?.activateNativeHandlers(view) + fun activateNativeHandlers(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER) { + rootHelper?.activateNativeHandlers(view, pointerType) } fun onNativeTouchCancelled() { diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index 14e83c7750..b3a22bf299 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -9,6 +9,7 @@ import { Keyboard, TextInput, View } from 'react-native'; import GestureHandlerRootView from '../components/GestureHandlerRootView'; import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; +import { PointerType } from '../PointerType'; import { State } from '../State'; import { Pressable, RectButton, ScrollView, Touchable } from '../v3/components'; import { GestureDetector } from '../v3/detectors'; @@ -51,6 +52,15 @@ const fireNativeTap = (testID: string) => { fireEvent(button, 'buttonInteractionFinished', buttonEvent()); }; +// A press no pointer drives, such as a hardware Enter's, reports no pointer. +const buttonEventWithoutPointer = () => ({ + nativeEvent: { + ...buttonEvent().nativeEvent, + numberOfPointers: 0, + pointerType: PointerType.KEY, + }, +}); + describe('[API v3] Hooks', () => { test('Pan gesture', () => { const onBegin = jest.fn(); @@ -477,6 +487,9 @@ describe('[API v3] Components', () => { keyboardShouldPersistTaps, }); + // A tap is driven by one pointer. + const ONE_POINTER = 1; + // The drop requires a focused RN TextInput to blur. const focusInput = () => jest @@ -497,21 +510,35 @@ describe('[API v3] Components', () => { await act(flushImmediate); // Keyboard not shown yet -> nothing to dismiss. - expect(isKeyboardDismissingTap(makeContext('never'))).toBe(false); + expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( + false + ); showKeyboard(addListenerSpy); // `never` (and its default, undefined) drops the tap; the others never do. - expect(isKeyboardDismissingTap(makeContext('never'))).toBe(true); - expect(isKeyboardDismissingTap(makeContext(undefined))).toBe(true); - expect(isKeyboardDismissingTap(makeContext('handled'))).toBe(false); - expect(isKeyboardDismissingTap(makeContext('always'))).toBe(false); + expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( + true + ); + expect(isKeyboardDismissingTap(makeContext(undefined), ONE_POINTER)).toBe( + true + ); + expect(isKeyboardDismissingTap(makeContext('handled'), ONE_POINTER)).toBe( + false + ); + expect(isKeyboardDismissingTap(makeContext('always'), ONE_POINTER)).toBe( + false + ); // Outside an RNGH ScrollView there is no context, so nothing is dropped. - expect(isKeyboardDismissingTap(null)).toBe(false); + expect(isKeyboardDismissingTap(null, ONE_POINTER)).toBe(false); + // A key or an accessibility service presses without a pointer, which is no tap. + expect(isKeyboardDismissingTap(makeContext('never'), 0)).toBe(false); // The verdict must survive the dismissal blurring the input mid-tap. focusSpy.mockReturnValue(undefined); - expect(isKeyboardDismissingTap(makeContext('never'))).toBe(true); + expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( + true + ); addListenerSpy.mockRestore(); focusSpy.mockRestore(); @@ -532,12 +559,16 @@ describe('[API v3] Components', () => { showKeyboard(addListenerSpy); expect(TextInput.State.currentlyFocusedInput()).toBeNull(); - expect(isKeyboardDismissingTap(makeContext('never'))).toBe(false); + expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( + false + ); // Focus moving to an RN input while the keyboard stays up makes the // tap dismissible again. const focusSpy = focusInput(); - expect(isKeyboardDismissingTap(makeContext('never'))).toBe(true); + expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( + true + ); addListenerSpy.mockRestore(); focusSpy.mockRestore(); @@ -556,7 +587,9 @@ describe('[API v3] Components', () => { // A detached/floating keyboard reports height 0 - nothing to dismiss. showKeyboard(addListenerSpy, 0); - expect(isKeyboardDismissingTap(makeContext('never'))).toBe(false); + expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( + false + ); addListenerSpy.mockRestore(); }); @@ -606,6 +639,49 @@ describe('[API v3] Components', () => { focusSpy.mockRestore(); }); + test('Touchable fires every press callback for a press no pointer drives while the keyboard is visible (never)', async () => { + const addListenerSpy = jest.spyOn(Keyboard, 'addListener'); + const focusSpy = focusInput(); + const onPress = jest.fn(); + const onPressIn = jest.fn(); + const onPressOut = jest.fn(); + + render( + + + + + + ); + await act(flushImmediate); + showKeyboard(addListenerSpy); + expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( + true + ); + + // A key or an accessibility service dismisses no keyboard, so the press is not the dismissing tap. + const button = screen.getByTestId('touchable'); + fireEvent(button, 'buttonPressIn', buttonEventWithoutPointer()); + fireEvent(button, 'buttonPressOut', buttonEventWithoutPointer()); + fireEvent(button, 'buttonPress', buttonEventWithoutPointer()); + fireEvent( + button, + 'buttonInteractionFinished', + buttonEventWithoutPointer() + ); + + expect(onPressIn).toHaveBeenCalledTimes(1); + expect(onPressOut).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledTimes(1); + addListenerSpy.mockRestore(); + focusSpy.mockRestore(); + }); + test('Touchable fires onPress in never mode when the keyboard is not visible', async () => { const onPress = jest.fn(); diff --git a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx new file mode 100644 index 0000000000..6d63c2061d --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx @@ -0,0 +1,653 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React, { act } from 'react'; +import type { KeyboardEvent, Platform as PlatformModule } from 'react-native'; +import { + AccessibilityInfo, + DeviceEventEmitter, + Keyboard, + Platform, + Text, + TextInput, +} from 'react-native'; + +import type { + PressableEvent, + PressableProps, +} from '../components/Pressable/PressableProps'; +import type { + GestureTouchEvent, + TouchData, +} from '../handlers/gestureHandlerCommon'; +import type { GestureType } from '../handlers/gestures/gesture'; +import type { ComposedGesture as LegacyComposedGesture } from '../handlers/gestures/gestureComposition'; +import { GestureDetector as LegacyGestureDetector } from '../handlers/gestures/GestureDetector'; +import { longPressHandlerName } from '../handlers/LongPressGestureHandler'; +import { nativeViewHandlerName } from '../handlers/NativeViewGestureHandler'; +import { GestureHandlerRootView, LegacyPressable, State } from '../index'; +import { fireGestureHandler } from '../jestUtils'; +import { PointerType } from '../PointerType'; +import { TouchEventType } from '../TouchEventType'; +import { Pressable, ScrollView } from '../v3/components'; +import { GestureDetector } from '../v3/detectors'; +import { isComposedGesture } from '../v3/hooks/utils'; +import { + isKeyboardDismissingTap, + type JSResponderContextValue, + setKeyboardVisibility, +} from '../v3/scrollViewInterop'; +import type { ComposedGesture, SingleGesture } from '../v3/types'; +import { SingleGestureName } from '../v3/types'; + +// `jestSetup.js` replaces LegacyPressable with its testing mock, and this test is about the component itself. +jest.unmock('../components/Pressable/Pressable'); + +// Both state-machine engines read `Platform.OS` to pick their Android path. +jest.mock('react-native/Libraries/Utilities/Platform', () => { + const actual = jest.requireActual<{ default: typeof PlatformModule }>( + 'react-native/Libraries/Utilities/Platform' + ).default; + return { + __esModule: true, + default: { + ...actual, + OS: 'android', + select: (spec: Record) => + 'android' in spec ? spec.android : (spec.native ?? spec.default), + }, + }; +}); + +type StateTransition = { oldState: State; state: State }; + +const BEGAN: StateTransition = { + oldState: State.UNDETERMINED, + state: State.BEGAN, +}; +const ACTIVATED: StateTransition = { + oldState: State.BEGAN, + state: State.ACTIVE, +}; +const ENDED: StateTransition = { oldState: State.ACTIVE, state: State.END }; +const CANCELLED: StateTransition = { + oldState: State.ACTIVE, + state: State.CANCELLED, +}; + +type NativeGestureEvent = StateTransition & { + numberOfPointers: number; + pointerType: PointerType; + x?: number; + y?: number; + absoluteX?: number; + absoluteY?: number; +}; + +const reportingPointers = + (numberOfPointers: number, pointerType: PointerType) => + (transition: StateTransition): NativeGestureEvent => ({ + ...transition, + numberOfPointers, + pointerType, + }); + +const withTouch = reportingPointers(1, PointerType.TOUCH); + +const BUTTON_LAYOUT = { x: 0, y: 0, width: 120, height: 44 }; +const BUTTON_CENTER_TOUCH = { + x: BUTTON_LAYOUT.width / 2, + y: BUTTON_LAYOUT.height / 2, +}; +const BUTTON_SCREEN_ORIGIN = { x: 100, y: 300 }; + +// `prepareActivationWithoutPointer` places an activation no pointer drives at the button's center, and reports that center on the screen. +const POSITION_WITHOUT_POINTER = { + x: BUTTON_CENTER_TOUCH.x, + y: BUTTON_CENTER_TOUCH.y, + absoluteX: BUTTON_SCREEN_ORIGIN.x + BUTTON_CENTER_TOUCH.x, + absoluteY: BUTTON_SCREEN_ORIGIN.y + BUTTON_CENTER_TOUCH.y, +}; + +// A hardware Enter or Space reaches the button through `performClick`, which +// begins, activates and ends its native gesture reporting no pointer +// (https://www.w3.org/TR/pointerevents3/#event-attributes); an accessibility +// service's click does the same with an unknown pointer type. +const withoutPointer = + (pointerType: PointerType) => + (transition: StateTransition): NativeGestureEvent => ({ + ...reportingPointers(0, pointerType)(transition), + ...POSITION_WITHOUT_POINTER, + }); + +const CLICK_WITHOUT_TOUCH = [BEGAN, ACTIVATED, ENDED].map( + withoutPointer(PointerType.KEY) +); +// tvOS sends a focus Select press with no touch, at (0, 0) in the view and at the view's origin in the window. +const TV_SELECT_PRESS = [BEGAN, ACTIVATED, ENDED].map( + (transition): NativeGestureEvent => ({ + ...reportingPointers(0, PointerType.TOUCH)(transition), + x: 0, + y: 0, + absoluteX: BUTTON_SCREEN_ORIGIN.x, + absoluteY: BUTTON_SCREEN_ORIGIN.y, + }) +); +const PRESSED_WHERE_NATIVE_GESTURE_REPORTS = { + locationX: POSITION_WITHOUT_POINTER.x, + locationY: POSITION_WITHOUT_POINTER.y, + pageX: POSITION_WITHOUT_POINTER.absoluteX, + pageY: POSITION_WITHOUT_POINTER.absoluteY, +}; +const CANCELLED_WITHOUT_TOUCH = [BEGAN, ACTIVATED, CANCELLED].map( + withoutPointer(PointerType.KEY) +); +const ACCESSIBILITY_CLICK = [BEGAN, ACTIVATED, ENDED].map( + withoutPointer(PointerType.OTHER) +); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// A touch there begins both gestures, but lies outside the button, so it never presses in. +const PRESS_RETENTION_OFFSET = 20; +const RETENTION_ZONE_TOUCH = { x: 130, y: 22 }; + +type PressHandler = jest.Mock; + +type PressHandlers = { + onPress: PressHandler; + onPressIn: PressHandler; + onPressOut: PressHandler; +}; + +type EngineProps = PressHandlers & Pick; + +type PressableContainer = (pressable: React.ReactElement) => React.ReactElement; + +const renderAlone: PressableContainer = (pressable) => pressable; + +type EngineDriver = { + render: (props: EngineProps, container?: PressableContainer) => Promise; + fireNativeGesture: (events: NativeGestureEvent[]) => void; + changeNativeGestureState: (event: NativeGestureEvent) => void; + touchDownOnPressGesture: (touch: Pick) => void; +}; + +const createPressHandlers = (): PressHandlers => ({ + onPress: jest.fn(), + onPressIn: jest.fn(), + onPressOut: jest.fn(), +}); + +const expectPressedOnceAt = ( + handlers: PressHandlers, + position: Partial +) => { + for (const handler of [ + handlers.onPressIn, + handlers.onPressOut, + handlers.onPress, + ]) { + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].nativeEvent).toMatchObject(position); + } +}; + +const flushImmediate = () => + new Promise((resolve) => { + setImmediate(() => resolve(undefined)); + }); + +// `useIsScreenReaderEnabled` reads the screen reader asynchronously after mount. +const renderSettled = async (element: React.ReactElement) => { + render(element); + await act(flushImmediate); +}; + +const layOutButton = () => { + fireEvent(screen.getByTestId('pressable'), 'layout', { + nativeEvent: { layout: BUTTON_LAYOUT }, + }); +}; + +const insideScrollViewDismissingKeyboard: PressableContainer = (pressable) => ( + {pressable} +); + +// A tap is driven by one pointer. +const ONE_POINTER = 1; + +const NEVER_PERSIST_TAPS_CONTEXT: JSResponderContextValue = { + isRNGHResponderEvent: { current: false }, + keyboardShouldPersistTaps: 'never', +}; + +const focusTextInput = () => { + jest + .spyOn(TextInput.State, 'currentlyFocusedInput') + .mockReturnValue( + {} as ReturnType + ); +}; + +const KEYBOARD_SHOWN: KeyboardEvent = { + duration: 0, + easing: 'keyboard', + endCoordinates: { screenX: 0, screenY: 500, width: 360, height: 300 }, +}; + +type KeyboardListenerSpy = jest.SpyInstance< + ReturnType, + Parameters +>; + +// A gesture-handler `ScrollView` tracks the keyboard through the listeners it adds on mount. +const emitKeyboardEvent = ( + addListenerSpy: KeyboardListenerSpy, + eventName: 'keyboardDidHide' | 'keyboardDidShow' +) => { + const listener = addListenerSpy.mock.calls.find( + ([listenedName]) => listenedName === eventName + )?.[1]; + if (listener === undefined) { + throw new Error('No ScrollView tracks the keyboard'); + } + act(() => { + listener(KEYBOARD_SHOWN); + }); +}; + +const createTouchEvent = ( + handlerTag: number, + eventType: TouchEventType, + { x, y }: Pick +): GestureTouchEvent => { + const touch: TouchData = { id: 0, x, y, absoluteX: x, absoluteY: y }; + return { + handlerTag, + numberOfTouches: 1, + state: State.BEGAN, + eventType, + allTouches: [touch], + changedTouches: [touch], + pointerType: PointerType.TOUCH, + }; +}; + +const findStatefulGesture = ( + type: SingleGestureName +): SingleGesture => { + const composed = screen.UNSAFE_getByType(GestureDetector).props + .gesture as ComposedGesture; + for (const candidate of composed.gestures) { + if (!isComposedGesture(candidate) && candidate.type === type) { + return candidate; + } + } + throw new Error(`StatefulPressable renders no ${type} gesture`); +}; + +const findLegacyGesture = (handlerName: string): GestureType => { + const composed = screen.UNSAFE_getByType(LegacyGestureDetector).props + .gesture as LegacyComposedGesture; + const gesture = composed + .toGestureArray() + .find((candidate: GestureType) => candidate.handlerName === handlerName); + if (gesture === undefined) { + throw new Error(`LegacyPressable renders no ${handlerName}`); + } + return gesture; +}; + +const statefulDriver: EngineDriver = { + render: (props, container = renderAlone) => + renderSettled( + + {container( + // A relation prop routes `Pressable` to the `StatefulPressable` engine. + + Press Me + + )} + + ), + fireNativeGesture: (events) => { + act(() => { + fireGestureHandler(findStatefulGesture(SingleGestureName.Native), events); + }); + }, + changeNativeGestureState: ({ oldState, state, ...pointers }) => { + const gesture = findStatefulGesture(SingleGestureName.Native); + act(() => { + gesture.detectorCallbacks.jsEventHandler?.({ + handlerTag: gesture.handlerTag, + oldState, + state, + handlerData: pointers, + }); + }); + }, + touchDownOnPressGesture: (touch) => { + const gesture = findStatefulGesture(SingleGestureName.LongPress); + act(() => { + gesture.detectorCallbacks.jsEventHandler?.( + createTouchEvent(gesture.handlerTag, TouchEventType.TOUCHES_DOWN, touch) + ); + }); + }, +}; + +const legacyDriver: EngineDriver = { + render: (props, container = renderAlone) => + renderSettled( + + {container( + + Press Me + + )} + + ), + fireNativeGesture: (events) => { + const gesture = findLegacyGesture(nativeViewHandlerName); + act(() => { + fireGestureHandler(gesture, events); + }); + }, + changeNativeGestureState: (event) => { + const { handlerTag } = findLegacyGesture(nativeViewHandlerName); + act(() => { + DeviceEventEmitter.emit('onGestureHandlerStateChange', { + handlerTag, + ...event, + pointerInside: true, + }); + }); + }, + touchDownOnPressGesture: (touch) => { + const { handlerTag } = findLegacyGesture(longPressHandlerName); + act(() => { + DeviceEventEmitter.emit( + 'onGestureHandlerEvent', + createTouchEvent(handlerTag, TouchEventType.TOUCHES_DOWN, touch) + ); + }); + }, +}; + +describe.each([ + ['StatefulPressable', statefulDriver], + ['LegacyPressable', legacyDriver], +] as const)('%s on Android', (_name, driver) => { + test('runs onPress once when its button is clicked without a touch', async () => { + const handlers = createPressHandlers(); + await driver.render(handlers); + layOutButton(); + + driver.fireNativeGesture(CLICK_WITHOUT_TOUCH); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('presses at the position the native gesture reports for a click without a touch', async () => { + const handlers = createPressHandlers(); + await driver.render(handlers); + layOutButton(); + + driver.fireNativeGesture(CLICK_WITHOUT_TOUCH); + + expectPressedOnceAt(handlers, PRESSED_WHERE_NATIVE_GESTURE_REPORTS); + }); + + test('presses in and out without onPress when the native gesture is cancelled without a touch', async () => { + const handlers = createPressHandlers(); + await driver.render(handlers); + layOutButton(); + + driver.fireNativeGesture(CANCELLED_WITHOUT_TOUCH); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).not.toHaveBeenCalled(); + }); + + test('runs onPress once for a screen reader click', async () => { + jest + .spyOn(AccessibilityInfo, 'isScreenReaderEnabled') + .mockResolvedValueOnce(true); + const handlers = createPressHandlers(); + await driver.render(handlers); + layOutButton(); + + driver.fireNativeGesture(ACCESSIBILITY_CLICK); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + // Switch Access and Voice Access click without turning on touch exploration, which is what reads as a screen reader. + test('runs onPress once for an accessibility service click with no screen reader on', async () => { + const handlers = createPressHandlers(); + await driver.render(handlers); + layOutButton(); + + driver.fireNativeGesture(ACCESSIBILITY_CLICK); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('runs onPress once for a touch press', async () => { + const handlers = createPressHandlers(); + await driver.render(handlers); + layOutButton(); + + driver.changeNativeGestureState(withTouch(BEGAN)); + driver.touchDownOnPressGesture(BUTTON_CENTER_TOUCH); + driver.changeNativeGestureState(withTouch(ACTIVATED)); + driver.changeNativeGestureState(withTouch(ENDED)); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('runs onPress once for a click after a touch its native gesture never saw', async () => { + const handlers = createPressHandlers(); + await driver.render(handlers); + layOutButton(); + + // A tap on a nested button in a scroll container cancels this button's native gesture while undetermined, which reaches JS as nothing, after its long press saw the touch. + driver.touchDownOnPressGesture(BUTTON_CENTER_TOUCH); + driver.fireNativeGesture(CLICK_WITHOUT_TOUCH); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('runs nothing for a native gesture that reports a pointer the press gesture never saw', async () => { + const handlers = createPressHandlers(); + await driver.render(handlers); + layOutButton(); + + driver.changeNativeGestureState(withTouch(BEGAN)); + driver.changeNativeGestureState(withTouch(ACTIVATED)); + driver.changeNativeGestureState(withTouch(ENDED)); + + expect(handlers.onPressIn).not.toHaveBeenCalled(); + expect(handlers.onPress).not.toHaveBeenCalled(); + }); + + test('runs nothing for a touch that begins in the press retention zone', async () => { + const handlers = createPressHandlers(); + await driver.render({ + ...handlers, + pressRetentionOffset: PRESS_RETENTION_OFFSET, + }); + layOutButton(); + + driver.changeNativeGestureState(withTouch(BEGAN)); + driver.touchDownOnPressGesture(RETENTION_ZONE_TOUCH); + driver.changeNativeGestureState(withTouch(ACTIVATED)); + driver.changeNativeGestureState(withTouch(ENDED)); + + expect(handlers.onPressIn).not.toHaveBeenCalled(); + expect(handlers.onPress).not.toHaveBeenCalled(); + }); +}); + +describe('StatefulPressable on a TV', () => { + test('presses an Android TV key press at the position its native gesture reports', async () => { + jest.replaceProperty(Platform, 'isTV', true); + const handlers = createPressHandlers(); + await statefulDriver.render(handlers); + layOutButton(); + + statefulDriver.fireNativeGesture(CLICK_WITHOUT_TOUCH); + + expectPressedOnceAt(handlers, PRESSED_WHERE_NATIVE_GESTURE_REPORTS); + }); + + test('presses a tvOS focus press at the layout center, with no page position', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + jest.replaceProperty(Platform, 'isTV', true); + const handlers = createPressHandlers(); + await statefulDriver.render(handlers); + layOutButton(); + + statefulDriver.fireNativeGesture(TV_SELECT_PRESS); + + expectPressedOnceAt(handlers, { + locationX: BUTTON_CENTER_TOUCH.x, + locationY: BUTTON_CENTER_TOUCH.y, + pageX: -1, + pageY: -1, + }); + }); +}); + +// The press gesture sees a touch, and a scroll cancels it before the native gesture begins. +const cancelTouchOnStatefulPressGesture = ( + touch: Pick +) => { + const gesture = findStatefulGesture(SingleGestureName.LongPress); + const handle = gesture.detectorCallbacks.jsEventHandler; + const pointers = { + numberOfPointers: ONE_POINTER, + pointerType: PointerType.TOUCH, + }; + act(() => { + handle?.({ + handlerTag: gesture.handlerTag, + ...BEGAN, + handlerData: pointers, + }); + handle?.( + createTouchEvent(gesture.handlerTag, TouchEventType.TOUCHES_DOWN, touch) + ); + handle?.( + createTouchEvent(gesture.handlerTag, TouchEventType.TOUCHES_CANCEL, touch) + ); + handle?.({ + handlerTag: gesture.handlerTag, + oldState: State.BEGAN, + state: State.CANCELLED, + handlerData: pointers, + }); + }); +}; + +// Only `StatefulPressable` drops the tap that dismisses the keyboard, so only it is driven with the keyboard up. +describe('StatefulPressable on Android with the keyboard up for a React Native input', () => { + const renderWithKeyboardUp = async (handlers: PressHandlers) => { + const addListenerSpy = jest.spyOn(Keyboard, 'addListener'); + focusTextInput(); + await statefulDriver.render(handlers, insideScrollViewDismissingKeyboard); + layOutButton(); + emitKeyboardEvent(addListenerSpy, 'keyboardDidShow'); + expect( + isKeyboardDismissingTap(NEVER_PERSIST_TAPS_CONTEXT, ONE_POINTER) + ).toBe(true); + return addListenerSpy; + }; + + afterEach(() => { + setKeyboardVisibility(false); + }); + + test('drops a touch press, which is the tap dismissing the keyboard', async () => { + const handlers = createPressHandlers(); + await renderWithKeyboardUp(handlers); + + statefulDriver.changeNativeGestureState(withTouch(BEGAN)); + statefulDriver.touchDownOnPressGesture(BUTTON_CENTER_TOUCH); + statefulDriver.changeNativeGestureState(withTouch(ACTIVATED)); + statefulDriver.changeNativeGestureState(withTouch(ENDED)); + + expect(handlers.onPressIn).not.toHaveBeenCalled(); + expect(handlers.onPress).not.toHaveBeenCalled(); + }); + + test('runs onPress once for a click without a touch', async () => { + const handlers = createPressHandlers(); + await renderWithKeyboardUp(handlers); + + statefulDriver.fireNativeGesture(CLICK_WITHOUT_TOUCH); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('presses in as a screen reader click begins', async () => { + jest + .spyOn(AccessibilityInfo, 'isScreenReaderEnabled') + .mockResolvedValueOnce(true); + const handlers = createPressHandlers(); + await renderWithKeyboardUp(handlers); + const [began, activated, ended] = ACCESSIBILITY_CLICK; + + statefulDriver.changeNativeGestureState(began); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + + statefulDriver.changeNativeGestureState(activated); + statefulDriver.changeNativeGestureState(ended); + + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('runs onPress once for a click without a touch after a touch the press gesture saw cancelled', async () => { + const handlers = createPressHandlers(); + await renderWithKeyboardUp(handlers); + + cancelTouchOnStatefulPressGesture(BUTTON_CENTER_TOUCH); + statefulDriver.fireNativeGesture(CLICK_WITHOUT_TOUCH); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('runs onPress once for a touch press after the keyboard hides, after a touch the press gesture saw cancelled', async () => { + const handlers = createPressHandlers(); + const addListenerSpy = await renderWithKeyboardUp(handlers); + + cancelTouchOnStatefulPressGesture(BUTTON_CENTER_TOUCH); + emitKeyboardEvent(addListenerSpy, 'keyboardDidHide'); + statefulDriver.changeNativeGestureState(withTouch(BEGAN)); + statefulDriver.touchDownOnPressGesture(BUTTON_CENTER_TOUCH); + statefulDriver.changeNativeGestureState(withTouch(ACTIVATED)); + statefulDriver.changeNativeGestureState(withTouch(ENDED)); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx index cdd52cda0f..20d2f55a13 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx @@ -32,6 +32,7 @@ import { addInsets, gestureToPressableEvent, gestureTouchToPressableEvent, + isActivatedWithoutPointer, isTouchWithinInset, numberAsInset, viewCenterToPressableEvent, @@ -302,7 +303,12 @@ const LegacyPressable = (props: LegacyPressableProps) => { handlePressOut(pressableEvent, false); } }) - .onBegin(() => { + .onBegin((event) => { + if (isActivatedWithoutPointer(event)) { + // The press state machine is touch-based and never receives LONG_PRESS_TOUCHES_DOWN here. + handlePressIn(gestureToPressableEvent(event)); + return; + } if (Platform.OS === 'android' && isScreenReaderEnabled) { stateMachine.handleEvent( StateMachineEvent.NATIVE_BEGIN, @@ -318,7 +324,13 @@ const LegacyPressable = (props: LegacyPressableProps) => { stateMachine.handleEvent(StateMachineEvent.NATIVE_START); } }) - .onFinalize((_event, success) => { + .onFinalize((event, success) => { + if (isActivatedWithoutPointer(event)) { + handlePressOut(gestureToPressableEvent(event), success); + handleFinalize(); + return; + } + if (Platform.OS !== 'web') { // On Web we use LongPress().onFinalize() instead of Native().onFinalize(), // as Native cancels on mouse move, and LongPress does not. @@ -334,7 +346,13 @@ const LegacyPressable = (props: LegacyPressableProps) => { } }) .shouldActivateOnStart(Platform.OS === 'web'), - [stateMachine, handlePressOut, handleFinalize, isScreenReaderEnabled] + [ + stateMachine, + handlePressIn, + handlePressOut, + handleFinalize, + isScreenReaderEnabled, + ] ); const isPressableEnabled = disabled !== true; diff --git a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts index ca655a1a31..92bfcba6a9 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts +++ b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts @@ -1,4 +1,5 @@ import type { Insets } from 'react-native'; +import { Platform } from 'react-native'; import type { GestureStateChangeEvent, @@ -8,8 +9,13 @@ import type { import type { HoverGestureHandlerEventPayload, LongPressGestureHandlerEventPayload, + NativeViewGestureHandlerPayload, } from '../../handlers/GestureHandlerEventPayload'; -import type { HoverGestureEvent, LongPressGestureEvent } from '../../v3'; +import type { + HoverGestureEvent, + LongPressGestureEvent, + NativeGestureEvent, +} from '../../v3'; import type { HoverGestureActiveEvent } from '../../v3/hooks'; import type { InnerPressableEvent, @@ -47,14 +53,19 @@ const touchDataToPressEvent = ( changedTouches: [], // Always empty - legacy compatibility }); +type PositionedGestureEvent = + | GestureStateChangeEvent< + | HoverGestureHandlerEventPayload + | LongPressGestureHandlerEventPayload + | NativeViewGestureHandlerPayload + > + | HoverGestureEvent + | HoverGestureActiveEvent + | LongPressGestureEvent + | NativeGestureEvent; + const gestureToPressEvent = ( - event: - | GestureStateChangeEvent< - HoverGestureHandlerEventPayload | LongPressGestureHandlerEventPayload - > - | HoverGestureEvent - | HoverGestureActiveEvent - | LongPressGestureEvent, + event: PositionedGestureEvent, timestamp: number, targetId: number ): InnerPressableEvent => ({ @@ -80,13 +91,7 @@ const isTouchWithinInset = ( (touch?.locationY ?? 0) > -(inset.top ?? 0); const gestureToPressableEvent = ( - event: - | GestureStateChangeEvent< - HoverGestureHandlerEventPayload | LongPressGestureHandlerEventPayload - > - | HoverGestureEvent - | HoverGestureActiveEvent - | LongPressGestureEvent + event: PositionedGestureEvent ): PressableEvent => { const timestamp = Date.now(); @@ -178,10 +183,16 @@ const viewCenterToPressableEvent = ( }; }; +// Android reports a key's or an accessibility service's activation of the native gesture with no pointer +// (https://www.w3.org/TR/pointerevents3/#event-attributes). +const isActivatedWithoutPointer = (event: { numberOfPointers: number }) => + Platform.OS === 'android' && event.numberOfPointers === 0; + export { addInsets, gestureToPressableEvent, gestureTouchToPressableEvent, + isActivatedWithoutPointer, isTouchWithinInset, numberAsInset, viewCenterToPressableEvent, diff --git a/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts b/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts index 35c0291432..04bc0439b0 100644 --- a/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts +++ b/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts @@ -67,6 +67,18 @@ export type NativeViewGestureHandlerPayload = { * True if gesture was performed inside of containing view, false otherwise. */ pointerInside: boolean; + + /** X of the pointer, or of the view's center when none drives the gesture, relative to the view. */ + x: number; + + /** Y of the pointer, or of the view's center when none drives the gesture, relative to the view. */ + y: number; + + /** X of the same point relative to the window. */ + absoluteX: number; + + /** Y of the same point relative to the window. */ + absoluteY: number; }; export type PanGestureHandlerEventPayload = { diff --git a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts index c103dc37f2..3eecd72a8e 100644 --- a/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts +++ b/packages/react-native-gesture-handler/src/jestUtils/jestUtils.ts @@ -115,6 +115,10 @@ const handlersDefaultEvents: DefaultEventsMapping = { [nativeViewHandlerName]: { pointerInside: true, numberOfPointers: 1, + x: 0, + y: 0, + absoluteX: 0, + absoluteY: 0, }, [panHandlerName]: { x: 0, diff --git a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx index bfbf25608e..e8b490a031 100644 --- a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx @@ -28,6 +28,7 @@ import { addInsets, gestureToPressableEvent, gestureTouchToPressableEvent, + isActivatedWithoutPointer, isTouchWithinInset, numberAsInset, viewCenterToPressableEvent, @@ -37,6 +38,7 @@ import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; import { useIsScreenReaderEnabled } from '../../useIsScreenReaderEnabled'; import { INT32_MAX, isTestEnv } from '../../utils'; import { GestureDetector } from '../detectors'; +import type { NativeGestureEvent } from '../hooks'; import { useHoverGesture, useLongPressGesture, @@ -168,9 +170,15 @@ const StatefulPressable = (props: PressableProps) => { setPressedState(false); }, [cancelDelayedPress, cancelLongPress]); - const captureKeyboardDismiss = useCallback(() => { - dropKeyboardTapRef.current ??= isKeyboardDismissingTap(jsResponderContext); - }, [jsResponderContext]); + const captureKeyboardDismiss = useCallback( + (numberOfPointers: number) => { + dropKeyboardTapRef.current ??= isKeyboardDismissingTap( + jsResponderContext, + numberOfPointers + ); + }, + [jsResponderContext] + ); const handlePressIn = useCallback( (event: PressableEvent, skipBoundsCheck = false) => { @@ -222,6 +230,12 @@ const StatefulPressable = (props: PressableProps) => { [handleFinalize, innerHandlePressIn, onPress, onPressOut] ); + // Android, a TV's included, reports where a key or an accessibility service activated the view; tvOS reports none. + const toPressableEventWithoutPointer = (event: NativeGestureEvent) => + Platform.OS === 'android' + ? gestureToPressableEvent(event) + : viewCenterToPressableEvent(dimensions.current); + const stateMachine = useMemo(() => new PressableStateMachine(), []); const isScreenReaderEnabled = useIsScreenReaderEnabled(); @@ -297,7 +311,7 @@ const StatefulPressable = (props: PressableProps) => { maxDistance: INT32_MAX, // Stops long press from cancelling on touch move cancelsTouchesInView: false, onTouchesDown: (event) => { - captureKeyboardDismiss(); + captureKeyboardDismiss(event.numberOfTouches); if (dropKeyboardTapRef.current) { return; @@ -322,6 +336,9 @@ const StatefulPressable = (props: PressableProps) => { handlePressOut(pressableEvent, false); }, onFinalize: (event) => { + // A touch ends here even when it never pressed in, so its keyboard verdict ends with it. + dropKeyboardTapRef.current = null; + if (Platform.OS !== 'web') { return; } @@ -351,19 +368,19 @@ const StatefulPressable = (props: PressableProps) => { handlePressOut(pressableEvent, false); } }, - onBegin: () => { - captureKeyboardDismiss(); + onBegin: (event) => { + captureKeyboardDismiss(event.numberOfPointers); if (dropKeyboardTapRef.current) { return; } - if (Platform.isTV) { - // tvOS drives this native gesture from the focus-engine Select press. - // The press state machine is touch-based and never + if (Platform.isTV || isActivatedWithoutPointer(event)) { + // tvOS drives this native gesture from the focus-engine Select press, and Android from a key + // or an accessibility service with no pointer. The press state machine is touch-based and never // receives LONG_PRESS_TOUCHES_DOWN here, so bypass it and drive the press handlers directly. // A focus-driven press has no coordinates, so skip the hit-slop bounds check entirely. - handlePressIn(viewCenterToPressableEvent(dimensions.current), true); + handlePressIn(toPressableEventWithoutPointer(event), true); return; } if (Platform.OS === 'android' && isScreenReaderEnabled) { @@ -387,11 +404,8 @@ const StatefulPressable = (props: PressableProps) => { return; } - if (Platform.isTV) { - handlePressOut( - viewCenterToPressableEvent(dimensions.current), - !event.canceled - ); + if (Platform.isTV || isActivatedWithoutPointer(event)) { + handlePressOut(toPressableEventWithoutPointer(event), !event.canceled); handleFinalize(); return; } diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx index 5ee7702419..226323f334 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx @@ -127,8 +127,10 @@ export const Touchable = (props: TouchableProps) => { // PressIn opens every press sequence; capture the verdict once per // sequence so a re-entry PressIn (with cancelOnLeave={false}) doesn't // overwrite it after the keyboard is already dismissed. - dropKeyboardTapRef.current ??= - isKeyboardDismissingTap(jsResponderContext); + dropKeyboardTapRef.current ??= isKeyboardDismissingTap( + jsResponderContext, + e.nativeEvent.numberOfPointers + ); if (dropKeyboardTapRef.current) { return; diff --git a/packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts b/packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts index ee469e5621..a890fafb48 100644 --- a/packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts +++ b/packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts @@ -40,9 +40,11 @@ export function setKeyboardVisibility(visible: boolean) { } export function isKeyboardDismissingTap( - jsResponderContext: JSResponderContextValue | null | undefined + jsResponderContext: JSResponderContextValue | null | undefined, + numberOfPointers: number ): boolean { - if (jsResponderContext == null) { + // Only a tap dismisses the keyboard; a key or an accessibility service presses without a pointer. + if (jsResponderContext == null || numberOfPointers === 0) { return false; } From 6cb2065c27f723f5c85ceba166af4a357e07ca9e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 18:47:30 +0300 Subject: [PATCH 02/11] [Android] Press beside a touch, and cancel the JS responder from a gesture's first touch A key or an accessibility service's click pressed nothing while a finger rested or a mouse hovered elsewhere in the root: the recorded handlers' check from #4127, which decides between handlers competing for the touch being dispatched, cancelled the button's handler against the root's own. The check now runs only inside touch dispatch, and `performClick` clicks only for a press a handler took. `makeActive` reads `hasTrackedPointers` instead of `numberOfPointers`, which `handle()` sets after the touch-down callbacks, so a gesture the state manager activates from its first `onTouchesDown` cancels React Native's responder again. The Keyboard activation example and its flow hold that case. The native gesture's payload docs name each platform's position for a press no pointer drives. --- .argent/flows/keyboard-activation-test.yaml | 33 +++++++++++++++---- .../tests/keyboardActivation/index.tsx | 31 ++++++++++++++++- .../core/GestureHandlerOrchestrator.kt | 15 ++++++--- .../RNGestureHandlerButtonViewManager.kt | 9 +++-- .../react/RNGestureHandlerRootHelper.kt | 5 ++- .../react/RNGestureHandlerRootView.kt | 5 ++- .../handlers/GestureHandlerEventPayload.ts | 4 +-- 7 files changed, 77 insertions(+), 25 deletions(-) diff --git a/.argent/flows/keyboard-activation-test.yaml b/.argent/flows/keyboard-activation-test.yaml index dd5fd4bf41..53c6944408 100644 --- a/.argent/flows/keyboard-activation-test.yaml +++ b/.argent/flows/keyboard-activation-test.yaml @@ -1,7 +1,8 @@ # Android only: a hardware Enter presses each kind of focused gesture-handler -# control once, as a key with no pointer, where a tap would land. Tab moves -# focus in view order, so after the taps and the console clear the counts below -# reach the inner default Pressable, then each control after it. +# control once, as a key with no pointer, where a tap would land, while a +# gesture its touch activates still takes that touch from React Native. Tab +# moves focus in view order, so after the taps and the console clear the counts +# below reach the inner default Pressable, then each control after it. steps: - echo: Launching Expo Example - launch: com.example.ExpoExample @@ -127,6 +128,24 @@ steps: - await: { hidden: { id: "close-console-button" } } - await: { idle: true } + - echo: "Tap React Native's Pressable inside the long press its touch activates, the first touch it gets" + - tap: { id: "react-native-pressable" } + - await: { idle: true } + + - echo: "The touch's activation takes the touch from React Native's Pressable, which never presses" + - tap: { id: "open-console-button" } + - await: { idle: true } + - await: { visible: { id: "close-console-button" } } + - assert: { visible: { text: { matches: '^22\. \[react native pressable\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^23\. \[react native pressable\] onPressOut$' } } } + - assert: { hidden: { text: { matches: '^24\. ' } } } + + - echo: "Clear console" + - tap: { id: "clear-console-button" } + - tap: { id: "close-console-button" } + - await: { hidden: { id: "close-console-button" } } + - await: { idle: true } + - echo: "Tap the input inside the ScrollView, so the soft keyboard is up for it, then Tab to the Pressable beside it and press Enter" - tap: { id: "typing-input" } - await: { idle: true } @@ -139,7 +158,7 @@ steps: - tap: { id: "open-console-button" } - await: { idle: true } - await: { visible: { id: "close-console-button" } } - - assert: { visible: { text: { matches: '^22\. \[typing pressable\] onPressIn$' } } } - - assert: { visible: { text: { matches: '^23\. \[typing pressable\] onPressOut$' } } } - - assert: { visible: { text: { matches: '^24\. \[typing pressable\] onPress$' } } } - - assert: { hidden: { text: { matches: '^25\. ' } } } + - assert: { visible: { text: { matches: '^24\. \[typing pressable\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^25\. \[typing pressable\] onPressOut$' } } } + - assert: { visible: { text: { matches: '^26\. \[typing pressable\] onPress$' } } } + - assert: { hidden: { text: { matches: '^27\. ' } } } diff --git a/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx b/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx index a41758e053..e68307ca5b 100644 --- a/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx +++ b/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx @@ -1,6 +1,13 @@ import React, { useRef } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; import { + Pressable as ReactNativePressable, + StyleSheet, + Text, + View, +} from 'react-native'; +import { + GestureDetector, + GestureStateManager, LegacyPressable, PointerType, Pressable, @@ -8,6 +15,7 @@ import { ScrollView, TextInput, Touchable, + useLongPressGesture, } from 'react-native-gesture-handler'; import { COLORS, useIndexedLogger } from '../../../common'; @@ -50,6 +58,12 @@ function useTapComparison(log: Log) { export default function KeyboardActivationExample() { const log = useIndexedLogger(); const compareWithTap = useTapComparison(log); + const touchActivatedLongPress = useLongPressGesture({ + onTouchesDown: (event) => { + 'worklet'; + GestureStateManager.activate(event.handlerTag); + }, + }); return ( @@ -137,6 +151,15 @@ export default function KeyboardActivationExample() { style={styles.disabledButton} {...pressHandlers(log, '[disabled pressable]')} /> + + {/* A gesture its own touch activates takes that touch from React Native's Pressable, the first touch after mount included. */} + + + ); } @@ -215,4 +238,10 @@ const styles = StyleSheet.create({ borderRadius: 8, backgroundColor: COLORS.GRAY, }, + reactNativeButton: { + width: 200, + height: 32, + borderRadius: 8, + backgroundColor: COLORS.NAVY, + }, }); diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index f05bc34d9a..a5bbbf0e47 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -267,8 +267,8 @@ class GestureHandlerOrchestrator( return } - // An activation reporting no pointer, a key's or an accessibility service's, shares no touch with the JS responder. - if (handler.cancelsJSResponder && handler.numberOfPointers > 0) { + // An activation whose handler tracks no pointer, a key's or an accessibility service's, shares no touch with the JS responder. + if (handler.cancelsJSResponder && handler.hasTrackedPointers) { onJSResponderCancelListener?.onCancelJSResponderRequested(handler) } @@ -544,7 +544,8 @@ class GestureHandlerOrchestrator( handler.activationIndex = Int.MAX_VALUE handler.prepare(view, this) - if (!handler.shouldBeginWithRecordedHandlers(gestureHandlers)) { + // The recorded handlers compete for the touch being dispatched; outside one, none competes with this handler. + if (isHandlingTouch && !handler.shouldBeginWithRecordedHandlers(gestureHandlers)) { handler.cancel() } @@ -947,7 +948,9 @@ class GestureHandlerOrchestrator( // be turned on and also confirm with the ViewConfigHelper implementation private fun isClipping(view: View) = view !is ViewGroup || viewConfigHelper.isViewClippingChildren(view) - fun activateNativeHandlersForView(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER) { + /** Activates the view's native handlers and answers whether one of them took the activation. */ + fun activateNativeHandlersForView(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER): Boolean { + var activated = false handlerRegistry.getHandlersForView(view)?.forEach { if (it !is NativeViewGestureHandler || !it.isEnabled) { return@forEach @@ -960,10 +963,14 @@ class GestureHandlerOrchestrator( it.withMarkedAsInBounds { it.begin() + if (it.state == GestureHandler.STATE_BEGAN || it.state == GestureHandler.STATE_ACTIVE) { + activated = true + } it.activate() it.end() } } + return activated } companion object { diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 5d36575ce4..95a08fd761 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -1607,8 +1607,8 @@ class RNGestureHandlerButtonViewManager : val source = clickWithoutPointer if (source != null) { - activateNativeHandlers(source.pointerType) - return super.performClick() + // View sounds the click and reports it to accessibility services, so it does only for a press a handler took. + return activateNativeHandlers(source.pointerType) && super.performClick() } return if (soundResponder === this) { @@ -1620,9 +1620,8 @@ class RNGestureHandlerButtonViewManager : } } - private fun activateNativeHandlers(pointerType: Int) { - RNGestureHandlerRootView.findGestureHandlerRootView(this)?.activateNativeHandlers(this, pointerType) - } + private fun activateNativeHandlers(pointerType: Int): Boolean = + RNGestureHandlerRootView.findGestureHandlerRootView(this)?.activateNativeHandlers(this, pointerType) ?: false override fun setEnabled(enabled: Boolean) { val changed = enabled != isEnabled diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt index 00a1a08568..d9844c81fc 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt @@ -195,9 +195,8 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: return shouldIntercept } - fun activateNativeHandlers(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER) { - orchestrator?.activateNativeHandlersForView(view, pointerType) - } + fun activateNativeHandlers(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER): Boolean = + orchestrator?.activateNativeHandlersForView(view, pointerType) ?: false fun recordHandlerIfNotPresent(handler: GestureHandler) { orchestrator?.recordHandlerIfNotPresent(handler, null) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt index e4aad9646a..2e059ed759 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt @@ -85,9 +85,8 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { super.requestDisallowInterceptTouchEvent(disallowIntercept) } - fun activateNativeHandlers(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER) { - rootHelper?.activateNativeHandlers(view, pointerType) - } + fun activateNativeHandlers(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER): Boolean = + rootHelper?.activateNativeHandlers(view, pointerType) ?: false fun onNativeTouchCancelled() { rootHelper?.onNativeTouchCancelled() diff --git a/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts b/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts index 04bc0439b0..3e81eab4e6 100644 --- a/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts +++ b/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts @@ -68,10 +68,10 @@ export type NativeViewGestureHandlerPayload = { */ pointerInside: boolean; - /** X of the pointer, or of the view's center when none drives the gesture, relative to the view. */ + /** X of the pointer relative to the view; with none, the view's center on Android and web and 0 on iOS and tvOS. */ x: number; - /** Y of the pointer, or of the view's center when none drives the gesture, relative to the view. */ + /** Y of the pointer relative to the view; with none, the view's center on Android and web and 0 on iOS and tvOS. */ y: number; /** X of the same point relative to the window. */ From 1424978c543790e5f28a00e42f89176ed89923be Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 20:03:08 +0300 Subject: [PATCH 03/11] [Android] Leave a pointer's gesture to its pointer, and press past the hit slop with none A key or an accessibility service's click on a button a finger already held ended the finger's gesture: both state-machine Pressables saw its touches cancelled and pressed nothing, and the button kept the touch responder, so every other exclusive button refused touches until it was touched again. A native handler a pointer drives is now left to that pointer, whose release presses, and the activation walks a snapshot of the view's handlers taken under the registry's lock. #4127's check runs where a pointer's touch records a handler, rather than wherever a handler is recorded during touch dispatch. A handler takes its pointer type from the pointer that begins its gesture, so one a second finger begins reports that finger's type rather than the last gesture's, a key's included. A key's press beside another button's touch moves the ripple to the view's center, and LegacyPressable and the default Pressable press for a press no pointer drives whatever their layout or hit slop, as StatefulPressable does. The native gesture's docs list its position fields with what each platform reports with no pointer, and cancelsJSResponder's docs name the activations that take no touch. --- .../gestures/_shared/base-gesture-config.mdx | 2 +- .../docs/gestures/use-native-gesture.mdx | 32 ++++++ .../gesturehandler/core/GestureHandler.kt | 5 +- .../core/GestureHandlerOrchestrator.kt | 80 ++++++++------ .../RNGestureHandlerButtonViewManager.kt | 8 +- .../pressableClickWithoutTouch.test.tsx | 102 +++++++++++++++++- .../src/components/Pressable/Pressable.tsx | 6 +- .../handlers/GestureHandlerEventPayload.ts | 24 ++++- .../v3/components/PressableWithTouchable.tsx | 15 +-- .../src/v3/components/StatefulPressable.tsx | 2 +- 10 files changed, 226 insertions(+), 50 deletions(-) diff --git a/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx b/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx index f6e7518787..b0a3ac0aa6 100644 --- a/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx +++ b/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx @@ -157,4 +157,4 @@ cancelsJSResponder?: boolean; Controls whether activating a Gesture Handler recognizer should cancel React Native JS responders. -Setting this property to `true` ensures that the gesture handler cancels any active React Native JS responders in the same root view upon activation. When set to `false`, both Gesture Handler and React Native responder callbacks can run simultaneously. The default value is set to `true`. +Setting this property to `true` ensures that the gesture handler cancels any active React Native JS responders in the same root view upon activation. On Android, an activation whose handler tracks no pointer, such as a key's press or an accessibility service's click, takes no touch from React Native and cancels none. When set to `false`, both Gesture Handler and React Native responder callbacks can run simultaneously. The default value is set to `true`. diff --git a/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx b/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx index 64e8bfd25d..b84a853238 100644 --- a/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx +++ b/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx @@ -151,4 +151,36 @@ pointerInside: boolean; `true` if the gesture was performed inside the containing view, `false` otherwise. +### x + +```ts +x: number; +``` + +X coordinate, expressed in points, of the pointer relative to the view attached to the [`GestureDetector`](/docs/core-components/gesture-detectors#gesture-detector). A key's press on Android and web, and an accessibility service's click on Android, report the view's center; iOS and tvOS report `0` for a press without a touch. + +### y + +```ts +y: number; +``` + +Y coordinate, expressed in points, of the pointer relative to the view attached to the [`GestureDetector`](/docs/core-components/gesture-detectors#gesture-detector). A key's press on Android and web, and an accessibility service's click on Android, report the view's center; iOS and tvOS report `0` for a press without a touch. + +### absoluteX + +```ts +absoluteX: number; +``` + +X coordinate, expressed in points, of the same point relative to the window. + +### absoluteY + +```ts +absoluteY: number; +``` + +Y coordinate, expressed in points, of the same point relative to the window. + diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index b7d6a5cd2d..14522beb36 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -424,11 +424,12 @@ open class GestureHandler { lastEventOffsetX = adaptedTransformedEvent.rawX - adaptedTransformedEvent.x lastEventOffsetY = adaptedTransformedEvent.rawY - adaptedTransformedEvent.y - if (sourceEvent.action == MotionEvent.ACTION_DOWN || + // The pointer beginning the gesture names its type; adapted, its event is `ACTION_DOWN`, a second finger's too. + if (adaptedSourceEvent.actionMasked == MotionEvent.ACTION_DOWN || sourceEvent.action == MotionEvent.ACTION_HOVER_ENTER || sourceEvent.action == MotionEvent.ACTION_HOVER_MOVE ) { - setPointerType(sourceEvent) + setPointerType(adaptedSourceEvent) } if (sourceEvent.isHoverAction()) { diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index a5bbbf0e47..303e305ef3 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -539,17 +539,30 @@ class GestureHandlerOrchestrator( return } + prepareForRecording(handler, view) + gestureHandlers.add(handler) + } + + private fun recordHandlerForPointer(handler: GestureHandler, view: View, pointerId: Int) { + if (!gestureHandlers.contains(handler)) { + prepareForRecording(handler, view) + + // The handlers already recorded compete with this one for the pointer's touch. + if (!handler.shouldBeginWithRecordedHandlers(gestureHandlers)) { + handler.cancel() + } + + gestureHandlers.add(handler) + } + + handler.startTrackingPointer(pointerId) + } + + private fun prepareForRecording(handler: GestureHandler, view: View?) { handler.isActive = false handler.isAwaiting = false handler.activationIndex = Int.MAX_VALUE handler.prepare(view, this) - - // The recorded handlers compete for the touch being dispatched; outside one, none competes with this handler. - if (isHandlingTouch && !handler.shouldBeginWithRecordedHandlers(gestureHandlers)) { - handler.cancel() - } - - gestureHandlers.add(handler) } private fun isViewOverflowingParent(view: View): Boolean { @@ -591,8 +604,7 @@ class GestureHandlerOrchestrator( if (handler.isEnabled && handler.isWithinBounds(view, coords[0], coords[1])) { found = true - recordHandlerIfNotPresent(handler, parentViewGroup) - handler.startTrackingPointer(pointerId) + recordHandlerForPointer(handler, parentViewGroup, pointerId) } } } @@ -656,8 +668,7 @@ class GestureHandlerOrchestrator( continue } - recordHandlerIfNotPresent(handler, view) - handler.startTrackingPointer(pointerId) + recordHandlerForPointer(handler, view, pointerId) found = true } } @@ -693,8 +704,7 @@ class GestureHandlerOrchestrator( continue } - recordHandlerIfNotPresent(handler, view) - handler.startTrackingPointer(pointerId) + recordHandlerForPointer(handler, view, pointerId) found = true } } @@ -948,29 +958,39 @@ class GestureHandlerOrchestrator( // be turned on and also confirm with the ViewConfigHelper implementation private fun isClipping(view: View) = view !is ViewGroup || viewConfigHelper.isViewClippingChildren(view) - /** Activates the view's native handlers and answers whether one of them took the activation. */ + /** Activates the view's native handlers no pointer drives and answers whether one of them took the activation. */ fun activateNativeHandlersForView(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER): Boolean { - var activated = false - handlerRegistry.getHandlersForView(view)?.forEach { - if (it !is NativeViewGestureHandler || !it.isEnabled) { - return@forEach - } - this.recordHandlerIfNotPresent(it, view) + val registeredHandlers = handlerRegistry.getHandlersForView(view) ?: return false + val handlers = obtainHandlerList() + // Other threads attach and drop handlers under the list's lock; the activation's callbacks run outside it. + synchronized(registeredHandlers) { + handlers.addAll(registeredHandlers) + } - if (!it.hasTrackedPointers) { - it.prepareActivationWithoutPointer(pointerType) - } + try { + var activated = false + for (handler in handlers) { + // A pointer's gesture presses on its release, which also frees the button's touch responder. + if (handler !is NativeViewGestureHandler || !handler.isEnabled || handler.hasTrackedPointers) { + continue + } - it.withMarkedAsInBounds { - it.begin() - if (it.state == GestureHandler.STATE_BEGAN || it.state == GestureHandler.STATE_ACTIVE) { - activated = true + recordHandlerIfNotPresent(handler, view) + handler.prepareActivationWithoutPointer(pointerType) + + handler.withMarkedAsInBounds { + handler.begin() + if (handler.state == GestureHandler.STATE_BEGAN || handler.state == GestureHandler.STATE_ACTIVE) { + activated = true + } + handler.activate() + handler.end() } - it.activate() - it.end() } + return activated + } finally { + recycleHandlerList(handlers) } - return activated } companion object { diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 95a08fd761..9c66a225bd 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -1503,7 +1503,7 @@ class RNGestureHandlerButtonViewManager : } override fun drawableHotspotChanged(x: Float, y: Float) { - if (touchResponder == null || touchResponder === this) { + if (touchResponder == null || touchResponder === this || canBePressedWithoutPointer) { super.drawableHotspotChanged(x, y) // Forward hotspot to the ripple when it's drawn manually. selectableDrawable?.setHotspot(x, y) @@ -1651,12 +1651,14 @@ class RNGestureHandlerButtonViewManager : } } + // `performClick` refuses while a child is pressed, so the press that click would end is refused too. + private val canBePressedWithoutPointer: Boolean + get() = clickWithoutPointer != null && !isChildTouched() + override fun setPressed(pressed: Boolean) { // button can be pressed alongside other button if both are non-exclusive and it doesn't have // any pressed children (to prevent pressing the parent when children is pressed). val canBePressedAlongsideOther = !exclusive && touchResponder?.exclusive != true && !isChildTouched() - // `performClick` refuses while a child is pressed, so the press that click would end is refused too. - val canBePressedWithoutPointer = clickWithoutPointer != null && !isChildTouched() if (!pressed || touchResponder === this || canBePressedAlongsideOther || canBePressedWithoutPointer) { // we set pressed state only for current responder or any non-exclusive button when responder diff --git a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx index 6d63c2061d..399daf391c 100644 --- a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx @@ -152,6 +152,9 @@ afterEach(() => { const PRESS_RETENTION_OFFSET = 20; const RETENTION_ZONE_TOUCH = { x: 130, y: 22 }; +// A hit slop that takes the button's left half, center included, out of what a pointer can press. +const CENTER_EXCLUDING_HIT_SLOP = { left: -(BUTTON_CENTER_TOUCH.x + 10) }; + type PressHandler = jest.Mock; type PressHandlers = { @@ -160,7 +163,8 @@ type PressHandlers = { onPressOut: PressHandler; }; -type EngineProps = PressHandlers & Pick; +type EngineProps = PressHandlers & + Pick; type PressableContainer = (pressable: React.ReactElement) => React.ReactElement; @@ -499,6 +503,102 @@ describe.each([ expect(handlers.onPressIn).not.toHaveBeenCalled(); expect(handlers.onPress).not.toHaveBeenCalled(); }); + + test('runs onPress once for a click without a touch before its layout is measured', async () => { + const handlers = createPressHandlers(); + await driver.render(handlers); + + driver.fireNativeGesture(CLICK_WITHOUT_TOUCH); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('runs onPress once for a click without a touch whose hit slop leaves out its center', async () => { + const handlers = createPressHandlers(); + await driver.render({ ...handlers, hitSlop: CENTER_EXCLUDING_HIT_SLOP }); + layOutButton(); + + driver.fireNativeGesture(CLICK_WITHOUT_TOUCH); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); +}); + +// The default `Pressable` presses from the press events its native button sends. +const buttonEvent = (numberOfPointers: number, pointerType: PointerType) => ({ + nativeEvent: { + pointerInside: true, + ...POSITION_WITHOUT_POINTER, + numberOfPointers, + pointerType, + }, +}); + +const pressButton = (event: ReturnType) => { + const button = screen.getByTestId('pressable'); + for (const eventName of [ + 'buttonPressIn', + 'buttonPressOut', + 'buttonPress', + 'buttonInteractionFinished', + ]) { + fireEvent(button, eventName, event); + } +}; + +const renderDefaultPressable = (props: EngineProps) => + renderSettled( + + + Press Me + + + ); + +describe('Pressable on Android', () => { + test('runs onPress once for a click without a touch before its layout is measured', async () => { + const handlers = createPressHandlers(); + await renderDefaultPressable(handlers); + + pressButton(buttonEvent(0, PointerType.KEY)); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('runs onPress once for a click without a touch whose hit slop leaves out its center', async () => { + const handlers = createPressHandlers(); + await renderDefaultPressable({ + ...handlers, + hitSlop: CENTER_EXCLUDING_HIT_SLOP, + }); + layOutButton(); + + pressButton(buttonEvent(0, PointerType.KEY)); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('runs nothing for a touch at its center when its hit slop leaves the center out', async () => { + const handlers = createPressHandlers(); + await renderDefaultPressable({ + ...handlers, + hitSlop: CENTER_EXCLUDING_HIT_SLOP, + }); + layOutButton(); + + pressButton(buttonEvent(1, PointerType.TOUCH)); + + expect(handlers.onPressIn).not.toHaveBeenCalled(); + expect(handlers.onPress).not.toHaveBeenCalled(); + }); }); describe('StatefulPressable on a TV', () => { diff --git a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx index 20d2f55a13..2430966da3 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx @@ -155,8 +155,9 @@ const LegacyPressable = (props: LegacyPressableProps) => { }, [cancelDelayedPress, cancelLongPress]); const handlePressIn = useCallback( - (event: PressableEvent) => { + (event: PressableEvent, skipBoundsCheck = false) => { if ( + !skipBoundsCheck && !isTouchWithinInset( dimensions.current, normalizedHitSlop, @@ -306,7 +307,8 @@ const LegacyPressable = (props: LegacyPressableProps) => { .onBegin((event) => { if (isActivatedWithoutPointer(event)) { // The press state machine is touch-based and never receives LONG_PRESS_TOUCHES_DOWN here. - handlePressIn(gestureToPressableEvent(event)); + // The hit-slop bounds check tests where a pointer went down, and a press with no pointer has none. + handlePressIn(gestureToPressableEvent(event), true); return; } if (Platform.OS === 'android' && isScreenReaderEnabled) { diff --git a/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts b/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts index 3e81eab4e6..8cdf10eb0a 100644 --- a/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts +++ b/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts @@ -68,16 +68,32 @@ export type NativeViewGestureHandlerPayload = { */ pointerInside: boolean; - /** X of the pointer relative to the view; with none, the view's center on Android and web and 0 on iOS and tvOS. */ + /** + * X coordinate, expressed in points, of the pointer relative to the view + * attached to the handler. A key's press on Android and web, and an + * accessibility service's click on Android, report the view's center; iOS and + * tvOS report 0 for a press without a touch. + */ x: number; - /** Y of the pointer relative to the view; with none, the view's center on Android and web and 0 on iOS and tvOS. */ + /** + * Y coordinate, expressed in points, of the pointer relative to the view + * attached to the handler. A key's press on Android and web, and an + * accessibility service's click on Android, report the view's center; iOS and + * tvOS report 0 for a press without a touch. + */ y: number; - /** X of the same point relative to the window. */ + /** + * X coordinate, expressed in points, of the same point relative to the + * window. + */ absoluteX: number; - /** Y of the same point relative to the window. */ + /** + * Y coordinate, expressed in points, of the same point relative to the + * window. + */ absoluteY: number; }; diff --git a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx index 2109c14fc8..cfa4a24367 100644 --- a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx @@ -9,6 +9,7 @@ import type { } from '../../components/Pressable/PressableProps'; import { addInsets, + isActivatedWithoutPointer, isTouchWithinInset, numberAsInset, } from '../../components/Pressable/utils'; @@ -169,12 +170,14 @@ const PressableWithTouchable = (props: PressableProps) => { }; const handlePressIn = (event: ButtonEvent) => { - // A down in the retention-only zone is held by the button but isn't a press. - isActive.current = isTouchWithinInset( - dimensions.current, - normalizedHitSlop, - buttonToInner(event) - ); + // A down in the retention-only zone is held by the button but isn't a press; a press with no pointer has no down. + isActive.current = + isActivatedWithoutPointer(event) || + isTouchWithinInset( + dimensions.current, + normalizedHitSlop, + buttonToInner(event) + ); if (!isActive.current) { return; diff --git a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx index e8b490a031..3f426a08cf 100644 --- a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx @@ -379,7 +379,7 @@ const StatefulPressable = (props: PressableProps) => { // tvOS drives this native gesture from the focus-engine Select press, and Android from a key // or an accessibility service with no pointer. The press state machine is touch-based and never // receives LONG_PRESS_TOUCHES_DOWN here, so bypass it and drive the press handlers directly. - // A focus-driven press has no coordinates, so skip the hit-slop bounds check entirely. + // The hit-slop bounds check tests where a pointer went down, and a press with no pointer has none. handlePressIn(toPressableEventWithoutPointer(event), true); return; } From f15bf4dac51d25e311532eef1a1c6f81b75a0313 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 00:29:32 +0300 Subject: [PATCH 04/11] [Android] Keep a key out of a pointer's press and a waiting gesture, and cancel the JS responder for every touch A confirm key on a button a finger held unpressed it, since `View.onKeyUp` unpresses a pressed view before it clicks: the ripple went out under the finger, and the finger's release neither clicked, sounded nor reported `TYPE_VIEW_CLICKED`. A press no pointer drives now shows only where its click would activate a native gesture, and its release unpresses the button only while no pointer presses it. A key on a button whose touch waited on another gesture's failure overwrote that touch's pointer count, type and position, which `makeActive` dispatched once the other gesture failed. The activation now leaves a handler in a gesture of its own to that gesture. `makeActive` skipped the JS-responder cancel for every handler tracking no pointer, so a touch handler activating after its pointers lifted left React Native's Pressable beside it to press too. The handler now records that an activation no pointer drives prepared it, and only that activation skips the cancel. The button reaches its root view's orchestrator directly, as `requestDisallowInterceptTouchEvent` does, so the root view's and the root helper's forwarding `activateNativeHandlers` go. The native gesture's docs note the pointer count and type of a press no pointer drives on Android and web. --- .../gestures/_shared/base-gesture-config.mdx | 2 +- .../docs/gestures/use-native-gesture.mdx | 4 ++ .../gesturehandler/core/GestureHandler.kt | 6 +++ .../core/GestureHandlerOrchestrator.kt | 43 ++++++++++++++++--- .../RNGestureHandlerButtonViewManager.kt | 35 +++++++++++---- .../react/RNGestureHandlerRootHelper.kt | 4 -- .../react/RNGestureHandlerRootView.kt | 4 -- 7 files changed, 75 insertions(+), 23 deletions(-) diff --git a/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx b/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx index b0a3ac0aa6..059cccdb33 100644 --- a/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx +++ b/packages/docs-gesture-handler/docs/gestures/_shared/base-gesture-config.mdx @@ -157,4 +157,4 @@ cancelsJSResponder?: boolean; Controls whether activating a Gesture Handler recognizer should cancel React Native JS responders. -Setting this property to `true` ensures that the gesture handler cancels any active React Native JS responders in the same root view upon activation. On Android, an activation whose handler tracks no pointer, such as a key's press or an accessibility service's click, takes no touch from React Native and cancels none. When set to `false`, both Gesture Handler and React Native responder callbacks can run simultaneously. The default value is set to `true`. +Setting this property to `true` ensures that the gesture handler cancels any active React Native JS responders in the same root view upon activation. On Android, an activation no pointer drives, such as a key's press or an accessibility service's click, takes no touch from React Native and cancels none. When set to `false`, both Gesture Handler and React Native responder callbacks can run simultaneously. The default value is set to `true`. diff --git a/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx b/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx index b84a853238..effe5cd419 100644 --- a/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx +++ b/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx @@ -184,3 +184,7 @@ absoluteY: number; Y coordinate, expressed in points, of the same point relative to the window. + +:::note +On Android, a hardware key's press and an accessibility service's click, which no pointer drives, activate the gesture with `numberOfPointers` set to `0`, and `pointerType` set to `PointerType.KEY` for the key and `PointerType.OTHER` for the service. On web, a key's press counts as one `PointerType.KEY` pointer. +::: diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 14522beb36..0460dd7a4a 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -123,6 +123,10 @@ open class GestureHandler { var pointerType: Int = POINTER_TYPE_OTHER private set + // Whether the current gesture is an activation no pointer drives, a confirm key's or an accessibility service's. + var isActivatedWithoutPointer = false + private set + protected var mouseButton = 0 // properties set and accessed only by the orchestrator @@ -203,6 +207,7 @@ open class GestureHandler { Arrays.fill(trackedPointerIDs, -1) trackedPointersIDsCount = 0 state = STATE_UNDETERMINED + isActivatedWithoutPointer = false this.view = view this.orchestrator = orchestrator @@ -885,6 +890,7 @@ open class GestureHandler { // An activation no pointer drives, a confirm key's or an accessibility service's click, reports no pointer // (https://www.w3.org/TR/pointerevents3/#event-attributes) and happens at the view's visible center. fun prepareActivationWithoutPointer(pointerType: Int) { + isActivatedWithoutPointer = true numberOfPointers = 0 this.pointerType = pointerType val centerView = coordinateView ?: return diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 303e305ef3..88f8126df4 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -267,8 +267,8 @@ class GestureHandlerOrchestrator( return } - // An activation whose handler tracks no pointer, a key's or an accessibility service's, shares no touch with the JS responder. - if (handler.cancelsJSResponder && handler.hasTrackedPointers) { + // An activation no pointer drives, a key's or an accessibility service's, shares no touch with the JS responder. + if (handler.cancelsJSResponder && !handler.isActivatedWithoutPointer) { onJSResponderCancelListener?.onCancelJSResponderRequested(handler) } @@ -958,7 +958,27 @@ class GestureHandlerOrchestrator( // be turned on and also confirm with the ViewConfigHelper implementation private fun isClipping(view: View) = view !is ViewGroup || viewConfigHelper.isViewClippingChildren(view) - /** Activates the view's native handlers no pointer drives and answers whether one of them took the activation. */ + /** Whether a pointer presses the view through one of its native handlers. */ + fun isViewPressedByPointer(view: View): Boolean { + val registeredHandlers = handlerRegistry.getHandlersForView(view) ?: return false + return synchronized(registeredHandlers) { + registeredHandlers.any { isNativeHandlerTrackingPointer(it) } + } + } + + /** Whether a press no pointer drives can activate the view's native handlers, so the view may show it as pressed. */ + fun canActivateNativeHandlersWithoutPointer(view: View): Boolean { + val registeredHandlers = handlerRegistry.getHandlersForView(view) ?: return false + return synchronized(registeredHandlers) { + registeredHandlers.none { isNativeHandlerTrackingPointer(it) } && + registeredHandlers.any { canBeginWithoutPointer(it) } + } + } + + /** + * Activates the view's native handlers a press no pointer drives can begin, and answers whether one of them took + * the press. + */ fun activateNativeHandlersForView(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER): Boolean { val registeredHandlers = handlerRegistry.getHandlersForView(view) ?: return false val handlers = obtainHandlerList() @@ -968,10 +988,13 @@ class GestureHandlerOrchestrator( } try { + if (handlers.any { isNativeHandlerTrackingPointer(it) }) { + return false + } + var activated = false for (handler in handlers) { - // A pointer's gesture presses on its release, which also frees the button's touch responder. - if (handler !is NativeViewGestureHandler || !handler.isEnabled || handler.hasTrackedPointers) { + if (!canBeginWithoutPointer(handler)) { continue } @@ -993,6 +1016,16 @@ class GestureHandlerOrchestrator( } } + // A pointer's gesture presses on its release, which also frees the button's touch responder. + private fun isNativeHandlerTrackingPointer(handler: GestureHandler) = + handler is NativeViewGestureHandler && handler.hasTrackedPointers + + // A handler in a gesture of its own, one waiting on another gesture with its pointers lifted included, keeps that + // gesture's report, which `makeActive` dispatches once it may activate. + private fun canBeginWithoutPointer(handler: GestureHandler) = handler is NativeViewGestureHandler && + handler.isEnabled && + (handler.state == GestureHandler.STATE_UNDETERMINED || !gestureHandlers.contains(handler)) + companion object { // The limit doesn't necessarily need to exists, it was just simpler to implement it that way // it is also more allocation-wise efficient to have a fixed limit diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 9c66a225bd..1da5abdc24 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -933,8 +933,7 @@ class RNGestureHandlerButtonViewManager : val moduleId = moduleId ?: return val managedHandlerTag = managedHandlerTag ?: return - val orchestrator = - RNGestureHandlerRootView.findGestureHandlerRootView(this)?.orchestrator ?: return + val orchestrator = findOrchestrator() ?: return val handler = RNGestureHandlerModule.registries[moduleId]?.getHandler(managedHandlerTag) ?: return if (!orchestrator.isHandlingTouch && handler.view != null) { @@ -1503,7 +1502,13 @@ class RNGestureHandlerButtonViewManager : } override fun drawableHotspotChanged(x: Float, y: Float) { - if (touchResponder == null || touchResponder === this || canBePressedWithoutPointer) { + val canMoveHotspot = if (clickWithoutPointer != null) { + canBePressedWithoutPointer + } else { + touchResponder == null || touchResponder === this + } + + if (canMoveHotspot) { super.drawableHotspotChanged(x, y) // Forward hotspot to the ripple when it's drawn manually. selectableDrawable?.setHotspot(x, y) @@ -1607,8 +1612,9 @@ class RNGestureHandlerButtonViewManager : val source = clickWithoutPointer if (source != null) { + val orchestrator = findOrchestrator() ?: return false // View sounds the click and reports it to accessibility services, so it does only for a press a handler took. - return activateNativeHandlers(source.pointerType) && super.performClick() + return orchestrator.activateNativeHandlersForView(this, source.pointerType) && super.performClick() } return if (soundResponder === this) { @@ -1620,8 +1626,7 @@ class RNGestureHandlerButtonViewManager : } } - private fun activateNativeHandlers(pointerType: Int): Boolean = - RNGestureHandlerRootView.findGestureHandlerRootView(this)?.activateNativeHandlers(this, pointerType) ?: false + private fun findOrchestrator() = RNGestureHandlerRootView.findGestureHandlerRootView(this)?.orchestrator override fun setEnabled(enabled: Boolean) { val changed = enabled != isEnabled @@ -1651,16 +1656,28 @@ class RNGestureHandlerButtonViewManager : } } - // `performClick` refuses while a child is pressed, so the press that click would end is refused too. + // A press no pointer drives shows only where its click would activate the button: `performClick` refuses while a + // child is pressed, and a pointer pressing the button keeps its press, whose release clicks. private val canBePressedWithoutPointer: Boolean - get() = clickWithoutPointer != null && !isChildTouched() + get() = !isChildTouched() && findOrchestrator()?.canActivateNativeHandlersWithoutPointer(this) == true + + // `View.onKeyUp` unpresses a pressed view before it clicks, which would end a pointer's press. + private val canBeUnpressedWithoutPointer: Boolean + get() = findOrchestrator()?.isViewPressedByPointer(this) != true override fun setPressed(pressed: Boolean) { + if (clickWithoutPointer != null) { + val canChangeWithoutPointer = if (pressed) canBePressedWithoutPointer else canBeUnpressedWithoutPointer + if (!canChangeWithoutPointer) { + return + } + } + // button can be pressed alongside other button if both are non-exclusive and it doesn't have // any pressed children (to prevent pressing the parent when children is pressed). val canBePressedAlongsideOther = !exclusive && touchResponder?.exclusive != true && !isChildTouched() - if (!pressed || touchResponder === this || canBePressedAlongsideOther || canBePressedWithoutPointer) { + if (!pressed || touchResponder === this || canBePressedAlongsideOther || clickWithoutPointer != null) { // we set pressed state only for current responder or any non-exclusive button when responder // is null or non-exclusive, assuming it doesn't have pressed children, and for a press no // pointer drives, which competes with no touch diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt index d9844c81fc..0fcb79f55e 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt @@ -3,7 +3,6 @@ package com.swmansion.gesturehandler.react import android.os.SystemClock import android.util.Log import android.view.MotionEvent -import android.view.View import android.view.ViewGroup import android.view.ViewParent import com.facebook.react.bridge.ReactContext @@ -195,9 +194,6 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: return shouldIntercept } - fun activateNativeHandlers(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER): Boolean = - orchestrator?.activateNativeHandlersForView(view, pointerType) ?: false - fun recordHandlerIfNotPresent(handler: GestureHandler) { orchestrator?.recordHandlerIfNotPresent(handler, null) } diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt index 2e059ed759..4136eae0fa 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt @@ -3,7 +3,6 @@ package com.swmansion.gesturehandler.react import android.content.Context import android.util.Log import android.view.MotionEvent -import android.view.View import android.view.ViewGroup import android.view.ViewParent import com.facebook.react.bridge.ReactContext @@ -85,9 +84,6 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { super.requestDisallowInterceptTouchEvent(disallowIntercept) } - fun activateNativeHandlers(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER): Boolean = - rootHelper?.activateNativeHandlers(view, pointerType) ?: false - fun onNativeTouchCancelled() { rootHelper?.onNativeTouchCancelled() } From 78c4183dab3c76afccc4cd068b61e5b3b19d4d5e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 01:27:11 +0300 Subject: [PATCH 05/11] [Android] Report a waiting key press inside its view, and click only for a press its handler kept A key's or an accessibility service's activation that waited on another gesture dispatched its delayed ACTIVE and END with `pointerInside` false, since the activation marked the handler in bounds only while it ran, so a legacy BaseButton waiting on such a gesture never pressed. The preparation now marks the pointer inside the view for the whole gesture, and `withMarkedAsInBounds`, which nothing else called, goes. The click sounded and reported `TYPE_VIEW_CLICKED` once the handler began, even when a gesture it waits on cancelled it as it activated. A press now counts as taken when its handler ends, waiting on another gesture or not. The press the button shows and the activation its click runs read one selection of the view's native handlers. --- .../gesturehandler/core/GestureHandler.kt | 9 +-- .../core/GestureHandlerOrchestrator.kt | 67 ++++++++++--------- 2 files changed, 37 insertions(+), 39 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 0460dd7a4a..fd312de5e9 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -881,16 +881,11 @@ open class GestureHandler { onReset() } - fun withMarkedAsInBounds(closure: () -> Unit) { - isWithinBounds = true - closure() - isWithinBounds = false - } - // An activation no pointer drives, a confirm key's or an accessibility service's click, reports no pointer - // (https://www.w3.org/TR/pointerevents3/#event-attributes) and happens at the view's visible center. + // (https://www.w3.org/TR/pointerevents3/#event-attributes) and happens inside the view, at its visible center. fun prepareActivationWithoutPointer(pointerType: Int) { isActivatedWithoutPointer = true + isWithinBounds = true numberOfPointers = 0 this.pointerType = pointerType val centerView = coordinateView ?: return diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 88f8126df4..08f97ebd00 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -967,50 +967,53 @@ class GestureHandlerOrchestrator( } /** Whether a press no pointer drives can activate the view's native handlers, so the view may show it as pressed. */ - fun canActivateNativeHandlersWithoutPointer(view: View): Boolean { - val registeredHandlers = handlerRegistry.getHandlersForView(view) ?: return false - return synchronized(registeredHandlers) { - registeredHandlers.none { isNativeHandlerTrackingPointer(it) } && - registeredHandlers.any { canBeginWithoutPointer(it) } - } - } + fun canActivateNativeHandlersWithoutPointer(view: View): Boolean = + withHandlersReadyForActivationWithoutPointer(view) { handlers -> handlers.isNotEmpty() } /** * Activates the view's native handlers a press no pointer drives can begin, and answers whether one of them took * the press. */ - fun activateNativeHandlersForView(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER): Boolean { - val registeredHandlers = handlerRegistry.getHandlersForView(view) ?: return false - val handlers = obtainHandlerList() - // Other threads attach and drop handlers under the list's lock; the activation's callbacks run outside it. - synchronized(registeredHandlers) { - handlers.addAll(registeredHandlers) - } - - try { - if (handlers.any { isNativeHandlerTrackingPointer(it) }) { - return false - } - + fun activateNativeHandlersForView(view: View, pointerType: Int = GestureHandler.POINTER_TYPE_OTHER): Boolean = + withHandlersReadyForActivationWithoutPointer(view) { handlers -> var activated = false for (handler in handlers) { - if (!canBeginWithoutPointer(handler)) { - continue - } - recordHandlerIfNotPresent(handler, view) handler.prepareActivationWithoutPointer(pointerType) + handler.begin() + handler.activate() + handler.end() - handler.withMarkedAsInBounds { - handler.begin() - if (handler.state == GestureHandler.STATE_BEGAN || handler.state == GestureHandler.STATE_ACTIVE) { - activated = true - } - handler.activate() - handler.end() + // A handler waiting on another gesture has ended too; one a gesture it waits on cancelled took nothing. + if (handler.state == GestureHandler.STATE_END) { + activated = true + } + } + activated + } + + // Hands the block the view's native handlers a press no pointer drives can begin, or none while a pointer presses + // the view through one of them. Other threads attach and drop handlers under the registry list's lock, so the block + // gets a snapshot taken under it and runs, with every callback it starts, outside it. + private inline fun withHandlersReadyForActivationWithoutPointer( + view: View, + block: (handlers: List) -> T, + ): T { + val handlers = obtainHandlerList() + try { + handlerRegistry.getHandlersForView(view)?.let { registeredHandlers -> + synchronized(registeredHandlers) { + handlers.addAll(registeredHandlers) } } - return activated + + if (handlers.any { isNativeHandlerTrackingPointer(it) }) { + handlers.clear() + } else { + handlers.retainAll { canBeginWithoutPointer(it) } + } + + return block(handlers) } finally { recycleHandlerList(handlers) } From 19879ef0c25ed550821782b31326a201fac1fdb5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 13:21:28 +0300 Subject: [PATCH 06/11] [General] Press every engine from a TV's Select press, and report a touchless press at the view's center on Apple On tvOS a focus Select press reaches the native gesture with no touch. Apple's native view handler reported it at (0, 0) as a TOUCH pointer, so the default Pressable dropped it at the hit-slop check and LegacyPressable waited for a touch that never came; only StatefulPressable's TV branch pressed, at the layout center. isActivatedWithoutPointer now takes every press on a TV, as that branch did, so all three engines go around the state machine and the hit-slop check for it and press at the position the native gesture reports. Apple reports a press with no touch at the view's center, as handleSwitch: reports a switch's, and as KEY, as Android reports a key's press. The docs and the payload type say a press no pointer drives reports the view's center on every platform. A click test case also pairs a screen reader with a hit slop that leaves out the center, which fails if either state-machine engine lets the screen reader's branch take a click with no pointer first. --- .../docs/gestures/use-native-gesture.mdx | 6 +- .../apple/Handlers/RNNativeViewHandler.mm | 14 +++- .../pressableClickWithoutTouch.test.tsx | 79 ++++++++++++++----- .../src/components/Pressable/utils.ts | 4 +- .../handlers/GestureHandlerEventPayload.ts | 12 +-- .../src/v3/components/StatefulPressable.tsx | 17 ++-- 6 files changed, 87 insertions(+), 45 deletions(-) diff --git a/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx b/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx index effe5cd419..c288f911a2 100644 --- a/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx +++ b/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx @@ -157,7 +157,7 @@ pointerInside: boolean; x: number; ``` -X coordinate, expressed in points, of the pointer relative to the view attached to the [`GestureDetector`](/docs/core-components/gesture-detectors#gesture-detector). A key's press on Android and web, and an accessibility service's click on Android, report the view's center; iOS and tvOS report `0` for a press without a touch. +X coordinate, expressed in points, of the pointer relative to the view attached to the [`GestureDetector`](/docs/core-components/gesture-detectors#gesture-detector). A press no pointer drives, such as a key's press, an accessibility service's click on Android or a TV's Select press, reports the view's center. ### y @@ -165,7 +165,7 @@ X coordinate, expressed in points, of the pointer relative to the view attached y: number; ``` -Y coordinate, expressed in points, of the pointer relative to the view attached to the [`GestureDetector`](/docs/core-components/gesture-detectors#gesture-detector). A key's press on Android and web, and an accessibility service's click on Android, report the view's center; iOS and tvOS report `0` for a press without a touch. +Y coordinate, expressed in points, of the pointer relative to the view attached to the [`GestureDetector`](/docs/core-components/gesture-detectors#gesture-detector). A press no pointer drives, such as a key's press, an accessibility service's click on Android or a TV's Select press, reports the view's center. ### absoluteX @@ -186,5 +186,5 @@ Y coordinate, expressed in points, of the same point relative to the window. :::note -On Android, a hardware key's press and an accessibility service's click, which no pointer drives, activate the gesture with `numberOfPointers` set to `0`, and `pointerType` set to `PointerType.KEY` for the key and `PointerType.OTHER` for the service. On web, a key's press counts as one `PointerType.KEY` pointer. +On Android, a hardware key's press and an accessibility service's click, which no pointer drives, activate the gesture with `numberOfPointers` set to `0`, and `pointerType` set to `PointerType.KEY` for the key and `PointerType.OTHER` for the service. On tvOS, the focus engine's Select press reports `numberOfPointers` set to `0` and `PointerType.KEY`. On web, a key's press counts as one `PointerType.KEY` pointer. ::: diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm b/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm index fba99d6428..25a8251441 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm +++ b/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm @@ -277,8 +277,10 @@ - (RNGestureHandlerEventExtraData *)extraDataForView:(UIView *)sender pointerInside:(BOOL)pointerInside { RNGHUITouch *touch = [[event allTouches] anyObject]; - CGPoint position = touch ? [touch locationInView:sender] : CGPointZero; - CGPoint absolutePosition = touch ? [touch locationInView:nil] : [sender convertPoint:CGPointZero toView:nil]; + // A press no touch drives, the focus engine's Select on a TV, happens at the view's center, as a switch's does. + CGPoint position = + touch ? [touch locationInView:sender] : CGPointMake(CGRectGetMidX(sender.bounds), CGRectGetMidY(sender.bounds)); + CGPoint absolutePosition = touch ? [touch locationInView:nil] : [sender convertPoint:position toView:nil]; return [RNGestureHandlerEventExtraData forPointerInside:pointerInside withPosition:position @@ -317,7 +319,13 @@ - (void)handleSwitch:(UIView *)sender - (void)handleTouchDown:(UIView *)sender forEvent:(UIEvent *)event { - [self setCurrentPointerTypeForEvent:event]; + if (event.allTouches.count == 0) { + // A press no touch drives reports no pointer, as a key does + // (https://www.w3.org/TR/pointerevents3/#event-attributes). + [self setCurrentPointerType:RNGestureHandlerKey]; + } else { + [self setCurrentPointerTypeForEvent:event]; + } [self reset]; if (_disallowInterruption) { diff --git a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx index 399daf391c..2dd688aeb3 100644 --- a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx @@ -121,15 +121,9 @@ const withoutPointer = const CLICK_WITHOUT_TOUCH = [BEGAN, ACTIVATED, ENDED].map( withoutPointer(PointerType.KEY) ); -// tvOS sends a focus Select press with no touch, at (0, 0) in the view and at the view's origin in the window. +// tvOS sends a focus Select press with no touch, as a key at the view's center. const TV_SELECT_PRESS = [BEGAN, ACTIVATED, ENDED].map( - (transition): NativeGestureEvent => ({ - ...reportingPointers(0, PointerType.TOUCH)(transition), - x: 0, - y: 0, - absoluteX: BUTTON_SCREEN_ORIGIN.x, - absoluteY: BUTTON_SCREEN_ORIGIN.y, - }) + withoutPointer(PointerType.KEY) ); const PRESSED_WHERE_NATIVE_GESTURE_REPORTS = { locationX: POSITION_WITHOUT_POINTER.x, @@ -526,6 +520,22 @@ describe.each([ expect(handlers.onPressOut).toHaveBeenCalledTimes(1); expect(handlers.onPress).toHaveBeenCalledTimes(1); }); + + // The screen reader's own branch presses at the layout center, which this hit slop leaves out. + test('runs onPress once for a screen reader click whose hit slop leaves out its center', async () => { + jest + .spyOn(AccessibilityInfo, 'isScreenReaderEnabled') + .mockResolvedValueOnce(true); + const handlers = createPressHandlers(); + await driver.render({ ...handlers, hitSlop: CENTER_EXCLUDING_HIT_SLOP }); + layOutButton(); + + driver.fireNativeGesture(ACCESSIBILITY_CLICK); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); }); // The default `Pressable` presses from the press events its native button sends. @@ -601,33 +611,64 @@ describe('Pressable on Android', () => { }); }); -describe('StatefulPressable on a TV', () => { +describe.each([ + ['StatefulPressable', statefulDriver], + ['LegacyPressable', legacyDriver], +] as const)('%s on a TV', (_name, driver) => { test('presses an Android TV key press at the position its native gesture reports', async () => { jest.replaceProperty(Platform, 'isTV', true); const handlers = createPressHandlers(); - await statefulDriver.render(handlers); + await driver.render(handlers); layOutButton(); - statefulDriver.fireNativeGesture(CLICK_WITHOUT_TOUCH); + driver.fireNativeGesture(CLICK_WITHOUT_TOUCH); expectPressedOnceAt(handlers, PRESSED_WHERE_NATIVE_GESTURE_REPORTS); }); - test('presses a tvOS focus press at the layout center, with no page position', async () => { + test('presses a tvOS focus press at the position its native gesture reports', async () => { jest.replaceProperty(Platform, 'OS', 'ios'); jest.replaceProperty(Platform, 'isTV', true); const handlers = createPressHandlers(); - await statefulDriver.render(handlers); + await driver.render(handlers); layOutButton(); - statefulDriver.fireNativeGesture(TV_SELECT_PRESS); + driver.fireNativeGesture(TV_SELECT_PRESS); + + expectPressedOnceAt(handlers, PRESSED_WHERE_NATIVE_GESTURE_REPORTS); + }); - expectPressedOnceAt(handlers, { - locationX: BUTTON_CENTER_TOUCH.x, - locationY: BUTTON_CENTER_TOUCH.y, - pageX: -1, - pageY: -1, + test('presses a tvOS focus press whose hit slop leaves out its center', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + jest.replaceProperty(Platform, 'isTV', true); + const handlers = createPressHandlers(); + await driver.render({ ...handlers, hitSlop: CENTER_EXCLUDING_HIT_SLOP }); + layOutButton(); + + driver.fireNativeGesture(TV_SELECT_PRESS); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); +}); + +describe('Pressable on a TV', () => { + test('presses a tvOS focus press whose hit slop leaves out its center', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + jest.replaceProperty(Platform, 'isTV', true); + const handlers = createPressHandlers(); + await renderDefaultPressable({ + ...handlers, + hitSlop: CENTER_EXCLUDING_HIT_SLOP, }); + layOutButton(); + + pressButton(buttonEvent(0, PointerType.KEY)); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts index 92bfcba6a9..01af947c14 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts +++ b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts @@ -184,9 +184,9 @@ const viewCenterToPressableEvent = ( }; // Android reports a key's or an accessibility service's activation of the native gesture with no pointer -// (https://www.w3.org/TR/pointerevents3/#event-attributes). +// (https://www.w3.org/TR/pointerevents3/#event-attributes), and a TV's focus engine drives every press. const isActivatedWithoutPointer = (event: { numberOfPointers: number }) => - Platform.OS === 'android' && event.numberOfPointers === 0; + Platform.isTV || (Platform.OS === 'android' && event.numberOfPointers === 0); export { addInsets, diff --git a/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts b/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts index 8cdf10eb0a..f404f660ee 100644 --- a/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts +++ b/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts @@ -70,17 +70,17 @@ export type NativeViewGestureHandlerPayload = { /** * X coordinate, expressed in points, of the pointer relative to the view - * attached to the handler. A key's press on Android and web, and an - * accessibility service's click on Android, report the view's center; iOS and - * tvOS report 0 for a press without a touch. + * attached to the handler. A press no pointer drives, such as a key's press, + * an accessibility service's click on Android or a TV's Select press, reports + * the view's center. */ x: number; /** * Y coordinate, expressed in points, of the pointer relative to the view - * attached to the handler. A key's press on Android and web, and an - * accessibility service's click on Android, report the view's center; iOS and - * tvOS report 0 for a press without a touch. + * attached to the handler. A press no pointer drives, such as a key's press, + * an accessibility service's click on Android or a TV's Select press, reports + * the view's center. */ y: number; diff --git a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx index 3f426a08cf..ac55aba13f 100644 --- a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx @@ -38,7 +38,6 @@ import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; import { useIsScreenReaderEnabled } from '../../useIsScreenReaderEnabled'; import { INT32_MAX, isTestEnv } from '../../utils'; import { GestureDetector } from '../detectors'; -import type { NativeGestureEvent } from '../hooks'; import { useHoverGesture, useLongPressGesture, @@ -230,12 +229,6 @@ const StatefulPressable = (props: PressableProps) => { [handleFinalize, innerHandlePressIn, onPress, onPressOut] ); - // Android, a TV's included, reports where a key or an accessibility service activated the view; tvOS reports none. - const toPressableEventWithoutPointer = (event: NativeGestureEvent) => - Platform.OS === 'android' - ? gestureToPressableEvent(event) - : viewCenterToPressableEvent(dimensions.current); - const stateMachine = useMemo(() => new PressableStateMachine(), []); const isScreenReaderEnabled = useIsScreenReaderEnabled(); @@ -375,12 +368,12 @@ const StatefulPressable = (props: PressableProps) => { return; } - if (Platform.isTV || isActivatedWithoutPointer(event)) { - // tvOS drives this native gesture from the focus-engine Select press, and Android from a key + if (isActivatedWithoutPointer(event)) { + // A TV drives this native gesture from the focus-engine Select press, and Android from a key // or an accessibility service with no pointer. The press state machine is touch-based and never // receives LONG_PRESS_TOUCHES_DOWN here, so bypass it and drive the press handlers directly. // The hit-slop bounds check tests where a pointer went down, and a press with no pointer has none. - handlePressIn(toPressableEventWithoutPointer(event), true); + handlePressIn(gestureToPressableEvent(event), true); return; } if (Platform.OS === 'android' && isScreenReaderEnabled) { @@ -404,8 +397,8 @@ const StatefulPressable = (props: PressableProps) => { return; } - if (Platform.isTV || isActivatedWithoutPointer(event)) { - handlePressOut(toPressableEventWithoutPointer(event), !event.canceled); + if (isActivatedWithoutPointer(event)) { + handlePressOut(gestureToPressableEvent(event), !event.canceled); handleFinalize(); return; } From d4c160968f6aa80212097fbf250ce4274c6926a8 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 14:40:59 +0300 Subject: [PATCH 07/11] [General] Keep an Android TV pointer press on the touch path, and report a touch's own pointers to an activation before it is handled isActivatedWithoutPointer took every press on a TV around the touch state machine and the hit-slop check, so on Android TV a mouse's press or a touch-capable box's touch beginning in the press retention zone, or at a center the hit slop leaves out, pressed in all three engines. On Android, Android TV included, a press now goes around them only when it reports no pointer; on tvOS, where nothing points at the screen, every press still does. The orchestrator dispatches a touch's down to the handler's touch callbacks before handle() reads it, so a callback that activates the handler from onTouchesDown on the UI thread dispatched the pointer count and type the handler held from before: 0 and OTHER for its first touch, 0 and KEY after a key's press. updatePointerData now sets both from the pointers the handler tracks before it dispatches the down. The Keyboard activation example gains a RawButton whose touch activates it that way, and the keyboard flow taps it after Enter. The native gesture docs say a key's or an accessibility service's activation reaches a Gesture Handler button's own native gesture only, and what a tvOS Select press on a button reports. --- .argent/flows/keyboard-activation-test.yaml | 44 ++++++++++--- .../tests/keyboardActivation/index.tsx | 24 +++++++ .../docs/gestures/use-native-gesture.mdx | 2 +- .../gesturehandler/core/GestureHandler.kt | 5 ++ .../pressableClickWithoutTouch.test.tsx | 64 +++++++++++++++++++ .../src/components/Pressable/utils.ts | 5 +- 6 files changed, 131 insertions(+), 13 deletions(-) diff --git a/.argent/flows/keyboard-activation-test.yaml b/.argent/flows/keyboard-activation-test.yaml index 53c6944408..63a4d3f2fa 100644 --- a/.argent/flows/keyboard-activation-test.yaml +++ b/.argent/flows/keyboard-activation-test.yaml @@ -1,6 +1,7 @@ # Android only: a hardware Enter presses each kind of focused gesture-handler -# control once, as a key with no pointer, where a tap would land, while a -# gesture its touch activates still takes that touch from React Native. Tab +# control once, as a key with no pointer, where a tap would land; a touch after +# it reports its own pointer; and a gesture its touch activates still takes that +# touch from React Native. Tab # moves focus in view order, so after the taps and the console clear the counts # below reach the inner default Pressable, then each control after it. steps: @@ -108,6 +109,11 @@ steps: - tool: keyboard args: { key: enter } - echo: "Tab to the scaled Touchable and press Enter" + - tool: keyboard + args: { key: tab } + - tool: keyboard + args: { key: enter } + - echo: "Tab to the RawButton its touch activates and press Enter" - tool: keyboard args: { key: tab } - tool: keyboard @@ -120,7 +126,25 @@ steps: - assert: { visible: { text: { matches: '^19\. \[rect button\] onBegin KEY with 0 pointers$' } } } - assert: { visible: { text: { matches: '^20\. \[rect button\] onPress$' } } } - assert: { visible: { text: { matches: '^21\. \[scaled\] key press lands on the tap$' } } } - - assert: { hidden: { text: { matches: '^22\. ' } } } + - assert: { visible: { text: { matches: '^22\. \[touch activated button\] onActivate KEY with 0 pointers$' } } } + - assert: { hidden: { text: { matches: '^23\. ' } } } + + - echo: "Clear console" + - tap: { id: "clear-console-button" } + - tap: { id: "close-console-button" } + - await: { hidden: { id: "close-console-button" } } + - await: { idle: true } + + - echo: "Tap the RawButton, whose touch activates it before the button reads the touch" + - tap: { id: "touch-activated-button" } + - await: { idle: true } + + - echo: "The activation reports the touch, not the key before it" + - tap: { id: "open-console-button" } + - await: { idle: true } + - await: { visible: { id: "close-console-button" } } + - assert: { visible: { text: { matches: '^23\. \[touch activated button\] onActivate TOUCH with 1 pointers$' } } } + - assert: { hidden: { text: { matches: '^24\. ' } } } - echo: "Clear console" - tap: { id: "clear-console-button" } @@ -136,9 +160,9 @@ steps: - tap: { id: "open-console-button" } - await: { idle: true } - await: { visible: { id: "close-console-button" } } - - assert: { visible: { text: { matches: '^22\. \[react native pressable\] onPressIn$' } } } - - assert: { visible: { text: { matches: '^23\. \[react native pressable\] onPressOut$' } } } - - assert: { hidden: { text: { matches: '^24\. ' } } } + - assert: { visible: { text: { matches: '^24\. \[react native pressable\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^25\. \[react native pressable\] onPressOut$' } } } + - assert: { hidden: { text: { matches: '^26\. ' } } } - echo: "Clear console" - tap: { id: "clear-console-button" } @@ -158,7 +182,7 @@ steps: - tap: { id: "open-console-button" } - await: { idle: true } - await: { visible: { id: "close-console-button" } } - - assert: { visible: { text: { matches: '^24\. \[typing pressable\] onPressIn$' } } } - - assert: { visible: { text: { matches: '^25\. \[typing pressable\] onPressOut$' } } } - - assert: { visible: { text: { matches: '^26\. \[typing pressable\] onPress$' } } } - - assert: { hidden: { text: { matches: '^27\. ' } } } + - assert: { visible: { text: { matches: '^26\. \[typing pressable\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^27\. \[typing pressable\] onPressOut$' } } } + - assert: { visible: { text: { matches: '^28\. \[typing pressable\] onPress$' } } } + - assert: { hidden: { text: { matches: '^29\. ' } } } diff --git a/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx b/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx index e68307ca5b..d2382c1a3a 100644 --- a/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx +++ b/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx @@ -11,6 +11,7 @@ import { LegacyPressable, PointerType, Pressable, + RawButton, RectButton, ScrollView, TextInput, @@ -128,6 +129,23 @@ export default function KeyboardActivationExample() { /> + {/* Its touch activates it from a UI-thread onTouchesDown, before the button reads that touch. */} + { + 'worklet'; + GestureStateManager.activate(event.handlerTag); + }} + onActivate={(event) => { + 'worklet'; + log( + `[touch activated button] onActivate ${PointerType[event.pointerType]} with ${event.numberOfPointers} pointers` + ); + }} + /> + {/* A tap here while the input holds the soft keyboard only dismisses it; a key press is no such tap. */} :::note -On Android, a hardware key's press and an accessibility service's click, which no pointer drives, activate the gesture with `numberOfPointers` set to `0`, and `pointerType` set to `PointerType.KEY` for the key and `PointerType.OTHER` for the service. On tvOS, the focus engine's Select press reports `numberOfPointers` set to `0` and `PointerType.KEY`. On web, a key's press counts as one `PointerType.KEY` pointer. +On Android, a hardware key's press and an accessibility service's click on a Gesture Handler button, which no pointer drives, activate the button's own native gesture with `numberOfPointers` set to `0`, and `pointerType` set to `PointerType.KEY` for the key and `PointerType.OTHER` for the service; a native gesture on any other view gets no such activation. On tvOS, the focus engine's Select press on a button reports `numberOfPointers` set to `0` and `PointerType.KEY`. On web, a key's press counts as one `PointerType.KEY` pointer. ::: diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index fd312de5e9..f33a823fa5 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -533,6 +533,11 @@ open class GestureHandler { if (event.actionMasked == MotionEvent.ACTION_DOWN || event.actionMasked == MotionEvent.ACTION_POINTER_DOWN ) { + // A touch callback can activate the handler before `handle()` runs, so its report counts this touch's pointers. + numberOfPointers = trackedPointersIDsCount + if (trackedPointersIDsCount == 1) { + setPointerType(sourceEvent) + } dispatchTouchDownEvent(event, sourceEvent) dispatchTouchMoveEvent(event, sourceEvent) } else if (event.actionMasked == MotionEvent.ACTION_UP || diff --git a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx index 2dd688aeb3..203f3cc7b0 100644 --- a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx @@ -651,6 +651,38 @@ describe.each([ expect(handlers.onPressOut).toHaveBeenCalledTimes(1); expect(handlers.onPress).toHaveBeenCalledTimes(1); }); + + test('presses a tvOS press that reports a pointer, as nothing on tvOS points at the screen', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + jest.replaceProperty(Platform, 'isTV', true); + const handlers = createPressHandlers(); + await driver.render(handlers); + layOutButton(); + + driver.fireNativeGesture([BEGAN, ACTIVATED, ENDED].map(withTouch)); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('runs nothing for a touch on an Android TV that begins in the press retention zone', async () => { + jest.replaceProperty(Platform, 'isTV', true); + const handlers = createPressHandlers(); + await driver.render({ + ...handlers, + pressRetentionOffset: PRESS_RETENTION_OFFSET, + }); + layOutButton(); + + driver.changeNativeGestureState(withTouch(BEGAN)); + driver.touchDownOnPressGesture(RETENTION_ZONE_TOUCH); + driver.changeNativeGestureState(withTouch(ACTIVATED)); + driver.changeNativeGestureState(withTouch(ENDED)); + + expect(handlers.onPressIn).not.toHaveBeenCalled(); + expect(handlers.onPress).not.toHaveBeenCalled(); + }); }); describe('Pressable on a TV', () => { @@ -670,6 +702,38 @@ describe('Pressable on a TV', () => { expect(handlers.onPressOut).toHaveBeenCalledTimes(1); expect(handlers.onPress).toHaveBeenCalledTimes(1); }); + + test('presses a tvOS press that reports a pointer under a hit slop that leaves out its center', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + jest.replaceProperty(Platform, 'isTV', true); + const handlers = createPressHandlers(); + await renderDefaultPressable({ + ...handlers, + hitSlop: CENTER_EXCLUDING_HIT_SLOP, + }); + layOutButton(); + + pressButton(buttonEvent(1, PointerType.TOUCH)); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('runs nothing for a touch on an Android TV at its center when its hit slop leaves the center out', async () => { + jest.replaceProperty(Platform, 'isTV', true); + const handlers = createPressHandlers(); + await renderDefaultPressable({ + ...handlers, + hitSlop: CENTER_EXCLUDING_HIT_SLOP, + }); + layOutButton(); + + pressButton(buttonEvent(1, PointerType.TOUCH)); + + expect(handlers.onPressIn).not.toHaveBeenCalled(); + expect(handlers.onPress).not.toHaveBeenCalled(); + }); }); // The press gesture sees a touch, and a scroll cancels it before the native gesture begins. diff --git a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts index 01af947c14..56fb8f11b4 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts +++ b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts @@ -184,9 +184,10 @@ const viewCenterToPressableEvent = ( }; // Android reports a key's or an accessibility service's activation of the native gesture with no pointer -// (https://www.w3.org/TR/pointerevents3/#event-attributes), and a TV's focus engine drives every press. +// (https://www.w3.org/TR/pointerevents3/#event-attributes); on tvOS nothing points at the screen, so the focus +// engine drives every press. const isActivatedWithoutPointer = (event: { numberOfPointers: number }) => - Platform.isTV || (Platform.OS === 'android' && event.numberOfPointers === 0); + Platform.OS === 'android' ? event.numberOfPointers === 0 : Platform.isTV; export { addInsets, From 8b5a34d1f13f60069f362fb9c1972c588919c323 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 16:31:22 +0300 Subject: [PATCH 08/11] [General] Report a touch's own position to an activation before it is handled, and press an iOS press no touch drives in every engine updatePointerData set the pointer count and type of a handler's down before its touch callbacks ran, but not where the pointer is, so a gesture its UI-thread onTouchesDown activates still reported the last position and pointerInside the handler held: (0, 0) and outside on its first touch, the view's center and inside after a key's press. handle() reads the count, position, bounds, last position and pointer type through helpers, and updatePointerData's down branch reads them through the same helpers from the same adapted events before it dispatches the touch. The example's touch-activated RawButton logs whether its activation lands on its touch, and the keyboard flow taps it first on a fresh screen. Apple's native view handler reports a press no touch drives at the view's center with no pointer on iPhone and iPad as well as on tvOS, and the default Pressable pressed for it there while StatefulPressable and LegacyPressable sent it into the touch state machine, which never presses it. isActivatedWithoutPointer now takes such a press on iOS as it does on Android; on tvOS every press still goes around the state machine, and web, which reports no pointer once a press's pointers lift, keeps its own path. The native gesture docs say iOS reports such a press with no pointer and as a key. --- .argent/flows/keyboard-activation-test.yaml | 59 ++++++------ .../tests/keyboardActivation/index.tsx | 22 ++++- .../docs/gestures/use-native-gesture.mdx | 2 +- .../gesturehandler/core/GestureHandler.kt | 85 +++++++++++------ .../pressableClickWithoutTouch.test.tsx | 92 ++++++++++++++++++- .../src/components/Pressable/utils.ts | 18 +++- 6 files changed, 211 insertions(+), 67 deletions(-) diff --git a/.argent/flows/keyboard-activation-test.yaml b/.argent/flows/keyboard-activation-test.yaml index 63a4d3f2fa..2671820648 100644 --- a/.argent/flows/keyboard-activation-test.yaml +++ b/.argent/flows/keyboard-activation-test.yaml @@ -1,7 +1,8 @@ # Android only: a hardware Enter presses each kind of focused gesture-handler -# control once, as a key with no pointer, where a tap would land; a touch after -# it reports its own pointer; and a gesture its touch activates still takes that -# touch from React Native. Tab +# control once, as a key with no pointer, where a tap would land; a touch that +# activates its gesture reports its own pointer and where it lands, after a key +# too; and a gesture its touch activates still takes that touch from React +# Native. Tab # moves focus in view order, so after the taps and the console clear the counts # below reach the inner default Pressable, then each control after it. steps: @@ -27,11 +28,12 @@ steps: - await: { hidden: { id: "close-console-button" } } - await: { idle: true } - - echo: "Tap the inner state-machine and legacy Pressables, the RectButton and the scaled Touchable" + - echo: "Tap the inner state-machine and legacy Pressables, the RectButton, the scaled Touchable and the RawButton its touch activates" - tap: { id: "stateful-inner" } - tap: { id: "legacy-inner" } - tap: { id: "rect-button" } - tap: { id: "scaled-touchable" } + - tap: { id: "touch-activated-button" } - await: { idle: true } - echo: "Each tap presses only its own control" @@ -42,6 +44,7 @@ steps: - assert: { visible: { text: { matches: '^6\. \[legacy inner\] onPress$' } } } - assert: { visible: { text: { matches: '^7\. \[rect button\] onBegin TOUCH with 1 pointers$' } } } - assert: { visible: { text: { matches: '^9\. \[scaled\] tap$' } } } + - assert: { visible: { text: { matches: '^10\. \[touch activated button\] onActivate TOUCH with 1 pointers at its touch$' } } } - assert: { hidden: { text: "outer]" } } - echo: "Clear console" @@ -73,17 +76,17 @@ steps: - tap: { id: "open-console-button" } - await: { idle: true } - await: { visible: { id: "close-console-button" } } - - assert: { visible: { text: { matches: '^10\. \[pressable inner\] onPressIn$' } } } - - assert: { visible: { text: { matches: '^11\. \[pressable inner\] onPressOut$' } } } - - assert: { visible: { text: { matches: '^12\. \[pressable inner\] onPress$' } } } - - assert: { visible: { text: { matches: '^13\. \[stateful outer\] onPressIn$' } } } - - assert: { visible: { text: { matches: '^14\. \[stateful outer\] onPressOut$' } } } - - assert: { visible: { text: { matches: '^15\. \[stateful outer\] onPress$' } } } - - assert: { visible: { text: { matches: '^16\. \[legacy outer\] onPressIn$' } } } - - assert: { visible: { text: { matches: '^17\. \[legacy outer\] onPressOut$' } } } - - assert: { visible: { text: { matches: '^18\. \[legacy outer\] onPress$' } } } + - assert: { visible: { text: { matches: '^11\. \[pressable inner\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^12\. \[pressable inner\] onPressOut$' } } } + - assert: { visible: { text: { matches: '^13\. \[pressable inner\] onPress$' } } } + - assert: { visible: { text: { matches: '^14\. \[stateful outer\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^15\. \[stateful outer\] onPressOut$' } } } + - assert: { visible: { text: { matches: '^16\. \[stateful outer\] onPress$' } } } + - assert: { visible: { text: { matches: '^17\. \[legacy outer\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^18\. \[legacy outer\] onPressOut$' } } } + - assert: { visible: { text: { matches: '^19\. \[legacy outer\] onPress$' } } } - assert: { hidden: { text: "[pressable outer]" } } - - assert: { hidden: { text: { matches: '^19\. ' } } } + - assert: { hidden: { text: { matches: '^20\. ' } } } - echo: "Clear console" - tap: { id: "clear-console-button" } @@ -123,11 +126,11 @@ steps: - tap: { id: "open-console-button" } - await: { idle: true } - await: { visible: { id: "close-console-button" } } - - assert: { visible: { text: { matches: '^19\. \[rect button\] onBegin KEY with 0 pointers$' } } } - - assert: { visible: { text: { matches: '^20\. \[rect button\] onPress$' } } } - - assert: { visible: { text: { matches: '^21\. \[scaled\] key press lands on the tap$' } } } - - assert: { visible: { text: { matches: '^22\. \[touch activated button\] onActivate KEY with 0 pointers$' } } } - - assert: { hidden: { text: { matches: '^23\. ' } } } + - assert: { visible: { text: { matches: '^20\. \[rect button\] onBegin KEY with 0 pointers$' } } } + - assert: { visible: { text: { matches: '^21\. \[rect button\] onPress$' } } } + - assert: { visible: { text: { matches: '^22\. \[scaled\] key press lands on the tap$' } } } + - assert: { visible: { text: { matches: '^23\. \[touch activated button\] onActivate KEY with 0 pointers$' } } } + - assert: { hidden: { text: { matches: '^24\. ' } } } - echo: "Clear console" - tap: { id: "clear-console-button" } @@ -143,8 +146,8 @@ steps: - tap: { id: "open-console-button" } - await: { idle: true } - await: { visible: { id: "close-console-button" } } - - assert: { visible: { text: { matches: '^23\. \[touch activated button\] onActivate TOUCH with 1 pointers$' } } } - - assert: { hidden: { text: { matches: '^24\. ' } } } + - assert: { visible: { text: { matches: '^24\. \[touch activated button\] onActivate TOUCH with 1 pointers at its touch$' } } } + - assert: { hidden: { text: { matches: '^25\. ' } } } - echo: "Clear console" - tap: { id: "clear-console-button" } @@ -160,9 +163,9 @@ steps: - tap: { id: "open-console-button" } - await: { idle: true } - await: { visible: { id: "close-console-button" } } - - assert: { visible: { text: { matches: '^24\. \[react native pressable\] onPressIn$' } } } - - assert: { visible: { text: { matches: '^25\. \[react native pressable\] onPressOut$' } } } - - assert: { hidden: { text: { matches: '^26\. ' } } } + - assert: { visible: { text: { matches: '^25\. \[react native pressable\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^26\. \[react native pressable\] onPressOut$' } } } + - assert: { hidden: { text: { matches: '^27\. ' } } } - echo: "Clear console" - tap: { id: "clear-console-button" } @@ -182,7 +185,7 @@ steps: - tap: { id: "open-console-button" } - await: { idle: true } - await: { visible: { id: "close-console-button" } } - - assert: { visible: { text: { matches: '^26\. \[typing pressable\] onPressIn$' } } } - - assert: { visible: { text: { matches: '^27\. \[typing pressable\] onPressOut$' } } } - - assert: { visible: { text: { matches: '^28\. \[typing pressable\] onPress$' } } } - - assert: { hidden: { text: { matches: '^29\. ' } } } + - assert: { visible: { text: { matches: '^27\. \[typing pressable\] onPressIn$' } } } + - assert: { visible: { text: { matches: '^28\. \[typing pressable\] onPressOut$' } } } + - assert: { visible: { text: { matches: '^29\. \[typing pressable\] onPress$' } } } + - assert: { hidden: { text: { matches: '^30\. ' } } } diff --git a/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx b/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx index d2382c1a3a..a93b97c9cf 100644 --- a/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx +++ b/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx @@ -18,6 +18,7 @@ import { Touchable, useLongPressGesture, } from 'react-native-gesture-handler'; +import { useSharedValue } from 'react-native-reanimated'; import { COLORS, useIndexedLogger } from '../../../common'; @@ -25,6 +26,22 @@ type Log = (message: string) => void; type Position = { absoluteX: number; absoluteY: number }; +type Point = { x: number; y: number }; + +// A press no pointer drives has no touch to land on. +function describeActivationPoint( + event: Point & { numberOfPointers: number; pointerInside: boolean }, + touch: Point +) { + 'worklet'; + if (event.numberOfPointers === 0) { + return ''; + } + const isAtTouch = + Math.abs(event.x - touch.x) <= 1 && Math.abs(event.y - touch.y) <= 1; + return `${isAtTouch ? ' at its touch' : ' away from its touch'}${event.pointerInside ? '' : ', outside'}`; +} + const pressHandlers = (log: Log, token: string) => ({ onPressIn: () => log(`${token} onPressIn`), onPressOut: () => log(`${token} onPressOut`), @@ -59,6 +76,7 @@ function useTapComparison(log: Log) { export default function KeyboardActivationExample() { const log = useIndexedLogger(); const compareWithTap = useTapComparison(log); + const touchOnTouchActivatedButton = useSharedValue({ x: 0, y: 0 }); const touchActivatedLongPress = useLongPressGesture({ onTouchesDown: (event) => { 'worklet'; @@ -136,12 +154,14 @@ export default function KeyboardActivationExample() { style={styles.touchActivatedButton} onTouchesDown={(event) => { 'worklet'; + const { x, y } = event.changedTouches[0]; + touchOnTouchActivatedButton.value = { x, y }; GestureStateManager.activate(event.handlerTag); }} onActivate={(event) => { 'worklet'; log( - `[touch activated button] onActivate ${PointerType[event.pointerType]} with ${event.numberOfPointers} pointers` + `[touch activated button] onActivate ${PointerType[event.pointerType]} with ${event.numberOfPointers} pointers${describeActivationPoint(event, touchOnTouchActivatedButton.value)}` ); }} /> diff --git a/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx b/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx index 85e83a5de1..07b24e6a1d 100644 --- a/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx +++ b/packages/docs-gesture-handler/docs/gestures/use-native-gesture.mdx @@ -186,5 +186,5 @@ Y coordinate, expressed in points, of the same point relative to the window. :::note -On Android, a hardware key's press and an accessibility service's click on a Gesture Handler button, which no pointer drives, activate the button's own native gesture with `numberOfPointers` set to `0`, and `pointerType` set to `PointerType.KEY` for the key and `PointerType.OTHER` for the service; a native gesture on any other view gets no such activation. On tvOS, the focus engine's Select press on a button reports `numberOfPointers` set to `0` and `PointerType.KEY`. On web, a key's press counts as one `PointerType.KEY` pointer. +On Android, a hardware key's press and an accessibility service's click on a Gesture Handler button, which no pointer drives, activate the button's own native gesture with `numberOfPointers` set to `0`, and `pointerType` set to `PointerType.KEY` for the key and `PointerType.OTHER` for the service; a native gesture on any other view gets no such activation. On iOS, a press on a button that no touch drives, such as the focus engine's Select press on tvOS, reports `numberOfPointers` set to `0` and `PointerType.KEY`. On web, a key's press counts as one `PointerType.KEY` pointer. ::: diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index f33a823fa5..81782ee5dc 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -392,18 +392,59 @@ open class GestureHandler { return } - // a workaround for https://github.com/software-mansion/react-native-gesture-handler/issues/1188 - val (adaptedTransformedEvent, adaptedSourceEvent) = if (BuildConfig.DEBUG) { + val adaptedEvents = adaptEvents(transformedEvent, sourceEvent) ?: run { + fail() + return + } + val (adaptedTransformedEvent, adaptedSourceEvent) = adaptedEvents + + updatePointerCountAndPosition(adaptedTransformedEvent) + + if (shouldCancelWhenOutside) { + if (!isWithinBounds && (state == STATE_ACTIVE || state == STATE_BEGAN)) { + fail() + return + } + } + + updateLastPosition(adaptedTransformedEvent) + updatePointerType(adaptedSourceEvent, sourceEvent) + + if (sourceEvent.isHoverAction()) { + onHandleHover(adaptedTransformedEvent, adaptedSourceEvent) + } else { + onHandle(adaptedTransformedEvent, adaptedSourceEvent) + } + recycleAdaptedEvents(adaptedEvents, transformedEvent, sourceEvent) + } + + // a workaround for https://github.com/software-mansion/react-native-gesture-handler/issues/1188 + private fun adaptEvents(transformedEvent: MotionEvent, sourceEvent: MotionEvent): Array? = + if (BuildConfig.DEBUG) { arrayOf(adaptEvent(transformedEvent), adaptEvent(sourceEvent)) } else { try { arrayOf(adaptEvent(transformedEvent), adaptEvent(sourceEvent)) } catch (e: AdaptEventException) { - fail() - return + null } } + private fun recycleAdaptedEvents( + adaptedEvents: Array, + transformedEvent: MotionEvent, + sourceEvent: MotionEvent, + ) { + val (adaptedTransformedEvent, adaptedSourceEvent) = adaptedEvents + if (adaptedTransformedEvent != transformedEvent) { + adaptedTransformedEvent.recycle() + } + if (adaptedSourceEvent != sourceEvent) { + adaptedSourceEvent.recycle() + } + } + + private fun updatePointerCountAndPosition(adaptedTransformedEvent: MotionEvent) { numberOfPointers = when (adaptedTransformedEvent.actionMasked) { MotionEvent.ACTION_POINTER_UP -> adaptedTransformedEvent.pointerCount - 1 else -> adaptedTransformedEvent.pointerCount @@ -416,38 +457,23 @@ open class GestureHandler { // also use that child rather than the wrapper, otherwise hit-testing would ignore the user's // transforms applied to the visible view. isWithinBounds = isWithinBounds(coordinateView, x, y) + } - if (shouldCancelWhenOutside) { - if (!isWithinBounds && (state == STATE_ACTIVE || state == STATE_BEGAN)) { - fail() - return - } - } - + private fun updateLastPosition(adaptedTransformedEvent: MotionEvent) { lastAbsolutePositionX = GestureUtils.getLastPointerX(adaptedTransformedEvent, true) lastAbsolutePositionY = GestureUtils.getLastPointerY(adaptedTransformedEvent, true) lastEventOffsetX = adaptedTransformedEvent.rawX - adaptedTransformedEvent.x lastEventOffsetY = adaptedTransformedEvent.rawY - adaptedTransformedEvent.y + } - // The pointer beginning the gesture names its type; adapted, its event is `ACTION_DOWN`, a second finger's too. + // The pointer beginning the gesture names its type; adapted, its event is `ACTION_DOWN`, a second finger's too. + private fun updatePointerType(adaptedSourceEvent: MotionEvent, sourceEvent: MotionEvent) { if (adaptedSourceEvent.actionMasked == MotionEvent.ACTION_DOWN || sourceEvent.action == MotionEvent.ACTION_HOVER_ENTER || sourceEvent.action == MotionEvent.ACTION_HOVER_MOVE ) { setPointerType(adaptedSourceEvent) } - - if (sourceEvent.isHoverAction()) { - onHandleHover(adaptedTransformedEvent, adaptedSourceEvent) - } else { - onHandle(adaptedTransformedEvent, adaptedSourceEvent) - } - if (adaptedTransformedEvent != transformedEvent) { - adaptedTransformedEvent.recycle() - } - if (adaptedSourceEvent != sourceEvent) { - adaptedSourceEvent.recycle() - } } private fun dispatchTouchDownEvent(event: MotionEvent, sourceEvent: MotionEvent) { @@ -533,10 +559,13 @@ open class GestureHandler { if (event.actionMasked == MotionEvent.ACTION_DOWN || event.actionMasked == MotionEvent.ACTION_POINTER_DOWN ) { - // A touch callback can activate the handler before `handle()` runs, so its report counts this touch's pointers. - numberOfPointers = trackedPointersIDsCount - if (trackedPointersIDsCount == 1) { - setPointerType(sourceEvent) + // A touch callback can activate the handler before `handle()` runs, so its report describes this touch first. + adaptEvents(event, sourceEvent)?.let { adaptedEvents -> + val (adaptedEvent, adaptedSourceEvent) = adaptedEvents + updatePointerCountAndPosition(adaptedEvent) + updateLastPosition(adaptedEvent) + updatePointerType(adaptedSourceEvent, sourceEvent) + recycleAdaptedEvents(adaptedEvents, event, sourceEvent) } dispatchTouchDownEvent(event, sourceEvent) dispatchTouchMoveEvent(event, sourceEvent) diff --git a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx index 203f3cc7b0..ab0baa4ddb 100644 --- a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx @@ -121,8 +121,8 @@ const withoutPointer = const CLICK_WITHOUT_TOUCH = [BEGAN, ACTIVATED, ENDED].map( withoutPointer(PointerType.KEY) ); -// tvOS sends a focus Select press with no touch, as a key at the view's center. -const TV_SELECT_PRESS = [BEGAN, ACTIVATED, ENDED].map( +// Apple's native view handler sends a press no touch drives, a TV's focus Select press among them, as a key at the view's center. +const APPLE_PRESS_WITHOUT_TOUCH = [BEGAN, ACTIVATED, ENDED].map( withoutPointer(PointerType.KEY) ); const PRESSED_WHERE_NATIVE_GESTURE_REPORTS = { @@ -633,7 +633,7 @@ describe.each([ await driver.render(handlers); layOutButton(); - driver.fireNativeGesture(TV_SELECT_PRESS); + driver.fireNativeGesture(APPLE_PRESS_WITHOUT_TOUCH); expectPressedOnceAt(handlers, PRESSED_WHERE_NATIVE_GESTURE_REPORTS); }); @@ -645,7 +645,7 @@ describe.each([ await driver.render({ ...handlers, hitSlop: CENTER_EXCLUDING_HIT_SLOP }); layOutButton(); - driver.fireNativeGesture(TV_SELECT_PRESS); + driver.fireNativeGesture(APPLE_PRESS_WITHOUT_TOUCH); expect(handlers.onPressIn).toHaveBeenCalledTimes(1); expect(handlers.onPressOut).toHaveBeenCalledTimes(1); @@ -736,6 +736,90 @@ describe('Pressable on a TV', () => { }); }); +describe.each([ + ['StatefulPressable', statefulDriver], + ['LegacyPressable', legacyDriver], +] as const)('%s on iOS', (_name, driver) => { + test('presses a press no touch drives at the position its native gesture reports', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + const handlers = createPressHandlers(); + await driver.render(handlers); + layOutButton(); + + driver.fireNativeGesture(APPLE_PRESS_WITHOUT_TOUCH); + + expectPressedOnceAt(handlers, PRESSED_WHERE_NATIVE_GESTURE_REPORTS); + }); + + // On iOS the press gesture sees the touch before the native gesture begins. + test('runs onPress once for a touch press', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + const handlers = createPressHandlers(); + await driver.render(handlers); + layOutButton(); + + driver.touchDownOnPressGesture(BUTTON_CENTER_TOUCH); + driver.changeNativeGestureState(withTouch(BEGAN)); + driver.changeNativeGestureState(withTouch(ACTIVATED)); + driver.changeNativeGestureState(withTouch(ENDED)); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('runs nothing for a touch that begins in the press retention zone', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + const handlers = createPressHandlers(); + await driver.render({ + ...handlers, + pressRetentionOffset: PRESS_RETENTION_OFFSET, + }); + layOutButton(); + + driver.touchDownOnPressGesture(RETENTION_ZONE_TOUCH); + driver.changeNativeGestureState(withTouch(BEGAN)); + driver.changeNativeGestureState(withTouch(ACTIVATED)); + driver.changeNativeGestureState(withTouch(ENDED)); + + expect(handlers.onPressIn).not.toHaveBeenCalled(); + expect(handlers.onPress).not.toHaveBeenCalled(); + }); +}); + +describe('Pressable on iOS', () => { + test('presses a press no touch drives whose hit slop leaves out its center', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + const handlers = createPressHandlers(); + await renderDefaultPressable({ + ...handlers, + hitSlop: CENTER_EXCLUDING_HIT_SLOP, + }); + layOutButton(); + + pressButton(buttonEvent(0, PointerType.KEY)); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('runs nothing for a touch at its center when its hit slop leaves the center out', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + const handlers = createPressHandlers(); + await renderDefaultPressable({ + ...handlers, + hitSlop: CENTER_EXCLUDING_HIT_SLOP, + }); + layOutButton(); + + pressButton(buttonEvent(1, PointerType.TOUCH)); + + expect(handlers.onPressIn).not.toHaveBeenCalled(); + expect(handlers.onPress).not.toHaveBeenCalled(); + }); +}); + // The press gesture sees a touch, and a scroll cancels it before the native gesture begins. const cancelTouchOnStatefulPressGesture = ( touch: Pick diff --git a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts index 56fb8f11b4..cb532fb8e3 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts +++ b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts @@ -183,11 +183,19 @@ const viewCenterToPressableEvent = ( }; }; -// Android reports a key's or an accessibility service's activation of the native gesture with no pointer -// (https://www.w3.org/TR/pointerevents3/#event-attributes); on tvOS nothing points at the screen, so the focus -// engine drives every press. -const isActivatedWithoutPointer = (event: { numberOfPointers: number }) => - Platform.OS === 'android' ? event.numberOfPointers === 0 : Platform.isTV; +// Android and iOS report a press no pointer drives, a key's, an accessibility service's or one no touch drives, with +// no pointer (https://www.w3.org/TR/pointerevents3/#event-attributes); on tvOS nothing points at the screen, so the +// focus engine drives every press. Web reports no pointer once a press's pointers lift. +const isActivatedWithoutPointer = (event: { numberOfPointers: number }) => { + switch (Platform.OS) { + case 'android': + return event.numberOfPointers === 0; + case 'ios': + return Platform.isTV || event.numberOfPointers === 0; + default: + return false; + } +}; export { addInsets, From df295511de6a09b6537c28b1439f0c7d101de12b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 16:40:59 +0300 Subject: [PATCH 09/11] [General] Test that a web press goes out through its press gesture Web drops a pointer from its tracker before its native gesture ends, so every web press ends reporting no pointer, which isActivatedWithoutPointer leaves to web's own path. A case pins it: LegacyPressable's web press goes out where its press gesture saw the touch, not where its native gesture ends. --- .../pressableClickWithoutTouch.test.tsx | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx index ab0baa4ddb..f0e1c3c43c 100644 --- a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx @@ -787,6 +787,50 @@ describe.each([ }); }); +// Web drops a pointer from its tracker before its gesture ends, so every press ends reporting no pointer. +const WEB_RELEASE = { + ...reportingPointers(0, PointerType.TOUCH)(ENDED), + x: 10, + y: 10, + absoluteX: BUTTON_SCREEN_ORIGIN.x + 10, + absoluteY: BUTTON_SCREEN_ORIGIN.y + 10, +}; + +const finishLegacyPressGesture = () => { + const { handlerTag } = findLegacyGesture(longPressHandlerName); + act(() => { + for (const transition of [BEGAN, ACTIVATED, ENDED]) { + DeviceEventEmitter.emit('onGestureHandlerStateChange', { + handlerTag, + ...withTouch(transition), + pointerInside: true, + }); + } + }); +}; + +describe('LegacyPressable on web', () => { + test('presses out where its press gesture saw the touch, not where its native gesture ends', async () => { + jest.replaceProperty(Platform, 'OS', 'web'); + const handlers = createPressHandlers(); + await legacyDriver.render(handlers); + layOutButton(); + + legacyDriver.changeNativeGestureState(withTouch(BEGAN)); + legacyDriver.changeNativeGestureState(withTouch(ACTIVATED)); + legacyDriver.touchDownOnPressGesture(BUTTON_CENTER_TOUCH); + legacyDriver.changeNativeGestureState(WEB_RELEASE); + finishLegacyPressGesture(); + + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut.mock.calls[0][0].nativeEvent).toMatchObject({ + locationX: BUTTON_CENTER_TOUCH.x, + locationY: BUTTON_CENTER_TOUCH.y, + }); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); +}); + describe('Pressable on iOS', () => { test('presses a press no touch drives whose hit slop leaves out its center', async () => { jest.replaceProperty(Platform, 'OS', 'ios'); From 757dad370c8dfd1a7570692455569e6cd9052f44 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 17:28:39 +0300 Subject: [PATCH 10/11] [General] End a press with no pointer the way it began A press's last event counts only the pointers still down: none once a web press's pointers lift, and none on iOS for an action a button sends with no event, as it sends a press's cancel when it is disabled under a finger. LegacyPressable and StatefulPressable read isActivatedWithoutPointer at their native gesture's onBegin and end the press the way it began, as PressableWithTouchable keeps its press-in verdict, so a touch press whose native gesture ends reporting no pointer presses where the touch was. Read only at a press's first event, the rule needs no platform but tvOS: web counts a key's press as a pointer, so no web press begins without one. --- .../pressableClickWithoutTouch.test.tsx | 20 +++++++++++++++++++ .../src/components/Pressable/Pressable.tsx | 8 ++++++-- .../src/components/Pressable/utils.ts | 18 +++++------------ .../src/v3/components/StatefulPressable.tsx | 8 ++++++-- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx index f0e1c3c43c..df9578c084 100644 --- a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx @@ -149,6 +149,8 @@ const RETENTION_ZONE_TOUCH = { x: 130, y: 22 }; // A hit slop that takes the button's left half, center included, out of what a pointer can press. const CENTER_EXCLUDING_HIT_SLOP = { left: -(BUTTON_CENTER_TOUCH.x + 10) }; +const TOUCH_AWAY_FROM_CENTER = { x: 10, y: 10 }; + type PressHandler = jest.Mock; type PressHandlers = { @@ -785,6 +787,24 @@ describe.each([ expect(handlers.onPressIn).not.toHaveBeenCalled(); expect(handlers.onPress).not.toHaveBeenCalled(); }); + + // An action an iOS button sends with no event, as it sends a press's cancel when it is disabled under a finger, counts no pointer. + test('presses a touch press where the touch was when its native gesture ends reporting no pointer', async () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + const handlers = createPressHandlers(); + await driver.render(handlers); + layOutButton(); + + driver.touchDownOnPressGesture(TOUCH_AWAY_FROM_CENTER); + driver.changeNativeGestureState(withTouch(BEGAN)); + driver.changeNativeGestureState(withTouch(ACTIVATED)); + driver.changeNativeGestureState(withoutPointer(PointerType.TOUCH)(ENDED)); + + expectPressedOnceAt(handlers, { + locationX: TOUCH_AWAY_FROM_CENTER.x, + locationY: TOUCH_AWAY_FROM_CENTER.y, + }); + }); }); // Web drops a pointer from its tracker before its gesture ends, so every press ends reporting no pointer. diff --git a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx index 2430966da3..7bb0bbd68e 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx @@ -85,6 +85,8 @@ const LegacyPressable = (props: LegacyPressableProps) => { const pressDelayTimeoutRef = useRef(null); const isOnPressAllowed = useRef(true); const isCurrentlyPressed = useRef(false); + // Whether the press in progress began with no pointer, which decides how it ends. + const isPressWithoutPointer = useRef(false); const dimensions = useRef({ width: 0, height: 0 }); const normalizedHitSlop: Insets = useMemo( @@ -149,6 +151,7 @@ const LegacyPressable = (props: LegacyPressableProps) => { const handleFinalize = useCallback(() => { isCurrentlyPressed.current = false; + isPressWithoutPointer.current = false; cancelLongPress(); cancelDelayedPress(); setPressedState(false); @@ -305,7 +308,8 @@ const LegacyPressable = (props: LegacyPressableProps) => { } }) .onBegin((event) => { - if (isActivatedWithoutPointer(event)) { + isPressWithoutPointer.current = isActivatedWithoutPointer(event); + if (isPressWithoutPointer.current) { // The press state machine is touch-based and never receives LONG_PRESS_TOUCHES_DOWN here. // The hit-slop bounds check tests where a pointer went down, and a press with no pointer has none. handlePressIn(gestureToPressableEvent(event), true); @@ -327,7 +331,7 @@ const LegacyPressable = (props: LegacyPressableProps) => { } }) .onFinalize((event, success) => { - if (isActivatedWithoutPointer(event)) { + if (isPressWithoutPointer.current) { handlePressOut(gestureToPressableEvent(event), success); handleFinalize(); return; diff --git a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts index cb532fb8e3..13a4ea1dbc 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts +++ b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts @@ -183,19 +183,11 @@ const viewCenterToPressableEvent = ( }; }; -// Android and iOS report a press no pointer drives, a key's, an accessibility service's or one no touch drives, with -// no pointer (https://www.w3.org/TR/pointerevents3/#event-attributes); on tvOS nothing points at the screen, so the -// focus engine drives every press. Web reports no pointer once a press's pointers lift. -const isActivatedWithoutPointer = (event: { numberOfPointers: number }) => { - switch (Platform.OS) { - case 'android': - return event.numberOfPointers === 0; - case 'ios': - return Platform.isTV || event.numberOfPointers === 0; - default: - return false; - } -}; +// A press no pointer drives, a key's, an accessibility service's or one no touch drives, begins with no pointer +// (https://www.w3.org/TR/pointerevents3/#event-attributes); on tvOS nothing points at the screen, so the focus engine +// drives every press. A press's last event counts only the pointers still down, so a press is classified by its first. +const isActivatedWithoutPointer = (event: { numberOfPointers: number }) => + (Platform.OS === 'ios' && Platform.isTV) || event.numberOfPointers === 0; export { addInsets, diff --git a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx index ac55aba13f..2ce7c8954b 100644 --- a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx @@ -92,6 +92,8 @@ const StatefulPressable = (props: PressableProps) => { const isOnPressAllowed = useRef(true); const jsResponderContext = use(JSResponderContext); const isCurrentlyPressed = useRef(false); + // Whether the press in progress began with no pointer, which decides how it ends. + const isPressWithoutPointer = useRef(false); const dimensions = useRef({ width: 0, height: 0, @@ -163,6 +165,7 @@ const StatefulPressable = (props: PressableProps) => { const handleFinalize = useCallback(() => { isCurrentlyPressed.current = false; + isPressWithoutPointer.current = false; dropKeyboardTapRef.current = null; cancelLongPress(); cancelDelayedPress(); @@ -362,13 +365,14 @@ const StatefulPressable = (props: PressableProps) => { } }, onBegin: (event) => { + isPressWithoutPointer.current = isActivatedWithoutPointer(event); captureKeyboardDismiss(event.numberOfPointers); if (dropKeyboardTapRef.current) { return; } - if (isActivatedWithoutPointer(event)) { + if (isPressWithoutPointer.current) { // A TV drives this native gesture from the focus-engine Select press, and Android from a key // or an accessibility service with no pointer. The press state machine is touch-based and never // receives LONG_PRESS_TOUCHES_DOWN here, so bypass it and drive the press handlers directly. @@ -397,7 +401,7 @@ const StatefulPressable = (props: PressableProps) => { return; } - if (isActivatedWithoutPointer(event)) { + if (isPressWithoutPointer.current) { handlePressOut(gestureToPressableEvent(event), !event.canceled); handleFinalize(); return; From 849bf134e6a172755b493ecea98e6bf8a6b902e5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 18:16:16 +0300 Subject: [PATCH 11/11] [General] Decide a keyboard-dismissing tap by the press rule, and recycle a handler's adapted events on every exit isKeyboardDismissingTap took a press with no pointer to be one counting 0 pointers, while the press path asks isActivatedWithoutPointer, which takes every press on tvOS, so a tvOS press reporting a pointer went around the state machine and was still dropped as the tap dismissing the keyboard. It now takes the press and asks the same rule, in StatefulPressable and in the Touchable under the default Pressable. handle() returned from its shouldCancelWhenOutside failure without recycling the events it adapted, as on main; it recycles them in a finally, as the orchestrator releases its pooled lists. The orchestrator's comment and the recording hook's KDoc say the recorded-handler check runs where a pointer's touch records a handler, and StatefulPressable's comment names iOS beside Android and tvOS. --- .../gesturehandler/core/GestureHandler.kt | 29 +++--- .../core/GestureHandlerOrchestrator.kt | 3 +- .../core/NativeViewGestureHandler.kt | 4 +- .../src/__tests__/api_v3.test.tsx | 48 +++------ .../pressableClickWithoutTouch.test.tsx | 98 +++++++++++++++---- .../src/v3/components/StatefulPressable.tsx | 16 +-- .../src/v3/components/Touchable/Touchable.tsx | 2 +- .../src/v3/scrollViewInterop.ts | 8 +- 8 files changed, 126 insertions(+), 82 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 81782ee5dc..0d878319a3 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -398,24 +398,27 @@ open class GestureHandler { } val (adaptedTransformedEvent, adaptedSourceEvent) = adaptedEvents - updatePointerCountAndPosition(adaptedTransformedEvent) + try { + updatePointerCountAndPosition(adaptedTransformedEvent) - if (shouldCancelWhenOutside) { - if (!isWithinBounds && (state == STATE_ACTIVE || state == STATE_BEGAN)) { - fail() - return + if (shouldCancelWhenOutside) { + if (!isWithinBounds && (state == STATE_ACTIVE || state == STATE_BEGAN)) { + fail() + return + } } - } - updateLastPosition(adaptedTransformedEvent) - updatePointerType(adaptedSourceEvent, sourceEvent) + updateLastPosition(adaptedTransformedEvent) + updatePointerType(adaptedSourceEvent, sourceEvent) - if (sourceEvent.isHoverAction()) { - onHandleHover(adaptedTransformedEvent, adaptedSourceEvent) - } else { - onHandle(adaptedTransformedEvent, adaptedSourceEvent) + if (sourceEvent.isHoverAction()) { + onHandleHover(adaptedTransformedEvent, adaptedSourceEvent) + } else { + onHandle(adaptedTransformedEvent, adaptedSourceEvent) + } + } finally { + recycleAdaptedEvents(adaptedEvents, transformedEvent, sourceEvent) } - recycleAdaptedEvents(adaptedEvents, transformedEvent, sourceEvent) } // a workaround for https://github.com/software-mansion/react-native-gesture-handler/issues/1188 diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 08f97ebd00..436ee08c5f 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -547,7 +547,8 @@ class GestureHandlerOrchestrator( if (!gestureHandlers.contains(handler)) { prepareForRecording(handler, view) - // The handlers already recorded compete with this one for the pointer's touch. + // The handlers the current touch already recorded may refuse one its pointer records; an activation no pointer + // drives is no part of a touch. if (!handler.shouldBeginWithRecordedHandlers(gestureHandlers)) { handler.cancel() } diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt index dc25ebf43e..ea3cd7d158 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt @@ -427,8 +427,8 @@ class NativeViewGestureHandler : GestureHandler() { fun shouldCancelRootViewGestureHandlerIfNecessary() = false /** - * Called when the handler is being recorded by the orchestrator, before any pointer events - * are delivered. Returning `false` cancels the handler immediately. + * Called when a pointer's touch records the handler with the orchestrator, before any pointer + * events are delivered. Returning `false` cancels the handler immediately. * * @param recorded handlers already recorded for the current touch * @param handler the handler being recorded diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index b3a22bf299..5926a52a32 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -488,7 +488,7 @@ describe('[API v3] Components', () => { }); // A tap is driven by one pointer. - const ONE_POINTER = 1; + const TAP = { numberOfPointers: 1 }; // The drop requires a focused RN TextInput to blur. const focusInput = () => @@ -510,35 +510,25 @@ describe('[API v3] Components', () => { await act(flushImmediate); // Keyboard not shown yet -> nothing to dismiss. - expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( - false - ); + expect(isKeyboardDismissingTap(makeContext('never'), TAP)).toBe(false); showKeyboard(addListenerSpy); // `never` (and its default, undefined) drops the tap; the others never do. - expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( - true - ); - expect(isKeyboardDismissingTap(makeContext(undefined), ONE_POINTER)).toBe( - true - ); - expect(isKeyboardDismissingTap(makeContext('handled'), ONE_POINTER)).toBe( - false - ); - expect(isKeyboardDismissingTap(makeContext('always'), ONE_POINTER)).toBe( - false - ); + expect(isKeyboardDismissingTap(makeContext('never'), TAP)).toBe(true); + expect(isKeyboardDismissingTap(makeContext(undefined), TAP)).toBe(true); + expect(isKeyboardDismissingTap(makeContext('handled'), TAP)).toBe(false); + expect(isKeyboardDismissingTap(makeContext('always'), TAP)).toBe(false); // Outside an RNGH ScrollView there is no context, so nothing is dropped. - expect(isKeyboardDismissingTap(null, ONE_POINTER)).toBe(false); + expect(isKeyboardDismissingTap(null, TAP)).toBe(false); // A key or an accessibility service presses without a pointer, which is no tap. - expect(isKeyboardDismissingTap(makeContext('never'), 0)).toBe(false); + expect( + isKeyboardDismissingTap(makeContext('never'), { numberOfPointers: 0 }) + ).toBe(false); // The verdict must survive the dismissal blurring the input mid-tap. focusSpy.mockReturnValue(undefined); - expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( - true - ); + expect(isKeyboardDismissingTap(makeContext('never'), TAP)).toBe(true); addListenerSpy.mockRestore(); focusSpy.mockRestore(); @@ -559,16 +549,12 @@ describe('[API v3] Components', () => { showKeyboard(addListenerSpy); expect(TextInput.State.currentlyFocusedInput()).toBeNull(); - expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( - false - ); + expect(isKeyboardDismissingTap(makeContext('never'), TAP)).toBe(false); // Focus moving to an RN input while the keyboard stays up makes the // tap dismissible again. const focusSpy = focusInput(); - expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( - true - ); + expect(isKeyboardDismissingTap(makeContext('never'), TAP)).toBe(true); addListenerSpy.mockRestore(); focusSpy.mockRestore(); @@ -587,9 +573,7 @@ describe('[API v3] Components', () => { // A detached/floating keyboard reports height 0 - nothing to dismiss. showKeyboard(addListenerSpy, 0); - expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( - false - ); + expect(isKeyboardDismissingTap(makeContext('never'), TAP)).toBe(false); addListenerSpy.mockRestore(); }); @@ -660,9 +644,7 @@ describe('[API v3] Components', () => { ); await act(flushImmediate); showKeyboard(addListenerSpy); - expect(isKeyboardDismissingTap(makeContext('never'), ONE_POINTER)).toBe( - true - ); + expect(isKeyboardDismissingTap(makeContext('never'), TAP)).toBe(true); // A key or an accessibility service dismisses no keyboard, so the press is not the dismissing tap. const button = screen.getByTestId('touchable'); diff --git a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx index df9578c084..a907c24d1b 100644 --- a/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx @@ -562,12 +562,17 @@ const pressButton = (event: ReturnType) => { } }; -const renderDefaultPressable = (props: EngineProps) => +const renderDefaultPressable = ( + props: EngineProps, + container: PressableContainer = renderAlone +) => renderSettled( - - Press Me - + {container( + + Press Me + + )} ); @@ -915,19 +920,30 @@ const cancelTouchOnStatefulPressGesture = ( }); }; -// Only `StatefulPressable` drops the tap that dismisses the keyboard, so only it is driven with the keyboard up. +// Renders a press target in a gesture-handler `ScrollView` whose tap dismisses the keyboard a React Native input holds. +const renderWithKeyboardUp = async ( + renderPressable: (container: PressableContainer) => Promise +) => { + const addListenerSpy = jest.spyOn(Keyboard, 'addListener'); + focusTextInput(); + await renderPressable(insideScrollViewDismissingKeyboard); + layOutButton(); + emitKeyboardEvent(addListenerSpy, 'keyboardDidShow'); + expect( + isKeyboardDismissingTap(NEVER_PERSIST_TAPS_CONTEXT, { + numberOfPointers: ONE_POINTER, + }) + ).toBe(true); + return addListenerSpy; +}; + +// `LegacyPressable` drops no tap dismissing the keyboard, so only `StatefulPressable` and the default `Pressable`, +// whose `Touchable` drops it, are driven with the keyboard up. describe('StatefulPressable on Android with the keyboard up for a React Native input', () => { - const renderWithKeyboardUp = async (handlers: PressHandlers) => { - const addListenerSpy = jest.spyOn(Keyboard, 'addListener'); - focusTextInput(); - await statefulDriver.render(handlers, insideScrollViewDismissingKeyboard); - layOutButton(); - emitKeyboardEvent(addListenerSpy, 'keyboardDidShow'); - expect( - isKeyboardDismissingTap(NEVER_PERSIST_TAPS_CONTEXT, ONE_POINTER) - ).toBe(true); - return addListenerSpy; - }; + const renderStatefulWithKeyboardUp = (handlers: PressHandlers) => + renderWithKeyboardUp((container) => + statefulDriver.render(handlers, container) + ); afterEach(() => { setKeyboardVisibility(false); @@ -935,7 +951,7 @@ describe('StatefulPressable on Android with the keyboard up for a React Native i test('drops a touch press, which is the tap dismissing the keyboard', async () => { const handlers = createPressHandlers(); - await renderWithKeyboardUp(handlers); + await renderStatefulWithKeyboardUp(handlers); statefulDriver.changeNativeGestureState(withTouch(BEGAN)); statefulDriver.touchDownOnPressGesture(BUTTON_CENTER_TOUCH); @@ -948,7 +964,7 @@ describe('StatefulPressable on Android with the keyboard up for a React Native i test('runs onPress once for a click without a touch', async () => { const handlers = createPressHandlers(); - await renderWithKeyboardUp(handlers); + await renderStatefulWithKeyboardUp(handlers); statefulDriver.fireNativeGesture(CLICK_WITHOUT_TOUCH); @@ -962,7 +978,7 @@ describe('StatefulPressable on Android with the keyboard up for a React Native i .spyOn(AccessibilityInfo, 'isScreenReaderEnabled') .mockResolvedValueOnce(true); const handlers = createPressHandlers(); - await renderWithKeyboardUp(handlers); + await renderStatefulWithKeyboardUp(handlers); const [began, activated, ended] = ACCESSIBILITY_CLICK; statefulDriver.changeNativeGestureState(began); @@ -978,7 +994,7 @@ describe('StatefulPressable on Android with the keyboard up for a React Native i test('runs onPress once for a click without a touch after a touch the press gesture saw cancelled', async () => { const handlers = createPressHandlers(); - await renderWithKeyboardUp(handlers); + await renderStatefulWithKeyboardUp(handlers); cancelTouchOnStatefulPressGesture(BUTTON_CENTER_TOUCH); statefulDriver.fireNativeGesture(CLICK_WITHOUT_TOUCH); @@ -990,7 +1006,7 @@ describe('StatefulPressable on Android with the keyboard up for a React Native i test('runs onPress once for a touch press after the keyboard hides, after a touch the press gesture saw cancelled', async () => { const handlers = createPressHandlers(); - const addListenerSpy = await renderWithKeyboardUp(handlers); + const addListenerSpy = await renderStatefulWithKeyboardUp(handlers); cancelTouchOnStatefulPressGesture(BUTTON_CENTER_TOUCH); emitKeyboardEvent(addListenerSpy, 'keyboardDidHide'); @@ -1004,3 +1020,43 @@ describe('StatefulPressable on Android with the keyboard up for a React Native i expect(handlers.onPress).toHaveBeenCalledTimes(1); }); }); + +// Each target is rendered where the keyboard drops a phone's tap; on tvOS the focus engine drives every press, so none is a tap. +describe('on tvOS with the keyboard up for a React Native input', () => { + const switchToTvOS = () => { + jest.replaceProperty(Platform, 'OS', 'ios'); + jest.replaceProperty(Platform, 'isTV', true); + }; + + afterEach(() => { + setKeyboardVisibility(false); + }); + + test('StatefulPressable presses a tvOS press that reports a pointer', async () => { + const handlers = createPressHandlers(); + await renderWithKeyboardUp((container) => + statefulDriver.render(handlers, container) + ); + switchToTvOS(); + + statefulDriver.fireNativeGesture([BEGAN, ACTIVATED, ENDED].map(withTouch)); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); + + test('Pressable presses a tvOS press that reports a pointer', async () => { + const handlers = createPressHandlers(); + await renderWithKeyboardUp((container) => + renderDefaultPressable(handlers, container) + ); + switchToTvOS(); + + pressButton(buttonEvent(1, PointerType.TOUCH)); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + expect(handlers.onPressOut).toHaveBeenCalledTimes(1); + expect(handlers.onPress).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx index 2ce7c8954b..c3daf7756f 100644 --- a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx @@ -173,10 +173,10 @@ const StatefulPressable = (props: PressableProps) => { }, [cancelDelayedPress, cancelLongPress]); const captureKeyboardDismiss = useCallback( - (numberOfPointers: number) => { + (press: { numberOfPointers: number }) => { dropKeyboardTapRef.current ??= isKeyboardDismissingTap( jsResponderContext, - numberOfPointers + press ); }, [jsResponderContext] @@ -307,7 +307,7 @@ const StatefulPressable = (props: PressableProps) => { maxDistance: INT32_MAX, // Stops long press from cancelling on touch move cancelsTouchesInView: false, onTouchesDown: (event) => { - captureKeyboardDismiss(event.numberOfTouches); + captureKeyboardDismiss({ numberOfPointers: event.numberOfTouches }); if (dropKeyboardTapRef.current) { return; @@ -366,17 +366,17 @@ const StatefulPressable = (props: PressableProps) => { }, onBegin: (event) => { isPressWithoutPointer.current = isActivatedWithoutPointer(event); - captureKeyboardDismiss(event.numberOfPointers); + captureKeyboardDismiss(event); if (dropKeyboardTapRef.current) { return; } if (isPressWithoutPointer.current) { - // A TV drives this native gesture from the focus-engine Select press, and Android from a key - // or an accessibility service with no pointer. The press state machine is touch-based and never - // receives LONG_PRESS_TOUCHES_DOWN here, so bypass it and drive the press handlers directly. - // The hit-slop bounds check tests where a pointer went down, and a press with no pointer has none. + // Android drives this native gesture from a key or an accessibility service with no pointer, iOS from a + // press no touch drives, and tvOS from the focus-engine Select press. The press state machine is + // touch-based and never receives LONG_PRESS_TOUCHES_DOWN here, so bypass it and drive the press handlers + // directly. The hit-slop bounds check tests where a pointer went down, and a press with no pointer has none. handlePressIn(gestureToPressableEvent(event), true); return; } diff --git a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx index 226323f334..bde6c1b11c 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx @@ -129,7 +129,7 @@ export const Touchable = (props: TouchableProps) => { // overwrite it after the keyboard is already dismissed. dropKeyboardTapRef.current ??= isKeyboardDismissingTap( jsResponderContext, - e.nativeEvent.numberOfPointers + e.nativeEvent ); if (dropKeyboardTapRef.current) { diff --git a/packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts b/packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts index a890fafb48..0ae0c7230b 100644 --- a/packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts +++ b/packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts @@ -1,6 +1,8 @@ import * as React from 'react'; import { TextInput } from 'react-native'; +import { isActivatedWithoutPointer } from '../components/Pressable/utils'; + export type KeyboardShouldPersistTaps = | boolean | 'always' @@ -41,10 +43,10 @@ export function setKeyboardVisibility(visible: boolean) { export function isKeyboardDismissingTap( jsResponderContext: JSResponderContextValue | null | undefined, - numberOfPointers: number + press: { numberOfPointers: number } ): boolean { - // Only a tap dismisses the keyboard; a key or an accessibility service presses without a pointer. - if (jsResponderContext == null || numberOfPointers === 0) { + // Only a tap dismisses the keyboard, and a press no pointer drives is none. + if (jsResponderContext == null || isActivatedWithoutPointer(press)) { return false; }