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/index.ts b/packages/react-native-gesture-handler/src/index.ts index 1e657867dd..ebdf90182a 100644 --- a/packages/react-native-gesture-handler/src/index.ts +++ b/packages/react-native-gesture-handler/src/index.ts @@ -147,10 +147,10 @@ export type { } from './handlers/gestureHandlerTypesCompat'; export type { - PressableProps, - PressableStateCallbackType, + PressableProps as LegacyPressableProps, + PressableStateCallbackType as LegacyPressableCallbackType, } 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/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable/Pressable.tsx new file mode 100644 index 0000000000..c387cfd96c --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable/Pressable.tsx @@ -0,0 +1,380 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + PressableEvent, + PressableProps, + FullPressableDimensions, +} from './PressableProps'; +import { + Insets, + LayoutChangeEvent, + Platform, + StyleProp, + View, + ViewStyle, +} from 'react-native'; +import { + addInsets, + numberAsInset, + gestureTouchToPressableEvent, + isTouchWithinInset, + gestureToPressableEvent, +} from './utils'; + +import { getStatesConfig, StateMachineEvent } from './stateDefinitions'; +import { PressableStateMachine } from './StateMachine'; +import { + useHoverGesture, + useLongPressGesture, + useNativeGesture, + useSimultaneousGestures, +} from '../../hooks'; +import { GestureDetector } from '../../detectors'; +import { PureNativeButton } from '../GestureButtons'; + +import { PressabilityDebugView } from '../../../handlers/PressabilityDebugView'; +import { INT32_MAX } 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, + x: 0, + y: 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 Gesture.Native() 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)); + }, + disableReanimated: true, + simultaneousWith: simultaneousWith, + block: block, + requireToFail: requireToFail, + }); + + 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') { + if (success) { + stateMachine.handleEvent(StateMachineEvent.FINALIZE); + } else { + stateMachine.handleEvent(StateMachineEvent.CANCEL); + } + handleFinalize(); + } + }, + disableReanimated: true, + simultaneousWith: simultaneousWith, + block: block, + requireToFail: requireToFail, + }); + + // 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') { + // Gesture.Native().onStart() is broken with Android + hitSlop + stateMachine.handleEvent(StateMachineEvent.NATIVE_START); + } + }, + onFinalize: (_event, success) => { + if (Platform.OS !== 'web') { + // On Web we use LongPress().onFinalize() instead of Native().onFinalize(), + // as Native cancels on mouse move, and LongPress does not. + if (success) { + stateMachine.handleEvent(StateMachineEvent.FINALIZE); + } else { + stateMachine.handleEvent(StateMachineEvent.CANCEL); + } + + if (Platform.OS !== 'ios') { + handleFinalize(); + } + } + }, + disableReanimated: true, + simultaneousWith: simultaneousWith, + block: block, + requireToFail: requireToFail, + }); + + 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/Pressable/PressableProps.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable/PressableProps.tsx new file mode 100644 index 0000000000..21e44b06fa --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable/PressableProps.tsx @@ -0,0 +1,180 @@ +import { + AccessibilityProps, + ViewProps, + Insets, + StyleProp, + ViewStyle, + PressableStateCallbackType as RNPressableStateCallbackType, + PressableAndroidRippleConfig as RNPressableAndroidRippleConfig, + View, +} from 'react-native'; +import { AnyGesture } from '../../types'; + +export type PressableDimensions = { width: number; height: number }; +export type FullPressableDimensions = { + width: number; + height: number; + x: number; + y: number; +}; + +export type PressableStateCallbackType = RNPressableStateCallbackType; +export type PressableAndroidRippleConfig = RNPressableAndroidRippleConfig; + +export type InnerPressableEvent = { + changedTouches: InnerPressableEvent[]; + identifier: number; + locationX: number; + locationY: number; + pageX: number; + pageY: number; + target: number; + timestamp: number; + touches: InnerPressableEvent[]; + force?: number; +}; + +export type PressableEvent = { nativeEvent: InnerPressableEvent }; + +export interface PressableProps + extends AccessibilityProps, + Omit { + /** + * Called when the hover is activated to provide visual feedback. + */ + onHoverIn?: null | ((event: PressableEvent) => void); + + /** + * Called when the hover is deactivated to undo visual feedback. + */ + onHoverOut?: null | ((event: PressableEvent) => void); + + /** + * Called when a single tap gesture is detected. + */ + onPress?: null | ((event: PressableEvent) => void); + + /** + * Called when a touch is engaged before `onPress`. + */ + onPressIn?: null | ((event: PressableEvent) => void); + + /** + * Called when a touch is released before `onPress`. + */ + onPressOut?: null | ((event: PressableEvent) => void); + + /** + * Called when a long-tap gesture is detected. + */ + onLongPress?: null | ((event: PressableEvent) => void); + + /** + * A reference to the pressable element. + */ + ref?: React.Ref; + + /** + * Either children or a render prop that receives a boolean reflecting whether + * the component is currently pressed. + */ + children?: + | React.ReactNode + | ((state: PressableStateCallbackType) => React.ReactNode); + + /** + * Whether a press gesture can be interrupted by a parent gesture such as a + * scroll event. Defaults to true. + */ + cancelable?: null | boolean; + + /** + * Duration to wait after hover in before calling `onHoverIn`. + * @platform web macos + * + * NOTE: not present in RN docs + */ + delayHoverIn?: number | null; + + /** + * Duration to wait after hover out before calling `onHoverOut`. + * @platform web macos + * + * NOTE: not present in RN docs + */ + delayHoverOut?: number | null; + + /** + * Duration (in milliseconds) from `onPressIn` before `onLongPress` is called. + */ + delayLongPress?: null | number; + + /** + * Whether the press behavior is disabled. + */ + disabled?: null | boolean; + + /** + * Additional distance outside of this view in which a press is detected. + */ + hitSlop?: null | Insets | number; + + /** + * Additional distance outside of this view in which a touch is considered a + * press before `onPressOut` is triggered. + */ + pressRetentionOffset?: null | Insets | number; + + /** + * If true, doesn't play system sound on touch. + * @platform android + */ + android_disableSound?: null | boolean; + + /** + * Enables the Android ripple effect and configures its color. + * @platform android + */ + android_ripple?: null | PressableAndroidRippleConfig; + + /** + * Used only for documentation or testing (e.g. snapshot testing). + */ + testOnly_pressed?: null | boolean; + + /** + * Either view styles or a function that receives a boolean reflecting whether + * the component is currently pressed and returns view styles. + */ + style?: + | StyleProp + | ((state: PressableStateCallbackType) => StyleProp); + + /** + * Duration (in milliseconds) to wait after press down before calling onPressIn. + */ + 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. + */ + 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; + + /** + * @deprecated This property is no longer used, and will be removed in the future. + */ + dimensionsAfterResize?: PressableDimensions; +} diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable/StateMachine.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable/StateMachine.tsx new file mode 100644 index 0000000000..b6d38ac828 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable/StateMachine.tsx @@ -0,0 +1,54 @@ +import { PressableEvent } from './PressableProps'; + +export interface StateDefinition { + eventName: string; + callback?: (event: PressableEvent) => void; +} + +class PressableStateMachine { + private states: StateDefinition[] | null; + private currentStepIndex: number; + private eventPayload: PressableEvent | null; + + constructor() { + this.states = null; + this.currentStepIndex = 0; + this.eventPayload = null; + } + + public setStates(states: StateDefinition[]) { + this.states = states; + } + + public reset() { + this.currentStepIndex = 0; + this.eventPayload = null; + } + + public handleEvent(eventName: string, eventPayload?: PressableEvent) { + if (!this.states) { + return; + } + const step = this.states[this.currentStepIndex]; + this.eventPayload = eventPayload || this.eventPayload; + + if (step.eventName !== eventName) { + if (this.currentStepIndex > 0) { + // retry with position at index 0 + this.reset(); + this.handleEvent(eventName, eventPayload); + } + return; + } + if (this.eventPayload && step.callback) { + step.callback(this.eventPayload); + } + this.currentStepIndex++; + + if (this.currentStepIndex === this.states.length) { + this.reset(); + } + } +} + +export { PressableStateMachine }; diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable/index.ts b/packages/react-native-gesture-handler/src/v3/components/Pressable/index.ts new file mode 100644 index 0000000000..79ce14a8f0 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable/index.ts @@ -0,0 +1,5 @@ +export type { + PressableProps, + PressableStateCallbackType, +} from './PressableProps'; +export { default } from './Pressable'; diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable/stateDefinitions.ts b/packages/react-native-gesture-handler/src/v3/components/Pressable/stateDefinitions.ts new file mode 100644 index 0000000000..55279211cd --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable/stateDefinitions.ts @@ -0,0 +1,126 @@ +import { Platform } from 'react-native'; +import { PressableEvent } from './PressableProps'; +import { StateDefinition } from './StateMachine'; + +export enum StateMachineEvent { + NATIVE_BEGIN = 'nativeBegin', + NATIVE_START = 'nativeStart', + FINALIZE = 'finalize', + LONG_PRESS_TOUCHES_DOWN = 'longPressTouchesDown', + CANCEL = 'cancel', +} + +function getAndroidStatesConfig( + handlePressIn: (event: PressableEvent) => void, + handlePressOut: (event: PressableEvent) => void +) { + return [ + { + eventName: StateMachineEvent.NATIVE_BEGIN, + }, + { + eventName: StateMachineEvent.LONG_PRESS_TOUCHES_DOWN, + callback: handlePressIn, + }, + { + eventName: StateMachineEvent.FINALIZE, + callback: handlePressOut, + }, + ]; +} + +function getIosStatesConfig( + handlePressIn: (event: PressableEvent) => void, + handlePressOut: (event: PressableEvent) => void +) { + return [ + { + eventName: StateMachineEvent.LONG_PRESS_TOUCHES_DOWN, + }, + { + eventName: StateMachineEvent.NATIVE_START, + callback: handlePressIn, + }, + { + eventName: StateMachineEvent.FINALIZE, + callback: handlePressOut, + }, + ]; +} + +function getWebStatesConfig( + handlePressIn: (event: PressableEvent) => void, + handlePressOut: (event: PressableEvent) => void +) { + return [ + { + eventName: StateMachineEvent.NATIVE_BEGIN, + }, + { + eventName: StateMachineEvent.NATIVE_START, + }, + { + eventName: StateMachineEvent.LONG_PRESS_TOUCHES_DOWN, + callback: handlePressIn, + }, + { + eventName: StateMachineEvent.FINALIZE, + callback: handlePressOut, + }, + ]; +} + +function getMacosStatesConfig( + handlePressIn: (event: PressableEvent) => void, + handlePressOut: (event: PressableEvent) => void +) { + return [ + { + eventName: StateMachineEvent.LONG_PRESS_TOUCHES_DOWN, + }, + { + eventName: StateMachineEvent.NATIVE_BEGIN, + callback: handlePressIn, + }, + { + eventName: StateMachineEvent.NATIVE_START, + }, + { + eventName: StateMachineEvent.FINALIZE, + callback: handlePressOut, + }, + ]; +} + +function getUniversalStatesConfig( + handlePressIn: (event: PressableEvent) => void, + handlePressOut: (event: PressableEvent) => void +) { + return [ + { + eventName: StateMachineEvent.FINALIZE, + callback: (event: PressableEvent) => { + handlePressIn(event); + handlePressOut(event); + }, + }, + ]; +} + +export function getStatesConfig( + handlePressIn: (event: PressableEvent) => void, + handlePressOut: (event: PressableEvent) => void +): StateDefinition[] { + if (Platform.OS === 'android') { + return getAndroidStatesConfig(handlePressIn, handlePressOut); + } else if (Platform.OS === 'ios') { + return getIosStatesConfig(handlePressIn, handlePressOut); + } else if (Platform.OS === 'web') { + return getWebStatesConfig(handlePressIn, handlePressOut); + } else if (Platform.OS === 'macos') { + return getMacosStatesConfig(handlePressIn, handlePressOut); + } else { + // Unknown platform - using minimal universal setup. + return getUniversalStatesConfig(handlePressIn, handlePressOut); + } +} diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable/utils.ts b/packages/react-native-gesture-handler/src/v3/components/Pressable/utils.ts new file mode 100644 index 0000000000..cd99adaaf7 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable/utils.ts @@ -0,0 +1,133 @@ +import { Insets } from 'react-native'; +import { + InnerPressableEvent, + PressableEvent, + FullPressableDimensions, +} from './PressableProps'; +import { + GestureTouchEvent, + TouchData, +} from '../../../handlers/gestureHandlerCommon'; +import { HoverGestureEvent } from '../../hooks/gestures/hover/useHoverGesture'; +import { LongPressGestureEvent } from '../../hooks'; + +const numberAsInset = (value: number): Insets => ({ + left: value, + right: value, + top: value, + bottom: value, +}); + +const addInsets = (a: Insets, b: Insets): Insets => ({ + left: (a.left ?? 0) + (b.left ?? 0), + right: (a.right ?? 0) + (b.right ?? 0), + top: (a.top ?? 0) + (b.top ?? 0), + bottom: (a.bottom ?? 0) + (b.bottom ?? 0), +}); + +const touchDataToPressEvent = ( + data: TouchData, + timestamp: number, + targetId: number +): InnerPressableEvent => ({ + identifier: data.id, + locationX: data.x, + locationY: data.y, + pageX: data.absoluteX, + pageY: data.absoluteY, + target: targetId, + timestamp: timestamp, + touches: [], // Always empty - legacy compatibility + changedTouches: [], // Always empty - legacy compatibility +}); + +const gestureToPressEvent = ( + event: HoverGestureEvent | LongPressGestureEvent, + timestamp: number, + targetId: number +): InnerPressableEvent => ({ + identifier: event.handlerTag, + locationX: event.x, + locationY: event.y, + pageX: event.absoluteX, + pageY: event.absoluteY, + target: targetId, + timestamp: timestamp, + touches: [], // Always empty - legacy compatibility + changedTouches: [], // Always empty - legacy compatibility +}); + +const isTouchWithinInset = ( + dimensions: FullPressableDimensions, + inset: Insets, + touch?: InnerPressableEvent +) => + (touch?.locationX ?? 0) < (inset.right ?? 0) + dimensions.width && + (touch?.locationY ?? 0) < (inset.bottom ?? 0) + dimensions.height && + (touch?.locationX ?? 0) > -(inset.left ?? 0) && + (touch?.locationY ?? 0) > -(inset.top ?? 0); + +const gestureToPressableEvent = ( + event: HoverGestureEvent | LongPressGestureEvent +): PressableEvent => { + const timestamp = Date.now(); + + // As far as I can see, there isn't a conventional way of getting targetId with the data we get + const targetId = 0; + + const pressEvent = gestureToPressEvent(event, timestamp, targetId); + + return { + nativeEvent: { + touches: [pressEvent], + changedTouches: [pressEvent], + identifier: pressEvent.identifier, + locationX: event.x, + locationY: event.y, + pageX: event.absoluteX, + pageY: event.absoluteY, + target: targetId, + timestamp: timestamp, + force: undefined, + }, + }; +}; + +const gestureTouchToPressableEvent = ( + event: GestureTouchEvent +): PressableEvent => { + const timestamp = Date.now(); + + // As far as I can see, there isn't a conventional way of getting targetId with the data we get + const targetId = 0; + + const touchesList = event.allTouches.map((touch: TouchData) => + touchDataToPressEvent(touch, timestamp, targetId) + ); + const changedTouchesList = event.changedTouches.map((touch: TouchData) => + touchDataToPressEvent(touch, timestamp, targetId) + ); + + return { + nativeEvent: { + touches: touchesList, + changedTouches: changedTouchesList, + identifier: event.handlerTag, + locationX: event.allTouches.at(0)?.x ?? -1, + locationY: event.allTouches.at(0)?.y ?? -1, + pageX: event.allTouches.at(0)?.absoluteX ?? -1, + pageY: event.allTouches.at(0)?.absoluteY ?? -1, + target: targetId, + timestamp: timestamp, + force: undefined, + }, + }; +}; + +export { + numberAsInset, + addInsets, + isTouchWithinInset, + gestureToPressableEvent, + gestureTouchToPressableEvent, +}; 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..a551f25f66 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,6 @@ export { FlatList, RefreshControl, } from './GestureComponents'; + +export type { PressableProps, PressableStateCallbackType } from './Pressable'; +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 12631bdf51..851a69d3ae 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', ]); const ExternalRelationsConfig = new Set([ diff --git a/packages/react-native-gesture-handler/src/v3/index.ts b/packages/react-native-gesture-handler/src/v3/index.ts index 4902aae885..ad50434663 100644 --- a/packages/react-native-gesture-handler/src/v3/index.ts +++ b/packages/react-native-gesture-handler/src/v3/index.ts @@ -71,6 +71,12 @@ export { RefreshControl, } from './components'; +export { + PressableProps, + PressableStateCallbackType, + Pressable, +} from './components'; + export type { ComposedGesture } from './types'; export { GestureStateManager } from './gestureStateManager';