diff --git a/.argent/flows/keyboard-activation-test.yaml b/.argent/flows/keyboard-activation-test.yaml new file mode 100644 index 0000000000..2671820648 --- /dev/null +++ b/.argent/flows/keyboard-activation-test.yaml @@ -0,0 +1,191 @@ +# 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 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: + - 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, 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" + - 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: { visible: { text: { matches: '^10\. \[touch activated button\] onActivate TOUCH with 1 pointers at its touch$' } } } + - 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: '^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: '^20\. ' } } } + + - 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: "Tab to the RawButton its touch activates 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: '^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" } + - 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: '^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" } + - tap: { id: "close-console-button" } + - 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: '^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" } + - 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: '^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/.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..a93b97c9cf --- /dev/null +++ b/apps/common-app/src/new_api/tests/keyboardActivation/index.tsx @@ -0,0 +1,291 @@ +import React, { useRef } from 'react'; +import { + Pressable as ReactNativePressable, + StyleSheet, + Text, + View, +} from 'react-native'; +import { + GestureDetector, + GestureStateManager, + LegacyPressable, + PointerType, + Pressable, + RawButton, + RectButton, + ScrollView, + TextInput, + Touchable, + useLongPressGesture, +} from 'react-native-gesture-handler'; +import { useSharedValue } from 'react-native-reanimated'; + +import { COLORS, useIndexedLogger } from '../../../common'; + +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`), + 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); + const touchOnTouchActivatedButton = useSharedValue({ x: 0, y: 0 }); + const touchActivatedLongPress = useLongPressGesture({ + onTouchesDown: (event) => { + 'worklet'; + GestureStateManager.activate(event.handlerTag); + }, + }); + + 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')} + /> + + + + + + {/* Its touch activates it from a UI-thread onTouchesDown, before the button reads that touch. */} + { + '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${describeActivationPoint(event, touchOnTouchActivatedButton.value)}` + ); + }} + /> + + {/* A tap here while the input holds the soft keyboard only dismisses it; a key press is no such tap. */} + + + + + + + + {/* A gesture its own touch activates takes that touch from React Native's Pressable, the first touch after mount included. */} + + + + + ); +} + +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, + }, + touchActivatedButton: { + width: 200, + height: 32, + borderRadius: 8, + backgroundColor: COLORS.KINDA_GREEN, + }, + 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, + }, + reactNativeButton: { + width: 200, + height: 32, + borderRadius: 8, + backgroundColor: COLORS.NAVY, + }, +}); 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..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. 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 64e8bfd25d..07b24e6a1d 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,40 @@ 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 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 + +```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 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 + +```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. + + +:::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 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 2f1a12b83a..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 @@ -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 @@ -387,18 +392,62 @@ 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 + + try { + 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) + } + } finally { + 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 @@ -411,36 +460,22 @@ 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 + } - 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. + 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(sourceEvent) - } - - if (sourceEvent.isHoverAction()) { - onHandleHover(adaptedTransformedEvent, adaptedSourceEvent) - } else { - onHandle(adaptedTransformedEvent, adaptedSourceEvent) - } - if (adaptedTransformedEvent != transformedEvent) { - adaptedTransformedEvent.recycle() - } - if (adaptedSourceEvent != sourceEvent) { - adaptedSourceEvent.recycle() + setPointerType(adaptedSourceEvent) } } @@ -527,6 +562,14 @@ 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 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) } else if (event.actionMasked == MotionEvent.ACTION_UP || @@ -875,10 +918,21 @@ open class GestureHandler { onReset() } - fun withMarkedAsInBounds(closure: () -> Unit) { + // 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 inside the view, at its visible center. + fun prepareActivationWithoutPointer(pointerType: Int) { + isActivatedWithoutPointer = true isWithinBounds = true - closure() - isWithinBounds = false + 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) { @@ -1044,6 +1098,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 +1119,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..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 @@ -267,7 +267,8 @@ class GestureHandlerOrchestrator( return } - if (handler.cancelsJSResponder) { + // 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) } @@ -538,16 +539,31 @@ 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 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() + } + + 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) - - if (!handler.shouldBeginWithRecordedHandlers(gestureHandlers)) { - handler.cancel() - } - - gestureHandlers.add(handler) } private fun isViewOverflowingParent(view: View): Boolean { @@ -589,8 +605,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) } } } @@ -654,8 +669,7 @@ class GestureHandlerOrchestrator( continue } - recordHandlerIfNotPresent(handler, view) - handler.startTrackingPointer(pointerId) + recordHandlerForPointer(handler, view, pointerId) found = true } } @@ -691,8 +705,7 @@ class GestureHandlerOrchestrator( continue } - recordHandlerIfNotPresent(handler, view) - handler.startTrackingPointer(pointerId) + recordHandlerForPointer(handler, view, pointerId) found = true } } @@ -946,21 +959,77 @@ 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) { - handlerRegistry.getHandlersForView(view)?.forEach { - if (it !is NativeViewGestureHandler) { - return@forEach + /** 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 = + 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 = + withHandlersReadyForActivationWithoutPointer(view) { handlers -> + var activated = false + for (handler in handlers) { + recordHandlerIfNotPresent(handler, view) + handler.prepareActivationWithoutPointer(pointerType) + handler.begin() + 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) + } } - this.recordHandlerIfNotPresent(it, view) - it.withMarkedAsInBounds { - it.begin() - it.activate() - it.end() + if (handlers.any { isNativeHandlerTrackingPointer(it) }) { + handlers.clear() + } else { + handlers.retainAll { canBeginWithoutPointer(it) } } + + return block(handlers) + } finally { + recycleHandlerList(handlers) } } + // 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/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/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..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 @@ -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() @@ -922,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) { @@ -1492,7 +1502,13 @@ class RNGestureHandlerButtonViewManager : } override fun drawableHotspotChanged(x: Float, y: Float) { - if (touchResponder == null || touchResponder === this) { + 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) @@ -1570,35 +1586,48 @@ 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) { + 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 orchestrator.activateNativeHandlersForView(this, source.pointerType) && super.performClick() + } + + return if (soundResponder === this) { + tryFreeingResponder() + soundResponder = null + super.performClick() } else { false } } + private fun findOrchestrator() = RNGestureHandlerRootView.findGestureHandlerRootView(this)?.orchestrator + override fun setEnabled(enabled: Boolean) { val changed = enabled != isEnabled super.setEnabled(enabled) @@ -1627,14 +1656,31 @@ class RNGestureHandlerButtonViewManager : } } + // 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() = !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) { + 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 + // 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 +1748,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..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,10 +194,6 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: return shouldIntercept } - fun activateNativeHandlers(view: View) { - orchestrator?.activateNativeHandlersForView(view) - } - 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 297c60740b..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,10 +84,6 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { super.requestDisallowInterceptTouchEvent(disallowIntercept) } - fun activateNativeHandlers(view: View) { - rootHelper?.activateNativeHandlers(view) - } - fun onNativeTouchCancelled() { rootHelper?.onNativeTouchCancelled() } 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__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index 14e83c7750..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 @@ -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 TAP = { numberOfPointers: 1 }; + // The drop requires a focused RN TextInput to blur. const focusInput = () => jest @@ -497,21 +510,25 @@ describe('[API v3] Components', () => { await act(flushImmediate); // Keyboard not shown yet -> nothing to dismiss. - expect(isKeyboardDismissingTap(makeContext('never'))).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'))).toBe(true); - expect(isKeyboardDismissingTap(makeContext(undefined))).toBe(true); - expect(isKeyboardDismissingTap(makeContext('handled'))).toBe(false); - expect(isKeyboardDismissingTap(makeContext('always'))).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)).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'), { numberOfPointers: 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'), TAP)).toBe(true); addListenerSpy.mockRestore(); focusSpy.mockRestore(); @@ -532,12 +549,12 @@ describe('[API v3] Components', () => { showKeyboard(addListenerSpy); expect(TextInput.State.currentlyFocusedInput()).toBeNull(); - expect(isKeyboardDismissingTap(makeContext('never'))).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'))).toBe(true); + expect(isKeyboardDismissingTap(makeContext('never'), TAP)).toBe(true); addListenerSpy.mockRestore(); focusSpy.mockRestore(); @@ -556,7 +573,7 @@ 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'), TAP)).toBe(false); addListenerSpy.mockRestore(); }); @@ -606,6 +623,47 @@ 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'), 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'); + 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..a907c24d1b --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/pressableClickWithoutTouch.test.tsx @@ -0,0 +1,1062 @@ +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) +); +// 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 = { + 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 }; + +// 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 = { + 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(); + }); + + 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 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. +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, + container: PressableContainer = renderAlone +) => + renderSettled( + + {container( + + 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.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 driver.render(handlers); + layOutButton(); + + driver.fireNativeGesture(CLICK_WITHOUT_TOUCH); + + expectPressedOnceAt(handlers, PRESSED_WHERE_NATIVE_GESTURE_REPORTS); + }); + + 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 driver.render(handlers); + layOutButton(); + + driver.fireNativeGesture(APPLE_PRESS_WITHOUT_TOUCH); + + expectPressedOnceAt(handlers, PRESSED_WHERE_NATIVE_GESTURE_REPORTS); + }); + + 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(APPLE_PRESS_WITHOUT_TOUCH); + + expect(handlers.onPressIn).toHaveBeenCalledTimes(1); + 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', () => { + 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); + }); + + 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(); + }); +}); + +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(); + }); + + // 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. +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'); + 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 +) => { + 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, + }); + }); +}; + +// 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 renderStatefulWithKeyboardUp = (handlers: PressHandlers) => + renderWithKeyboardUp((container) => + statefulDriver.render(handlers, container) + ); + + afterEach(() => { + setKeyboardVisibility(false); + }); + + test('drops a touch press, which is the tap dismissing the keyboard', async () => { + const handlers = createPressHandlers(); + await renderStatefulWithKeyboardUp(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 renderStatefulWithKeyboardUp(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 renderStatefulWithKeyboardUp(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 renderStatefulWithKeyboardUp(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 renderStatefulWithKeyboardUp(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); + }); +}); + +// 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/components/Pressable/Pressable.tsx b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx index cdd52cda0f..7bb0bbd68e 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, @@ -84,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( @@ -148,14 +151,16 @@ const LegacyPressable = (props: LegacyPressableProps) => { const handleFinalize = useCallback(() => { isCurrentlyPressed.current = false; + isPressWithoutPointer.current = false; cancelLongPress(); cancelDelayedPress(); setPressedState(false); }, [cancelDelayedPress, cancelLongPress]); const handlePressIn = useCallback( - (event: PressableEvent) => { + (event: PressableEvent, skipBoundsCheck = false) => { if ( + !skipBoundsCheck && !isTouchWithinInset( dimensions.current, normalizedHitSlop, @@ -302,7 +307,14 @@ const LegacyPressable = (props: LegacyPressableProps) => { handlePressOut(pressableEvent, false); } }) - .onBegin(() => { + .onBegin((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); + return; + } if (Platform.OS === 'android' && isScreenReaderEnabled) { stateMachine.handleEvent( StateMachineEvent.NATIVE_BEGIN, @@ -318,7 +330,13 @@ const LegacyPressable = (props: LegacyPressableProps) => { stateMachine.handleEvent(StateMachineEvent.NATIVE_START); } }) - .onFinalize((_event, success) => { + .onFinalize((event, success) => { + if (isPressWithoutPointer.current) { + 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 +352,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..13a4ea1dbc 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,17 @@ const viewCenterToPressableEvent = ( }; }; +// 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, 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..f404f660ee 100644 --- a/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts +++ b/packages/react-native-gesture-handler/src/handlers/GestureHandlerEventPayload.ts @@ -67,6 +67,34 @@ export type NativeViewGestureHandlerPayload = { * True if gesture was performed inside of containing view, false otherwise. */ pointerInside: boolean; + + /** + * X coordinate, expressed in points, of the pointer relative to the view + * 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 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; + + /** + * X coordinate, expressed in points, of the same point relative to the + * window. + */ + absoluteX: number; + + /** + * Y coordinate, expressed in points, 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/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 bfbf25608e..c3daf7756f 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, @@ -91,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, @@ -162,15 +165,22 @@ const StatefulPressable = (props: PressableProps) => { const handleFinalize = useCallback(() => { isCurrentlyPressed.current = false; + isPressWithoutPointer.current = false; dropKeyboardTapRef.current = null; cancelLongPress(); cancelDelayedPress(); setPressedState(false); }, [cancelDelayedPress, cancelLongPress]); - const captureKeyboardDismiss = useCallback(() => { - dropKeyboardTapRef.current ??= isKeyboardDismissingTap(jsResponderContext); - }, [jsResponderContext]); + const captureKeyboardDismiss = useCallback( + (press: { numberOfPointers: number }) => { + dropKeyboardTapRef.current ??= isKeyboardDismissingTap( + jsResponderContext, + press + ); + }, + [jsResponderContext] + ); const handlePressIn = useCallback( (event: PressableEvent, skipBoundsCheck = false) => { @@ -297,7 +307,7 @@ const StatefulPressable = (props: PressableProps) => { maxDistance: INT32_MAX, // Stops long press from cancelling on touch move cancelsTouchesInView: false, onTouchesDown: (event) => { - captureKeyboardDismiss(); + captureKeyboardDismiss({ numberOfPointers: event.numberOfTouches }); if (dropKeyboardTapRef.current) { return; @@ -322,6 +332,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 +364,20 @@ const StatefulPressable = (props: PressableProps) => { handlePressOut(pressableEvent, false); } }, - onBegin: () => { - captureKeyboardDismiss(); + onBegin: (event) => { + isPressWithoutPointer.current = isActivatedWithoutPointer(event); + captureKeyboardDismiss(event); 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 - // 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); + if (isPressWithoutPointer.current) { + // 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; } if (Platform.OS === 'android' && isScreenReaderEnabled) { @@ -387,11 +401,8 @@ const StatefulPressable = (props: PressableProps) => { return; } - if (Platform.isTV) { - handlePressOut( - viewCenterToPressableEvent(dimensions.current), - !event.canceled - ); + if (isPressWithoutPointer.current) { + handlePressOut(gestureToPressableEvent(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..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 @@ -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 + ); 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..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' @@ -40,9 +42,11 @@ export function setKeyboardVisibility(visible: boolean) { } export function isKeyboardDismissingTap( - jsResponderContext: JSResponderContextValue | null | undefined + jsResponderContext: JSResponderContextValue | null | undefined, + press: { numberOfPointers: number } ): boolean { - if (jsResponderContext == null) { + // Only a tap dismisses the keyboard, and a press no pointer drives is none. + if (jsResponderContext == null || isActivatedWithoutPointer(press)) { return false; }