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(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); useEffect( () => () => { if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.current); } if (pressDelayTimeoutRef.current) { clearTimeout(pressDelayTimeoutRef.current); } if (hoverInTimeout.current) { clearTimeout(hoverInTimeout.current); } if (hoverOutTimeout.current) { clearTimeout(hoverOutTimeout.current); } }, [] ); 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' } : {}; // `testOnly_pressed` forces the pressed state for snapshots/tests. Derive the // displayed value from it each render, keeping the interactive `pressedState` // independent (seeded to false) so clearing the prop doesn't leave it stuck. const displayPressed = testOnly_pressed ?? pressedState; const styleProp = typeof style === 'function' ? style({ pressed: displayPressed }) : style; const childrenProp = typeof children === 'function' ? children({ pressed: displayPressed }) : 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} borderless={android_ripple?.borderless ?? undefined} foreground={android_ripple?.foreground ?? 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} testOnly_onHoverIn={IS_TEST_ENV ? onHoverIn : undefined} testOnly_onHoverOut={IS_TEST_ENV ? onHoverOut : undefined}> {childrenProp} {__DEV__ ? ( ) : null} ); }; export default StatefulPressable;