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 26010ddd4b..ccfd163fdb 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 @@ -256,14 +256,14 @@ describe('[API v3] Components', () => { await act(flushImmediate); - const nativeDetector = getNativeDetector(UNSAFE_getAllByType); const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + const button = screen.getByTestId('pressable'); expect(scrollViewResponder).toBeDefined(); expect( scrollViewResponder?.props.onStartShouldSetResponderCapture() ).toBe(false); - expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); + expect(button.props.onStartShouldSetResponderCapture()).toBe(false); expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( false @@ -281,13 +281,13 @@ describe('[API v3] Components', () => { await act(flushImmediate); - const nativeDetector = getNativeDetector(UNSAFE_getAllByType); const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + const button = screen.getByTestId('pressable'); // Outside of 'handled' mode the logical responder view is not rendered // at all — the responder event can never be claimed on behalf of RNGH. expect(scrollViewResponder).toBeUndefined(); - expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); + expect(button.props.onStartShouldSetResponderCapture()).toBe(false); }); test('handles responder event passed through NativeDetector for keyboardShouldPersistTaps handled', async () => { diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx index 7938d8629d..3e56aebc44 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -1,452 +1,32 @@ -import React, { - use, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import type { - Insets, - LayoutChangeEvent, - StyleProp, - ViewStyle, -} from 'react-native'; -import { Platform } from 'react-native'; - -import type { - PressableDimensions, - PressableEvent, - PressableProps, -} from '../../components/Pressable/PressableProps'; -import { - getStatesConfig, - StateMachineEvent, -} from '../../components/Pressable/stateDefinitions'; -import { PressableStateMachine } from '../../components/Pressable/StateMachine'; -import { - addInsets, - gestureToPressableEvent, - gestureTouchToPressableEvent, - isTouchWithinInset, - numberAsInset, - viewCenterToPressableEvent, -} from '../../components/Pressable/utils'; -import { getTVProps } from '../../components/utils'; -import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; -import { useIsScreenReaderEnabled } from '../../useIsScreenReaderEnabled'; -import { INT32_MAX, isTestEnv } from '../../utils'; -import { GestureDetector } from '../detectors'; -import { - useHoverGesture, - useLongPressGesture, - useNativeGesture, - useSimultaneousGestures, -} from '../hooks'; -import { - isKeyboardDismissingTap, - JSResponderContext, -} from '../scrollViewInterop'; -import { PureNativeButton } from './GestureButtons'; - -const DEFAULT_LONG_PRESS_DURATION = 500; -const IS_TEST_ENV = isTestEnv(); - +import React from 'react'; + +import type { PressableProps } from '../../components/Pressable/PressableProps'; +import PressableWithTouchable from './PressableWithTouchable'; +import StatefulPressable from './StatefulPressable'; + +/** + * `Pressable` dispatches between two implementations: + * + * - {@link StatefulPressable} — the state-machine engine, used whenever any of + * the `simultaneousWith` / `requireToFail` / `block` relation props is passed, + * since coordinating the press with an external gesture needs the composed + * gesture recognizers. + * - {@link PressableWithTouchable} — the simpler engine built on the native + * button `Touchable`, used for everything else (the common case). + * + * The choice is re-evaluated each render: toggling a relation prop at runtime + * swaps engines, which remounts and drops any in-progress press. + */ const Pressable = (props: PressableProps) => { - const { - testOnly_pressed, - hitSlop, - pressRetentionOffset, - delayHoverIn, - delayHoverOut, - delayLongPress, - unstable_pressDelay, - onHoverIn, - onHoverOut, - onPress, - onPressIn, - onPressOut, - onLongPress, - onLayout, - style, - children, - android_disableSound, - android_ripple, - disabled, - accessible, - simultaneousWith, - requireToFail, - block, - ref, - ...remainingProps - } = props; - - const [pressedState, setPressedState] = useState(testOnly_pressed ?? false); - - const longPressTimeoutRef = useRef(null); - const pressDelayTimeoutRef = useRef(null); - const isOnPressAllowed = useRef(true); - const jsResponderContext = use(JSResponderContext); - const isCurrentlyPressed = useRef(false); - const dimensions = useRef({ - width: 0, - height: 0, - }); - - // When the touch that begins a press is the one dismissing the keyboard - // (keyboardShouldPersistTaps="never"), the press is swallowed to match RN's - // touchables. - const dropKeyboardTapRef = useRef(null); - - const normalizedHitSlop: Insets = useMemo( - () => - typeof hitSlop === 'number' - ? numberAsInset(hitSlop) - : (hitSlop ?? numberAsInset(0)), - [hitSlop] - ); - const normalizedPressRetentionOffset: Insets = useMemo( - () => - typeof pressRetentionOffset === 'number' - ? numberAsInset(pressRetentionOffset) - : (pressRetentionOffset ?? {}), - [pressRetentionOffset] - ); - const appliedHitSlop = addInsets( - normalizedHitSlop, - normalizedPressRetentionOffset - ); - - const cancelLongPress = useCallback(() => { - if (longPressTimeoutRef.current) { - clearTimeout(longPressTimeoutRef.current); - longPressTimeoutRef.current = null; - isOnPressAllowed.current = true; - } - }, []); - - const cancelDelayedPress = useCallback(() => { - if (pressDelayTimeoutRef.current) { - clearTimeout(pressDelayTimeoutRef.current); - pressDelayTimeoutRef.current = null; - } - }, []); - - const startLongPress = useCallback( - (event: PressableEvent) => { - if (onLongPress) { - cancelLongPress(); - longPressTimeoutRef.current = setTimeout(() => { - isOnPressAllowed.current = false; - onLongPress(event); - }, delayLongPress ?? DEFAULT_LONG_PRESS_DURATION); - } - }, - [onLongPress, cancelLongPress, delayLongPress] - ); - const innerHandlePressIn = useCallback( - (event: PressableEvent) => { - onPressIn?.(event); - startLongPress(event); - setPressedState(true); - if (pressDelayTimeoutRef.current) { - clearTimeout(pressDelayTimeoutRef.current); - pressDelayTimeoutRef.current = null; - } - }, - [onPressIn, startLongPress] - ); - - const handleFinalize = useCallback(() => { - isCurrentlyPressed.current = false; - dropKeyboardTapRef.current = null; - cancelLongPress(); - cancelDelayedPress(); - setPressedState(false); - }, [cancelDelayedPress, cancelLongPress]); - - const captureKeyboardDismiss = useCallback(() => { - dropKeyboardTapRef.current ??= isKeyboardDismissingTap(jsResponderContext); - }, [jsResponderContext]); - - const handlePressIn = useCallback( - (event: PressableEvent, skipBoundsCheck = false) => { - if ( - !skipBoundsCheck && - !isTouchWithinInset( - dimensions.current, - normalizedHitSlop, - event.nativeEvent.changedTouches.at(-1) - ) - ) { - // Ignoring pressIn within pressRetentionOffset - return; - } - - isCurrentlyPressed.current = true; - if (unstable_pressDelay) { - pressDelayTimeoutRef.current = setTimeout(() => { - innerHandlePressIn(event); - }, unstable_pressDelay); - } else { - innerHandlePressIn(event); - } - }, - [innerHandlePressIn, normalizedHitSlop, unstable_pressDelay] - ); - - const handlePressOut = useCallback( - (event: PressableEvent, success: boolean = true) => { - if (!isCurrentlyPressed.current) { - // Some prop configurations may lead to handlePressOut being called mutliple times. - return; - } - - isCurrentlyPressed.current = false; - - if (pressDelayTimeoutRef.current) { - innerHandlePressIn(event); - } - - onPressOut?.(event); - - if (isOnPressAllowed.current && success) { - onPress?.(event); - } - - handleFinalize(); - }, - [handleFinalize, innerHandlePressIn, onPress, onPressOut] - ); - - const stateMachine = useMemo(() => new PressableStateMachine(), []); - const isScreenReaderEnabled = useIsScreenReaderEnabled(); - - useEffect(() => { - const configuration = getStatesConfig( - handlePressIn, - handlePressOut, - isScreenReaderEnabled - ); - stateMachine.setStates(configuration); - }, [handlePressIn, handlePressOut, stateMachine, isScreenReaderEnabled]); - - const hoverInTimeout = useRef(null); - const hoverOutTimeout = useRef(null); - - const hoverGesture = useHoverGesture({ - manualActivation: true, // Prevents Hover blocking Native gesture on web - cancelsTouchesInView: false, - onBegin: (event) => { - if (hoverOutTimeout.current) { - clearTimeout(hoverOutTimeout.current); - } - if (delayHoverIn) { - hoverInTimeout.current = setTimeout( - () => onHoverIn?.(gestureToPressableEvent(event)), - delayHoverIn - ); - return; - } - onHoverIn?.(gestureToPressableEvent(event)); - }, - onFinalize: (event) => { - if (hoverInTimeout.current) { - clearTimeout(hoverInTimeout.current); - } - if (delayHoverOut) { - hoverOutTimeout.current = setTimeout( - () => onHoverOut?.(gestureToPressableEvent(event)), - delayHoverOut - ); - return; - } - onHoverOut?.(gestureToPressableEvent(event)); - }, - enabled: disabled !== true, - disableReanimated: true, - simultaneousWith, - block, - requireToFail, - hitSlop: appliedHitSlop, - }); - - const pressAndTouchGesture = useLongPressGesture({ - minDuration: Platform.OS === 'web' ? 0 : INT32_MAX, // Long press handles finalize on web, thus it must activate right away - maxDistance: INT32_MAX, // Stops long press from cancelling on touch move - cancelsTouchesInView: false, - onTouchesDown: (event) => { - captureKeyboardDismiss(); - - if (dropKeyboardTapRef.current) { - return; - } - - const pressableEvent = gestureTouchToPressableEvent(event); - stateMachine.handleEvent( - StateMachineEvent.LONG_PRESS_TOUCHES_DOWN, - pressableEvent - ); - }, - onTouchesUp: () => { - if (Platform.OS === 'android' && !isScreenReaderEnabled) { - // Prevents potential soft-locks - stateMachine.reset(); - handleFinalize(); - } - }, - onTouchesCancel: (event) => { - const pressableEvent = gestureTouchToPressableEvent(event); - stateMachine.reset(); - handlePressOut(pressableEvent, false); - }, - onFinalize: (event) => { - if (Platform.OS !== 'web') { - return; - } - - stateMachine.handleEvent( - event.canceled ? StateMachineEvent.CANCEL : StateMachineEvent.FINALIZE - ); - - handleFinalize(); - }, - enabled: disabled !== true, - disableReanimated: true, - simultaneousWith: simultaneousWith, - block: block, - requireToFail: requireToFail, - hitSlop: appliedHitSlop, - }); - - // RNButton is placed inside ButtonGesture to enable Android's ripple and to capture non-propagating events - const buttonGesture = useNativeGesture({ - onTouchesCancel: (event) => { - if (Platform.OS !== 'macos' && Platform.OS !== 'web') { - // On MacOS cancel occurs in middle of gesture - // On Web cancel occurs on mouse move, which is unwanted - const pressableEvent = gestureTouchToPressableEvent(event); - stateMachine.reset(); - handlePressOut(pressableEvent, false); - } - }, - onBegin: () => { - captureKeyboardDismiss(); - - 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); - return; - } - if (Platform.OS === 'android' && isScreenReaderEnabled) { - stateMachine.handleEvent( - StateMachineEvent.NATIVE_BEGIN, - viewCenterToPressableEvent(dimensions.current) - ); - return; - } - stateMachine.handleEvent(StateMachineEvent.NATIVE_BEGIN); - }, - onActivate: () => { - if (!Platform.isTV && Platform.OS !== 'android') { - stateMachine.handleEvent(StateMachineEvent.NATIVE_START); - } - }, - onFinalize: (event) => { - // On Web we use LongPress.onFinalize instead of Native.onFinalize, - // as Native cancels on mouse move, and LongPress does not. - if (Platform.OS === 'web') { - return; - } - - if (Platform.isTV) { - handlePressOut( - viewCenterToPressableEvent(dimensions.current), - !event.canceled - ); - handleFinalize(); - return; - } - - stateMachine.handleEvent( - event.canceled ? StateMachineEvent.CANCEL : StateMachineEvent.FINALIZE - ); - - handleFinalize(); - }, - enabled: disabled !== true, - disableReanimated: true, - simultaneousWith, - block, - requireToFail, - hitSlop: appliedHitSlop, - shouldActivateOnStart: Platform.OS === 'web', - }); - - const gesture = useSimultaneousGestures( - buttonGesture, - pressAndTouchGesture, - hoverGesture - ); - - // `cursor: 'pointer'` on `RNButton` crashes iOS - const pointerStyle: StyleProp = - Platform.OS === 'web' ? { cursor: 'pointer' } : {}; - - const styleProp = - typeof style === 'function' ? style({ pressed: pressedState }) : style; - - const childrenProp = - typeof children === 'function' - ? children({ pressed: pressedState }) - : children; - - const rippleColor = useMemo(() => { - const defaultRippleColor = android_ripple ? undefined : 'transparent'; - return android_ripple?.color ?? defaultRippleColor; - }, [android_ripple]); - - const setDimensions = useCallback( - (event: LayoutChangeEvent) => { - onLayout?.(event); - dimensions.current = event.nativeEvent.layout; - }, - [onLayout] - ); - - const tvProps = getTVProps(remainingProps); - - return ( - - >} - {...tvProps} - onLayout={setDimensions} - accessible={accessible !== false} - hitSlop={appliedHitSlop} - enabled={disabled !== true} - touchSoundDisabled={android_disableSound ?? undefined} - rippleColor={rippleColor} - rippleRadius={android_ripple?.radius ?? undefined} - style={[pointerStyle, styleProp]} - testOnly_onPress={IS_TEST_ENV ? onPress : undefined} - testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined} - testOnly_onPressOut={IS_TEST_ENV ? onPressOut : undefined} - testOnly_onLongPress={IS_TEST_ENV ? onLongPress : undefined}> - {childrenProp} - {__DEV__ ? ( - - ) : null} - - + const usesRelations = + props.simultaneousWith != null || + props.requireToFail != null || + props.block != null; + + return usesRelations ? ( + + ) : ( + ); }; diff --git a/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx new file mode 100644 index 0000000000..e80c389670 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/PressableWithTouchable.tsx @@ -0,0 +1,319 @@ +import React, { useEffect, useRef, useState } from 'react'; +import type { Insets, LayoutChangeEvent } from 'react-native'; + +import type { ButtonEvent } from '../../components/GestureHandlerButton'; +import type { + InnerPressableEvent, + PressableDimensions, + PressableProps, +} from '../../components/Pressable/PressableProps'; +import { + addInsets, + isTouchWithinInset, + numberAsInset, +} from '../../components/Pressable/utils'; +import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; +import { pointerStyle } from './pointerStyle'; +import { Touchable } from './Touchable/Touchable'; + +// RN's Pressable default. Touchable's own default is 600ms +const DEFAULT_LONG_PRESS_DURATION = 500; + +type Timers = { + press: ReturnType | null; + hoverIn: ReturnType | null; + hoverOut: ReturnType | null; +}; + +function normalizeInset(value: Insets | number | null | undefined): Insets { + return typeof value === 'number' + ? numberAsInset(value) + : (value ?? numberAsInset(0)); +} + +function buttonToInner(event: ButtonEvent): InnerPressableEvent { + return { + identifier: 0, + locationX: event.x, + locationY: event.y, + pageX: event.absoluteX, + pageY: event.absoluteY, + target: 0, + timestamp: Date.now(), + touches: [], + changedTouches: [], + force: undefined, + }; +} + +function buttonToPressableEvent(event: ButtonEvent) { + const inner = buttonToInner(event); + + return { + nativeEvent: { + ...inner, + touches: [inner], + changedTouches: [inner], + }, + }; +} + +const PressableWithTouchable = (props: PressableProps) => { + const { + testOnly_pressed, + hitSlop, + pressRetentionOffset, + delayHoverIn, + delayHoverOut, + delayLongPress, + unstable_pressDelay, + onHoverIn, + onHoverOut, + onPress, + onPressIn, + onPressOut, + onLongPress, + onLayout, + style, + children, + android_disableSound, + android_ripple, + disabled, + accessible, + ref, + ...rest + } = props; + + // Drop props Touchable doesn't take: `cancelable`/`dimensionsAfterResize` are + // unsupported; the relation props are handled by the wrapper. + // + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + cancelable, + dimensionsAfterResize, + simultaneousWith, + requireToFail, + block, + ...remainingProps + } = rest; + /* eslint-enable @typescript-eslint/no-unused-vars */ + + const [pressed, setPressed] = useState(testOnly_pressed ?? false); + const timers = useRef({ press: null, hoverIn: null, hoverOut: null }); + const dimensions = useRef({ width: 0, height: 0 }); + + // Whether the in-progress press activated within hitSlop (see handlePressIn). + const isActive = useRef(false); + + useEffect( + () => () => { + const pending = timers.current; + + if (pending.press) { + clearTimeout(pending.press); + } + + if (pending.hoverIn) { + clearTimeout(pending.hoverIn); + } + + if (pending.hoverOut) { + clearTimeout(pending.hoverOut); + } + }, + [] + ); + + // RN's ripple config allows `null` on every field; Touchable's excludes it. + // Normalize `null` → `undefined` so the types line up. + const androidRipple = android_ripple + ? { + color: android_ripple.color ?? undefined, + borderless: android_ripple.borderless ?? undefined, + radius: android_ripple.radius ?? undefined, + foreground: android_ripple.foreground ?? undefined, + } + : undefined; + + // Activation is gated to `normalizedHitSlop` (see handlePressIn); the native + // button gets the wider `appliedHitSlop` so an active press is retained out + // to hitSlop + pressRetentionOffset before it cancels. + const normalizedHitSlop = normalizeInset(hitSlop); + const appliedHitSlop = addInsets( + normalizedHitSlop, + normalizeInset(pressRetentionOffset) + ); + + // Long press is measured from onPressIn, which `unstable_pressDelay` defers, + // so fold it in to match RN / StatefulPressable. + const resolvedDelayLongPress = + (delayLongPress ?? DEFAULT_LONG_PRESS_DURATION) + + (unstable_pressDelay ?? 0); + + const handleLayout = (event: LayoutChangeEvent) => { + onLayout?.(event); + dimensions.current = event.nativeEvent.layout; + }; + + const firePressIn = (event: ButtonEvent) => { + setPressed(true); + onPressIn?.(buttonToPressableEvent(event)); + }; + + 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) + ); + + if (!isActive.current) { + return; + } + + if (unstable_pressDelay) { + // Drop a still-pending timer so a re-entrant press can't double-fire. + if (timers.current.press) { + clearTimeout(timers.current.press); + } + + timers.current.press = setTimeout(() => { + timers.current.press = null; + firePressIn(event); + }, unstable_pressDelay); + return; + } + + firePressIn(event); + }; + + const handlePressOut = (event: ButtonEvent) => { + // Not cleared here: onPress fires after onPressOut and must stay suppressed + // too; isActive resets on the next press-in. + if (!isActive.current) { + return; + } + + // If the touch is released before `unstable_pressDelay` elapses, RN still + // emits the deferred `onPressIn` before `onPressOut` — flush it now. + if (timers.current.press) { + clearTimeout(timers.current.press); + timers.current.press = null; + firePressIn(event); + } + + setPressed(false); + onPressOut?.(buttonToPressableEvent(event)); + }; + + const handlePress = onPress + ? (event: ButtonEvent) => { + if (isActive.current) { + onPress(buttonToPressableEvent(event)); + } + } + : undefined; + + const handleLongPress = onLongPress + ? (event: ButtonEvent) => { + if (!isActive.current) { + return; + } + // Flush the deferred onPressIn so it can't arrive after onLongPress + // (e.g. delayLongPress={0} makes the two timers coincide). + if (timers.current.press) { + clearTimeout(timers.current.press); + timers.current.press = null; + firePressIn(event); + } + onLongPress(buttonToPressableEvent(event)); + } + : undefined; + + // Wire each handler whenever the opposite side can leave a pending timer, so a + // delayed hover callback is cancelled once the pointer leaves/re-enters. + const needsHoverIn = + onHoverIn != null || (onHoverOut != null && !!delayHoverOut); + const needsHoverOut = + onHoverOut != null || (onHoverIn != null && !!delayHoverIn); + + const handleHoverIn = needsHoverIn + ? (event: ButtonEvent) => { + if (timers.current.hoverOut) { + clearTimeout(timers.current.hoverOut); + timers.current.hoverOut = null; + } + + if (!onHoverIn) { + return; + } + + if (delayHoverIn) { + timers.current.hoverIn = setTimeout(() => { + timers.current.hoverIn = null; + onHoverIn(buttonToPressableEvent(event)); + }, delayHoverIn); + return; + } + + onHoverIn(buttonToPressableEvent(event)); + } + : undefined; + + const handleHoverOut = needsHoverOut + ? (event: ButtonEvent) => { + if (timers.current.hoverIn) { + clearTimeout(timers.current.hoverIn); + timers.current.hoverIn = null; + } + + if (!onHoverOut) { + return; + } + + if (delayHoverOut) { + timers.current.hoverOut = setTimeout(() => { + timers.current.hoverOut = null; + onHoverOut(buttonToPressableEvent(event)); + }, delayHoverOut); + return; + } + + onHoverOut(buttonToPressableEvent(event)); + } + : undefined; + + const resolvedStyle = + typeof style === 'function' ? style({ pressed }) : style; + + const resolvedChildren = + typeof children === 'function' ? children({ pressed }) : children; + + return ( + + {resolvedChildren} + {__DEV__ ? ( + + ) : null} + + ); +}; + +export default PressableWithTouchable; diff --git a/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx new file mode 100644 index 0000000000..cc06618652 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/StatefulPressable.tsx @@ -0,0 +1,453 @@ +import React, { + use, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { + Insets, + LayoutChangeEvent, + StyleProp, + ViewStyle, +} from 'react-native'; +import { Platform } from 'react-native'; + +import type { + PressableDimensions, + PressableEvent, + PressableProps, +} from '../../components/Pressable/PressableProps'; +import { + getStatesConfig, + StateMachineEvent, +} from '../../components/Pressable/stateDefinitions'; +import { PressableStateMachine } from '../../components/Pressable/StateMachine'; +import { + addInsets, + gestureToPressableEvent, + gestureTouchToPressableEvent, + isTouchWithinInset, + numberAsInset, + viewCenterToPressableEvent, +} from '../../components/Pressable/utils'; +import { getTVProps } from '../../components/utils'; +import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; +import { useIsScreenReaderEnabled } from '../../useIsScreenReaderEnabled'; +import { INT32_MAX, isTestEnv } from '../../utils'; +import { GestureDetector } from '../detectors'; +import { + useHoverGesture, + useLongPressGesture, + useNativeGesture, + useSimultaneousGestures, +} from '../hooks'; +import { + isKeyboardDismissingTap, + JSResponderContext, +} from '../scrollViewInterop'; +import { PureNativeButton } from './GestureButtons'; + +const DEFAULT_LONG_PRESS_DURATION = 500; +const IS_TEST_ENV = isTestEnv(); + +const StatefulPressable = (props: PressableProps) => { + const { + testOnly_pressed, + hitSlop, + pressRetentionOffset, + delayHoverIn, + delayHoverOut, + delayLongPress, + unstable_pressDelay, + onHoverIn, + onHoverOut, + onPress, + onPressIn, + onPressOut, + onLongPress, + onLayout, + style, + children, + android_disableSound, + android_ripple, + disabled, + accessible, + simultaneousWith, + requireToFail, + block, + ref, + ...remainingProps + } = props; + + const [pressedState, setPressedState] = useState(testOnly_pressed ?? false); + + const longPressTimeoutRef = useRef(null); + const pressDelayTimeoutRef = useRef(null); + const isOnPressAllowed = useRef(true); + const jsResponderContext = use(JSResponderContext); + const isCurrentlyPressed = useRef(false); + const dimensions = useRef({ + width: 0, + height: 0, + }); + + // When the touch that begins a press is the one dismissing the keyboard + // (keyboardShouldPersistTaps="never"), the press is swallowed to match RN's + // touchables. + const dropKeyboardTapRef = useRef(null); + + const normalizedHitSlop: Insets = useMemo( + () => + typeof hitSlop === 'number' + ? numberAsInset(hitSlop) + : (hitSlop ?? numberAsInset(0)), + [hitSlop] + ); + const normalizedPressRetentionOffset: Insets = useMemo( + () => + typeof pressRetentionOffset === 'number' + ? numberAsInset(pressRetentionOffset) + : (pressRetentionOffset ?? {}), + [pressRetentionOffset] + ); + const appliedHitSlop = addInsets( + normalizedHitSlop, + normalizedPressRetentionOffset + ); + + const cancelLongPress = useCallback(() => { + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + longPressTimeoutRef.current = null; + isOnPressAllowed.current = true; + } + }, []); + + const cancelDelayedPress = useCallback(() => { + if (pressDelayTimeoutRef.current) { + clearTimeout(pressDelayTimeoutRef.current); + pressDelayTimeoutRef.current = null; + } + }, []); + + const startLongPress = useCallback( + (event: PressableEvent) => { + if (onLongPress) { + cancelLongPress(); + longPressTimeoutRef.current = setTimeout(() => { + isOnPressAllowed.current = false; + onLongPress(event); + }, delayLongPress ?? DEFAULT_LONG_PRESS_DURATION); + } + }, + [onLongPress, cancelLongPress, delayLongPress] + ); + const innerHandlePressIn = useCallback( + (event: PressableEvent) => { + onPressIn?.(event); + startLongPress(event); + setPressedState(true); + if (pressDelayTimeoutRef.current) { + clearTimeout(pressDelayTimeoutRef.current); + pressDelayTimeoutRef.current = null; + } + }, + [onPressIn, startLongPress] + ); + + const handleFinalize = useCallback(() => { + isCurrentlyPressed.current = false; + dropKeyboardTapRef.current = null; + cancelLongPress(); + cancelDelayedPress(); + setPressedState(false); + }, [cancelDelayedPress, cancelLongPress]); + + const captureKeyboardDismiss = useCallback(() => { + dropKeyboardTapRef.current ??= isKeyboardDismissingTap(jsResponderContext); + }, [jsResponderContext]); + + const handlePressIn = useCallback( + (event: PressableEvent, skipBoundsCheck = false) => { + if ( + !skipBoundsCheck && + !isTouchWithinInset( + dimensions.current, + normalizedHitSlop, + event.nativeEvent.changedTouches.at(-1) + ) + ) { + // Ignoring pressIn within pressRetentionOffset + return; + } + + isCurrentlyPressed.current = true; + if (unstable_pressDelay) { + pressDelayTimeoutRef.current = setTimeout(() => { + innerHandlePressIn(event); + }, unstable_pressDelay); + } else { + innerHandlePressIn(event); + } + }, + [innerHandlePressIn, normalizedHitSlop, unstable_pressDelay] + ); + + const handlePressOut = useCallback( + (event: PressableEvent, success: boolean = true) => { + if (!isCurrentlyPressed.current) { + // Some prop configurations may lead to handlePressOut being called multiple times. + return; + } + + isCurrentlyPressed.current = false; + + if (pressDelayTimeoutRef.current) { + innerHandlePressIn(event); + } + + onPressOut?.(event); + + if (isOnPressAllowed.current && success) { + onPress?.(event); + } + + handleFinalize(); + }, + [handleFinalize, innerHandlePressIn, onPress, onPressOut] + ); + + const stateMachine = useMemo(() => new PressableStateMachine(), []); + const isScreenReaderEnabled = useIsScreenReaderEnabled(); + + useEffect(() => { + const configuration = getStatesConfig( + handlePressIn, + handlePressOut, + isScreenReaderEnabled + ); + stateMachine.setStates(configuration); + }, [handlePressIn, handlePressOut, stateMachine, isScreenReaderEnabled]); + + const hoverInTimeout = useRef(null); + const hoverOutTimeout = useRef(null); + + const hoverGesture = useHoverGesture({ + manualActivation: true, // Prevents Hover blocking Native gesture on web + cancelsTouchesInView: false, + onBegin: (event) => { + if (hoverOutTimeout.current) { + clearTimeout(hoverOutTimeout.current); + } + if (delayHoverIn) { + hoverInTimeout.current = setTimeout( + () => onHoverIn?.(gestureToPressableEvent(event)), + delayHoverIn + ); + return; + } + onHoverIn?.(gestureToPressableEvent(event)); + }, + onFinalize: (event) => { + if (hoverInTimeout.current) { + clearTimeout(hoverInTimeout.current); + } + if (delayHoverOut) { + hoverOutTimeout.current = setTimeout( + () => onHoverOut?.(gestureToPressableEvent(event)), + delayHoverOut + ); + return; + } + onHoverOut?.(gestureToPressableEvent(event)); + }, + enabled: disabled !== true, + disableReanimated: true, + simultaneousWith, + block, + requireToFail, + hitSlop: appliedHitSlop, + }); + + const pressAndTouchGesture = useLongPressGesture({ + minDuration: Platform.OS === 'web' ? 0 : INT32_MAX, // Long press handles finalize on web, thus it must activate right away + maxDistance: INT32_MAX, // Stops long press from cancelling on touch move + cancelsTouchesInView: false, + onTouchesDown: (event) => { + captureKeyboardDismiss(); + + if (dropKeyboardTapRef.current) { + return; + } + + const pressableEvent = gestureTouchToPressableEvent(event); + stateMachine.handleEvent( + StateMachineEvent.LONG_PRESS_TOUCHES_DOWN, + pressableEvent + ); + }, + onTouchesUp: () => { + if (Platform.OS === 'android' && !isScreenReaderEnabled) { + // Prevents potential soft-locks + stateMachine.reset(); + handleFinalize(); + } + }, + onTouchesCancel: (event) => { + const pressableEvent = gestureTouchToPressableEvent(event); + stateMachine.reset(); + handlePressOut(pressableEvent, false); + }, + onFinalize: (event) => { + if (Platform.OS !== 'web') { + return; + } + + stateMachine.handleEvent( + event.canceled ? StateMachineEvent.CANCEL : StateMachineEvent.FINALIZE + ); + + handleFinalize(); + }, + enabled: disabled !== true, + disableReanimated: true, + simultaneousWith: simultaneousWith, + block: block, + requireToFail: requireToFail, + hitSlop: appliedHitSlop, + }); + + // RNButton is placed inside ButtonGesture to enable Android's ripple and to capture non-propagating events + const buttonGesture = useNativeGesture({ + onTouchesCancel: (event) => { + if (Platform.OS !== 'macos' && Platform.OS !== 'web') { + // On MacOS cancel occurs in middle of gesture + // On Web cancel occurs on mouse move, which is unwanted + const pressableEvent = gestureTouchToPressableEvent(event); + stateMachine.reset(); + handlePressOut(pressableEvent, false); + } + }, + onBegin: () => { + captureKeyboardDismiss(); + + 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); + return; + } + if (Platform.OS === 'android' && isScreenReaderEnabled) { + stateMachine.handleEvent( + StateMachineEvent.NATIVE_BEGIN, + viewCenterToPressableEvent(dimensions.current) + ); + return; + } + stateMachine.handleEvent(StateMachineEvent.NATIVE_BEGIN); + }, + onActivate: () => { + if (!Platform.isTV && Platform.OS !== 'android') { + stateMachine.handleEvent(StateMachineEvent.NATIVE_START); + } + }, + onFinalize: (event) => { + // On Web we use LongPress.onFinalize instead of Native.onFinalize, + // as Native cancels on mouse move, and LongPress does not. + if (Platform.OS === 'web') { + return; + } + + if (Platform.isTV) { + handlePressOut( + viewCenterToPressableEvent(dimensions.current), + !event.canceled + ); + handleFinalize(); + return; + } + + stateMachine.handleEvent( + event.canceled ? StateMachineEvent.CANCEL : StateMachineEvent.FINALIZE + ); + + handleFinalize(); + }, + enabled: disabled !== true, + disableReanimated: true, + simultaneousWith, + block, + requireToFail, + hitSlop: appliedHitSlop, + shouldActivateOnStart: Platform.OS === 'web', + }); + + const gesture = useSimultaneousGestures( + buttonGesture, + pressAndTouchGesture, + hoverGesture + ); + + // `cursor: 'pointer'` on `RNButton` crashes iOS + const pointerStyle: StyleProp = + Platform.OS === 'web' ? { cursor: 'pointer' } : {}; + + const styleProp = + typeof style === 'function' ? style({ pressed: pressedState }) : style; + + const childrenProp = + typeof children === 'function' + ? children({ pressed: pressedState }) + : children; + + const rippleColor = useMemo(() => { + const defaultRippleColor = android_ripple ? undefined : 'transparent'; + return android_ripple?.color ?? defaultRippleColor; + }, [android_ripple]); + + const setDimensions = useCallback( + (event: LayoutChangeEvent) => { + onLayout?.(event); + dimensions.current = event.nativeEvent.layout; + }, + [onLayout] + ); + + const tvProps = getTVProps(remainingProps); + + return ( + + >} + {...tvProps} + onLayout={setDimensions} + accessible={accessible !== false} + hitSlop={appliedHitSlop} + enabled={disabled !== true} + touchSoundDisabled={android_disableSound ?? undefined} + rippleColor={rippleColor} + rippleRadius={android_ripple?.radius ?? undefined} + style={[pointerStyle, styleProp]} + testOnly_onPress={IS_TEST_ENV ? onPress : undefined} + testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined} + testOnly_onPressOut={IS_TEST_ENV ? onPressOut : undefined} + testOnly_onLongPress={IS_TEST_ENV ? onLongPress : undefined}> + {childrenProp} + {__DEV__ ? ( + + ) : null} + + + ); +}; + +export default StatefulPressable; diff --git a/packages/react-native-gesture-handler/src/v3/components/pointerStyle.ts b/packages/react-native-gesture-handler/src/v3/components/pointerStyle.ts new file mode 100644 index 0000000000..f9e2c15950 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/pointerStyle.ts @@ -0,0 +1,5 @@ +import type { ViewStyle } from 'react-native'; + +// A pointer cursor only applies on the web (see the `.web` counterpart); other +// platforms contribute nothing. +export const pointerStyle: ViewStyle = {}; diff --git a/packages/react-native-gesture-handler/src/v3/components/pointerStyle.web.ts b/packages/react-native-gesture-handler/src/v3/components/pointerStyle.web.ts new file mode 100644 index 0000000000..8d3a12c1a7 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/pointerStyle.web.ts @@ -0,0 +1,6 @@ +import type { ViewStyle } from 'react-native'; + +// react-native-web sets `cursor: 'pointer'` on its interactive components +// (Pressable, TouchableOpacity, …) but not on `View`, which the native button +// renders — so the Touchable-based Pressable adds it here to match RN Pressable. +export const pointerStyle: ViewStyle = { cursor: 'pointer' };