diff --git a/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerDetectorShadowNode.cpp b/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerDetectorShadowNode.cpp index 7abe62f4b3..7fef34cefd 100644 --- a/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerDetectorShadowNode.cpp +++ b/packages/react-native-gesture-handler/shared/shadowNodes/react/renderer/components/rngesturehandler_codegen/RNGestureHandlerDetectorShadowNode.cpp @@ -38,10 +38,6 @@ void RNGestureHandlerDetectorShadowNode::layout(LayoutContext layoutContext) { // TODO: consider allowing more than one child and doing bounding box react_native_assert(getChildren().size() == 1); - if (!this->yogaNode_.getHasNewLayout()) { - return; - } - auto child = std::static_pointer_cast( getChildren()[0]); @@ -51,6 +47,7 @@ void RNGestureHandlerDetectorShadowNode::layout(LayoutContext layoutContext) { // TODO: figure out the correct way to setup metrics between detector and // the child auto metrics = child->getLayoutMetrics(); + metrics.frame = child->getLayoutMetrics().frame; setLayoutMetrics(metrics); auto childmetrics = child->getLayoutMetrics(); 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 c5f8f6d60e..8d8bf3eee7 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx @@ -9,8 +9,8 @@ import { GestureObjects as Gesture } from '../../handlers/gestures/gestureObject import { GestureDetector } from '../../handlers/gestures/GestureDetector'; import { PressableEvent, - PressableProps, PressableDimensions, + LegacyPressableProps, } from './PressableProps'; import { Insets, @@ -40,7 +40,7 @@ import { PressableStateMachine } from './StateMachine'; const DEFAULT_LONG_PRESS_DURATION = 500; const IS_TEST_ENV = isTestEnv(); -const Pressable = (props: PressableProps) => { +const Pressable = (props: LegacyPressableProps) => { const { testOnly_pressed, hitSlop, diff --git a/packages/react-native-gesture-handler/src/components/Pressable/PressableProps.tsx b/packages/react-native-gesture-handler/src/components/Pressable/PressableProps.tsx index 0a18f75d4c..716d6bd5e3 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/PressableProps.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/PressableProps.tsx @@ -9,6 +9,7 @@ import { View, } from 'react-native'; import { RelationPropType } from '../utils'; +import { AnyGesture } from '../../v3/types'; export type PressableDimensions = { width: number; height: number }; @@ -30,7 +31,47 @@ export type InnerPressableEvent = { export type PressableEvent = { nativeEvent: InnerPressableEvent }; -export interface PressableProps +export interface LegacyPressableProps extends CommonPressableProps { + /** + * A gesture object or an array of gesture objects containing the configuration and callbacks to be + * used with the Pressable's gesture handlers. + */ + simultaneousWithExternalGesture?: RelationPropType; + + /** + * A gesture object or an array of gesture objects containing the configuration and callbacks to be + * used with the Pressable's gesture handlers. + */ + requireExternalGestureToFail?: RelationPropType; + + /** + * A gesture object or an array of gesture objects containing the configuration and callbacks to be + * used with the Pressable's gesture handlers. + */ + blocksExternalGesture?: RelationPropType; +} + +export interface PressableProps extends CommonPressableProps { + /** + * A gesture object or an array of gesture objects containing the configuration and callbacks to be + * used with the Pressable's gesture handlers. + */ + simultaneousWith?: AnyGesture; + + /** + * A gesture object or an array of gesture objects containing the configuration and callbacks to be + * used with the Pressable's gesture handlers. + */ + requireToFail?: AnyGesture; + + /** + * A gesture object or an array of gesture objects containing the configuration and callbacks to be + * used with the Pressable's gesture handlers. + */ + block?: AnyGesture; +} + +interface CommonPressableProps extends AccessibilityProps, Omit { /** @@ -149,24 +190,6 @@ export interface PressableProps */ unstable_pressDelay?: number; - /** - * A gesture object or an array of gesture objects containing the configuration and callbacks to be - * used with the Pressable's gesture handlers. - */ - simultaneousWithExternalGesture?: RelationPropType; - - /** - * A gesture object or an array of gesture objects containing the configuration and callbacks to be - * used with the Pressable's gesture handlers. - */ - requireExternalGestureToFail?: RelationPropType; - - /** - * A gesture object or an array of gesture objects containing the configuration and callbacks to be - * used with the Pressable's gesture handlers. - */ - blocksExternalGesture?: RelationPropType; - /** * @deprecated This property is no longer used, and will be removed in the future. */ diff --git a/packages/react-native-gesture-handler/src/components/Pressable/index.ts b/packages/react-native-gesture-handler/src/components/Pressable/index.ts index 79ce14a8f0..806a3719da 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/index.ts +++ b/packages/react-native-gesture-handler/src/components/Pressable/index.ts @@ -1,4 +1,5 @@ export type { + LegacyPressableProps, PressableProps, PressableStateCallbackType, } from './PressableProps'; 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 89235e46d9..058a4c25ce 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/utils.ts +++ b/packages/react-native-gesture-handler/src/components/Pressable/utils.ts @@ -13,6 +13,7 @@ import { InnerPressableEvent, PressableEvent, } from './PressableProps'; +import { HoverGestureEvent, LongPressGestureEvent } from '../../v3'; const numberAsInset = (value: number): Insets => ({ left: value, @@ -45,9 +46,12 @@ const touchDataToPressEvent = ( }); const gestureToPressEvent = ( - event: GestureStateChangeEvent< - HoverGestureHandlerEventPayload | LongPressGestureHandlerEventPayload - >, + event: + | GestureStateChangeEvent< + HoverGestureHandlerEventPayload | LongPressGestureHandlerEventPayload + > + | HoverGestureEvent + | LongPressGestureEvent, timestamp: number, targetId: number ): InnerPressableEvent => ({ @@ -73,9 +77,12 @@ const isTouchWithinInset = ( (touch?.locationY ?? 0) > -(inset.top ?? 0); const gestureToPressableEvent = ( - event: GestureStateChangeEvent< - HoverGestureHandlerEventPayload | LongPressGestureHandlerEventPayload - > + event: + | GestureStateChangeEvent< + HoverGestureHandlerEventPayload | LongPressGestureHandlerEventPayload + > + | HoverGestureEvent + | LongPressGestureEvent ): PressableEvent => { const timestamp = Date.now(); diff --git a/packages/react-native-gesture-handler/src/index.ts b/packages/react-native-gesture-handler/src/index.ts index 1e657867dd..e6b13ee297 100644 --- a/packages/react-native-gesture-handler/src/index.ts +++ b/packages/react-native-gesture-handler/src/index.ts @@ -148,9 +148,10 @@ export type { export type { PressableProps, + LegacyPressableProps, PressableStateCallbackType, } from './components/Pressable'; -export { default as Pressable } from './components/Pressable'; +export { default as LegacyPressable } from './components/Pressable'; export type { GestureTouchEvent as SingleGestureTouchEvent } from './handlers/gestureHandlerCommon'; diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx new file mode 100644 index 0000000000..66a842292b --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -0,0 +1,388 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + PressableDimensions, + PressableEvent, + PressableProps, +} from '../../components/Pressable/PressableProps'; +import { + Insets, + LayoutChangeEvent, + Platform, + StyleProp, + ViewStyle, +} from 'react-native'; +import { + addInsets, + numberAsInset, + gestureTouchToPressableEvent, + isTouchWithinInset, + gestureToPressableEvent, +} from '../../components/Pressable/utils'; +import { + getStatesConfig, + StateMachineEvent, +} from '../../components/Pressable/stateDefinitions'; +import { PressableStateMachine } from '../../components/Pressable/StateMachine'; +import { + useHoverGesture, + useLongPressGesture, + useNativeGesture, + useSimultaneousGestures, +} from '../hooks'; +import { GestureDetector } from '../detectors'; +import { PureNativeButton } from './GestureButtons'; + +import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; +import { INT32_MAX, isTestEnv } from '../../utils'; + +const DEFAULT_LONG_PRESS_DURATION = 500; +const IS_TEST_ENV = isTestEnv(); + +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, + ...remainingProps + } = props; + + const [pressedState, setPressedState] = useState(testOnly_pressed ?? false); + + const longPressTimeoutRef = useRef(null); + const pressDelayTimeoutRef = useRef(null); + const isOnPressAllowed = useRef(true); + const isCurrentlyPressed = useRef(false); + const dimensions = useRef({ + width: 0, + height: 0, + }); + + 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; + cancelLongPress(); + cancelDelayedPress(); + setPressedState(false); + }, [cancelDelayedPress, cancelLongPress]); + + const handlePressIn = useCallback( + (event: PressableEvent) => { + if ( + !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(), []); + + useEffect(() => { + const configuration = getStatesConfig(handlePressIn, handlePressOut); + stateMachine.setStates(configuration); + }, [handlePressIn, handlePressOut, stateMachine]); + + 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) => { + const pressableEvent = gestureTouchToPressableEvent(event); + stateMachine.handleEvent( + StateMachineEvent.LONG_PRESS_TOUCHES_DOWN, + pressableEvent + ); + }, + onTouchesUp: () => { + if (Platform.OS === 'android') { + // Prevents potential soft-locks + stateMachine.reset(); + handleFinalize(); + } + }, + onTouchesCancel: (event) => { + const pressableEvent = gestureTouchToPressableEvent(event); + stateMachine.reset(); + handlePressOut(pressableEvent, false); + }, + onFinalize: (_event, success) => { + if (Platform.OS !== 'web') { + return; + } + + stateMachine.handleEvent( + success ? StateMachineEvent.FINALIZE : StateMachineEvent.CANCEL + ); + + 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: () => { + stateMachine.handleEvent(StateMachineEvent.NATIVE_BEGIN); + }, + onActivate: () => { + if (Platform.OS !== 'android') { + // Native.onActivate is broken with Android + hitSlop + stateMachine.handleEvent(StateMachineEvent.NATIVE_START); + } + }, + onFinalize: (_event, success) => { + // 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; + } + stateMachine.handleEvent( + success ? StateMachineEvent.FINALIZE : StateMachineEvent.CANCEL + ); + + if (Platform.OS !== 'ios') { + handleFinalize(); + } + }, + enabled: disabled !== true, + disableReanimated: true, + simultaneousWith, + block, + requireToFail, + hitSlop: appliedHitSlop, + }); + + 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] + ); + + return ( + + + {childrenProp} + {__DEV__ ? ( + + ) : null} + + + ); +}; + +export default Pressable; diff --git a/packages/react-native-gesture-handler/src/v3/components/index.ts b/packages/react-native-gesture-handler/src/v3/components/index.ts index 6412249068..bf4bbc5526 100644 --- a/packages/react-native-gesture-handler/src/v3/components/index.ts +++ b/packages/react-native-gesture-handler/src/v3/components/index.ts @@ -20,3 +20,5 @@ export { FlatList, RefreshControl, } from './GestureComponents'; + +export { default as Pressable } from './Pressable'; diff --git a/packages/react-native-gesture-handler/src/v3/detectors/HostGestureDetector.web.tsx b/packages/react-native-gesture-handler/src/v3/detectors/HostGestureDetector.web.tsx index d118d1abb9..f822fee97a 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/HostGestureDetector.web.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/HostGestureDetector.web.tsx @@ -1,4 +1,4 @@ -import React, { Ref, RefObject, useEffect, useRef } from 'react'; +import React, { Ref, RefObject, useEffect, useMemo, useRef } from 'react'; import RNGestureHandlerModule from '../../RNGestureHandlerModule.web'; import { ActionType } from '../../ActionType'; import { PropsRef } from '../../web/interfaces'; @@ -27,6 +27,8 @@ const EMPTY_HANDLERS = new Set(); const HostGestureDetector = (props: GestureHandlerDetectorProps) => { const { handlerTags, children } = props; + const handlerTagsSet = useMemo(() => new Set(handlerTags), [...handlerTags]); + const viewRef = useRef(null); const propsRef = useRef(props); const attachedHandlers = useRef>(new Set()); @@ -110,25 +112,31 @@ const HostGestureDetector = (props: GestureHandlerDetectorProps) => { tagMessage('Detector expected to have exactly one child element') ); } + }, [children]); - const currentHandlerTags = new Set(handlerTags); - detachHandlers(currentHandlerTags, attachedHandlers.current); + useEffect(() => { + if (React.Children.count(children) !== 1) { + throw new Error( + tagMessage('Detector expected to have exactly one child element') + ); + } + + detachHandlers(handlerTagsSet, attachedHandlers.current); attachHandlers( viewRef, propsRef, - currentHandlerTags, + handlerTagsSet, attachedHandlers.current, ActionType.NATIVE_DETECTOR ); - return () => { detachHandlers(EMPTY_HANDLERS, attachedHandlers.current); attachedVirtualHandlers?.current.forEach((childHandlerTags) => { detachHandlers(EMPTY_HANDLERS, childHandlerTags); }); }; - }, [handlerTags, children]); + }, [handlerTagsSet, viewRef]); useEffect(() => { const virtualChildrenToDetach: Set = new Set( diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts index a8443c06e0..f3758bfd7b 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts @@ -22,6 +22,7 @@ const CommonConfig = new Set([ 'activeCursor', 'mouseButton', 'testID', + 'cancelsTouchesInView', 'manualActivation', ]); diff --git a/packages/react-native-gesture-handler/src/v3/index.ts b/packages/react-native-gesture-handler/src/v3/index.ts index 4902aae885..835113c66d 100644 --- a/packages/react-native-gesture-handler/src/v3/index.ts +++ b/packages/react-native-gesture-handler/src/v3/index.ts @@ -64,6 +64,7 @@ export { RectButton, BorderlessButton, PureNativeButton, + Pressable, ScrollView, Switch, TextInput,