diff --git a/example/App.tsx b/example/App.tsx index f44a2e1dd8..0087b26b79 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -23,6 +23,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import OverflowParent from './src/release_tests/overflowParent'; import DoublePinchRotate from './src/release_tests/doubleScalePinchAndRotate'; import DoubleDraggable from './src/release_tests/doubleDraggable'; +import GesturizedPressable from './src/release_tests/gesturizedPressable'; import { ComboWithGHScroll } from './src/release_tests/combo'; import { TouchablesIndex, @@ -66,6 +67,7 @@ import Hover from './src/new_api/hover'; import HoverableIcons from './src/new_api/hoverable_icons'; import VelocityTest from './src/new_api/velocityTest'; import Swipeable from 'src/new_api/swipeable'; +import Pressable from 'src/new_api/pressable'; import EmptyExample from './src/empty/EmptyExample'; import RectButtonBorders from './src/release_tests/rectButton'; @@ -143,6 +145,7 @@ const EXAMPLES: ExamplesSection[] = [ { name: 'PointerType', component: PointerType }, { name: 'Swipeable Reanimation', component: SwipeableReanimation }, { name: 'RectButton (borders)', component: RectButtonBorders }, + { name: 'Gesturized pressable', component: GesturizedPressable }, ], }, { @@ -164,6 +167,7 @@ const EXAMPLES: ExamplesSection[] = [ { name: 'Chat Heads', component: ChatHeadsNewApi }, { name: 'Drag and drop', component: DragNDrop }, { name: 'Swipeable', component: Swipeable }, + { name: 'Pressable', component: Pressable }, { name: 'Horizontal Drawer (Reanimated 2 & RNGH 2)', component: BetterHorizontalDrawer, diff --git a/example/src/new_api/pressable/index.tsx b/example/src/new_api/pressable/index.tsx new file mode 100644 index 0000000000..bf3319c617 --- /dev/null +++ b/example/src/new_api/pressable/index.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { Pressable } from 'react-native-gesture-handler'; + +export default function PressableExample() { + const pressIn = () => { + console.log('Pressable pressed in'); + }; + + const pressOut = () => { + console.log('Pressable pressed out'); + }; + + const press = () => { + console.log('Pressable pressed'); + }; + + const hoverIn = () => { + console.log('Hovered in'); + }; + + const hoverOut = () => { + console.log('Hovered out'); + }; + + const longPress = () => { + console.log('Long pressed'); + }; + return ( + + + + + Pressable! + + + Hit Rect + + Press Rect + + ); +} + +const BACKGROUND_COLOR = '#F5FCFF'; + +const styles = StyleSheet.create({ + pressRectContainer: { + backgroundColor: '#FFD6E0', + padding: 20, + width: 200, + height: 200, + margin: 'auto', + }, + hitRectContainer: { + backgroundColor: '#F29DC3', + padding: 20, + width: 160, + height: 160, + margin: 'auto', + }, + rectText: { + color: BACKGROUND_COLOR, + fontWeight: '700', + position: 'absolute', + right: 5, + bottom: 2, + }, + pressable: { + width: 120, + height: 120, + backgroundColor: 'mediumpurple', + }, + textWrapper: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + text: { + color: BACKGROUND_COLOR, + }, +}); diff --git a/example/src/release_tests/gesturizedPressable/androidRippleExample.tsx b/example/src/release_tests/gesturizedPressable/androidRippleExample.tsx new file mode 100644 index 0000000000..952b7fb6ed --- /dev/null +++ b/example/src/release_tests/gesturizedPressable/androidRippleExample.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Platform, StyleSheet, View } from 'react-native'; +import TestingBase from './testingBase'; + +export function RippleExample() { + const buttonOpacity = + Platform.OS === 'android' ? { opacity: 1 } : { opacity: 0.6 }; + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 40, + padding: 20, + }, + pressable: { + width: 100, + height: 100, + borderWidth: StyleSheet.hairlineWidth, + backgroundColor: 'mediumpurple', + }, +}); diff --git a/example/src/release_tests/gesturizedPressable/delayedPressExample.tsx b/example/src/release_tests/gesturizedPressable/delayedPressExample.tsx new file mode 100644 index 0000000000..537d20c018 --- /dev/null +++ b/example/src/release_tests/gesturizedPressable/delayedPressExample.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import TestingBase from './testingBase'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSequence, + withSpring, +} from 'react-native-reanimated'; + +const signalerConfig = { + duration: 200, + dampingRatio: 1, + stiffness: 500, + overshootClamping: true, + restDisplacementThreshold: 0.01, + restSpeedThreshold: 2, +}; + +export function DelayedPressExample() { + const startColor = '#fff'; + const pressColor = '#ff0'; + const longPressColor = '#f0f'; + const animatedColor = useSharedValue(startColor); + + const pressDelay = 1000; + const longPressDelay = 1000; + + const onPressIn = () => { + console.log('Pressed with delay'); + animatedColor.value = withSequence( + withSpring(pressColor, signalerConfig), + withSpring(startColor, signalerConfig) + ); + }; + + const onLongPress = () => { + console.log('Long pressed with delay'); + animatedColor.value = withSequence( + withSpring(longPressColor, signalerConfig), + withSpring(startColor, signalerConfig) + ); + }; + + const signalerStyle = useAnimatedStyle(() => ({ + backgroundColor: animatedColor.value, + })); + + return ( + <> + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 40, + padding: 20, + }, + pressable: { + width: 100, + height: 100, + backgroundColor: 'mediumpurple', + }, + signaler: { + width: 50, + height: 50, + borderRadius: 25, + marginTop: 15, + borderWidth: StyleSheet.hairlineWidth, + }, +}); diff --git a/example/src/release_tests/gesturizedPressable/functionalStylesExample.tsx b/example/src/release_tests/gesturizedPressable/functionalStylesExample.tsx new file mode 100644 index 0000000000..b1730df28a --- /dev/null +++ b/example/src/release_tests/gesturizedPressable/functionalStylesExample.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { + PressableStateCallbackType, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; +import TestingBase from './testingBase'; + +export function FunctionalStyleExample() { + const functionalStyle = ( + state: PressableStateCallbackType + ): StyleProp => { + if (state.pressed) { + return { + width: 100, + height: 100, + backgroundColor: 'red', + }; + } else { + return { + width: 100, + height: 100, + backgroundColor: 'mediumpurple', + }; + } + }; + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 40, + padding: 20, + }, +}); diff --git a/example/src/release_tests/gesturizedPressable/hitSlopExample.tsx b/example/src/release_tests/gesturizedPressable/hitSlopExample.tsx new file mode 100644 index 0000000000..3c65482aa4 --- /dev/null +++ b/example/src/release_tests/gesturizedPressable/hitSlopExample.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import TestingBase from './testingBase'; + +const HIT_SLOP = 40; +const PRESS_RETENTION_OFFSET = HIT_SLOP; + +export function HitSlopExample() { + const pressIn = () => { + console.log('Pressable pressed in'); + }; + + const pressOut = () => { + console.log('Pressable pressed out'); + }; + + const press = () => { + console.log('Pressable pressed'); + }; + + const hoverIn = () => { + console.log('Hovered in'); + }; + + const hoverOut = () => { + console.log('Hovered out'); + }; + + const longPress = () => { + console.log('Long pressed'); + }; + + return ( + + + + pressIn()} + onPressOut={() => pressOut()} + onPress={() => press()} + onHoverIn={() => hoverIn()} + onHoverOut={() => hoverOut()} + onLongPress={() => longPress()} + /> + + Hit Slop + + Retention Offset + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'flex-start', + alignItems: 'center', + gap: 40, + }, + pressable: { + backgroundColor: 'mediumpurple', + width: 100, + height: 100, + }, + textWrapper: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + text: { + alignSelf: 'flex-end', + marginBottom: 4, + marginRight: 6, + marginTop: 12, + }, + slopIndicator: { + display: 'flex', + alignItems: 'center', + width: 100 + HIT_SLOP * 2, + borderRightWidth: StyleSheet.hairlineWidth, + }, + retentionIndicator: { + display: 'flex', + alignItems: 'center', + width: 180 + PRESS_RETENTION_OFFSET * 2, + borderRightWidth: StyleSheet.hairlineWidth, + margin: 20, + }, +}); diff --git a/example/src/release_tests/gesturizedPressable/hoverDelayExample.tsx b/example/src/release_tests/gesturizedPressable/hoverDelayExample.tsx new file mode 100644 index 0000000000..ad42288cfa --- /dev/null +++ b/example/src/release_tests/gesturizedPressable/hoverDelayExample.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import TestingBase from './testingBase'; + +export function DelayHoverExample() { + const hoverIn = () => { + console.log('Hover in with delay registered'); + }; + + const hoverOut = () => { + console.log('Hover out with delay registered'); + }; + + return ( + + hoverIn()} + onHoverOut={() => hoverOut()} + delayHoverIn={500} + delayHoverOut={500} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 40, + padding: 20, + }, + pressable: { + width: 100, + height: 100, + borderWidth: StyleSheet.hairlineWidth, + backgroundColor: 'mediumpurple', + }, +}); diff --git a/example/src/release_tests/gesturizedPressable/index.tsx b/example/src/release_tests/gesturizedPressable/index.tsx new file mode 100644 index 0000000000..5b6e656875 --- /dev/null +++ b/example/src/release_tests/gesturizedPressable/index.tsx @@ -0,0 +1,123 @@ +import React, { ReactNode } from 'react'; +import { Text, View, StyleSheet } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; + +import { BACKGROUND_COLOR } from './testingBase'; +import { HitSlopExample } from './hitSlopExample'; +import { RippleExample } from './androidRippleExample'; +import { FunctionalStyleExample } from './functionalStylesExample'; +import { DelayedPressExample } from './delayedPressExample'; +import { DelayHoverExample } from './hoverDelayExample'; + +type TestingEntryProps = { + title: string; + platform?: string; + comment?: string; + children: ReactNode; +}; +const TestingEntry = ({ + children, + title, + platform, + comment, +}: TestingEntryProps) => ( + + + + {title} + {platform && {platform}} + + {comment && {comment}} + + {children} + + +); + +export default function Example() { + return ( + + + + + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: BACKGROUND_COLOR, + justifyContent: 'center', + }, + data: { + flex: 1, + alignSelf: 'center', + flexDirection: 'column', + justifyContent: 'center', + gap: 12, + marginTop: 15, + }, + header: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + alignContent: 'center', + gap: 8, + }, + title: { + fontSize: 24, + fontWeight: '400', + }, + code: { + fontSize: 16, + fontWeight: '400', + padding: 5, + borderRadius: 5, + + color: '#37474f', + backgroundColor: '#bbc', + fontFamily: 'monospace', + fontVariant: ['tabular-nums'], + }, + comment: { + alignSelf: 'flex-start', + textAlign: 'center', + margin: 15, + marginTop: 0, + marginBottom: 5, + color: '#555', + }, + testSandbox: { + marginTop: 5, + display: 'flex', + alignItems: 'center', + }, + separator: { + borderWidth: 0.6, + borderStyle: 'dashed', + }, +}); diff --git a/example/src/release_tests/gesturizedPressable/testingBase.tsx b/example/src/release_tests/gesturizedPressable/testingBase.tsx new file mode 100644 index 0000000000..c92fd9301e --- /dev/null +++ b/example/src/release_tests/gesturizedPressable/testingBase.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { + StyleSheet, + Text, + View, + Pressable, + PressableProps as RNPressableProps, +} from 'react-native'; +import { + Pressable as GesturizedPressable, + PressableProps as GHPressableProps, +} from 'react-native-gesture-handler'; + +const TestingBase = ( + props: GHPressableProps & RNPressableProps & React.RefAttributes +) => ( + <> + + + Gesturized pressable! + + + + + Legacy pressable! + + + +); + +const BACKGROUND_COLOR = '#F5FCFF'; + +export default TestingBase; +export { BACKGROUND_COLOR }; + +const styles = StyleSheet.create({ + textWrapper: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + text: { + color: BACKGROUND_COLOR, + textAlign: 'center', + }, +}); diff --git a/src/components/GestureButtonsProps.ts b/src/components/GestureButtonsProps.ts index a79c779830..480d28bea6 100644 --- a/src/components/GestureButtonsProps.ts +++ b/src/components/GestureButtonsProps.ts @@ -43,6 +43,11 @@ export interface RawButtonProps extends NativeViewGestureHandlerProps { * Set this to true if you don't want the system to play sound when the button is pressed. */ touchSoundDisabled?: boolean; + + /** + * Style object, use it to set additional styles. + */ + style?: StyleProp; } interface ButtonWithRefProps { innerRef?: React.ForwardedRef>; diff --git a/src/components/Pressable/Pressable.tsx b/src/components/Pressable/Pressable.tsx new file mode 100644 index 0000000000..22921beaca --- /dev/null +++ b/src/components/Pressable/Pressable.tsx @@ -0,0 +1,279 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { GestureObjects as Gesture } from '../../handlers/gestures/gestureObjects'; +import { GestureDetector } from '../../handlers/gestures/GestureDetector'; +import { PressableProps } from './PressableProps'; +import { + Insets, + Platform, + StyleProp, + View, + ViewStyle, + processColor, + StyleSheet, +} from 'react-native'; +import NativeButton from '../GestureHandlerButton'; +import { + numberAsInset, + adaptStateChangeEvent, + isTouchWithinInset, + adaptTouchEvent, + addInsets, +} from './utils'; +import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; +import { GestureTouchEvent } from '../../handlers/gestureHandlerCommon'; + +const DEFAULT_LONG_PRESS_DURATION = 500; + +export default function Pressable(props: PressableProps) { + const [pressedState, setPressedState] = useState( + props.testOnly_pressed ?? false + ); + + const pressableRef = useRef(null); + + // disabled when onLongPress has been called + const isPressCallbackEnabled = useRef(true); + const isPressedDown = useRef(false); + + const normalizedHitSlop: Insets = + typeof props.hitSlop === 'number' + ? numberAsInset(props.hitSlop) + : props.hitSlop ?? {}; + + const normalizedPressRetentionOffset: Insets = + typeof props.pressRetentionOffset === 'number' + ? numberAsInset(props.pressRetentionOffset) + : props.pressRetentionOffset ?? {}; + + const pressGesture = useMemo( + () => + Gesture.LongPress().onStart((event) => { + if (isPressedDown.current) { + props.onLongPress?.(adaptStateChangeEvent(event)); + isPressCallbackEnabled.current = false; + } + }), + [isPressCallbackEnabled, props.onLongPress, isPressedDown] + ); + + const hoverInTimeout = useRef(null); + const hoverOutTimeout = useRef(null); + + const hoverGesture = useMemo( + () => + Gesture.Hover() + .onBegin((event) => { + if (hoverOutTimeout.current) { + clearTimeout(hoverOutTimeout.current); + } + if (props.delayHoverIn) { + hoverInTimeout.current = setTimeout( + () => props.onHoverIn?.(adaptStateChangeEvent(event)), + props.delayHoverIn + ); + return; + } + props.onHoverIn?.(adaptStateChangeEvent(event)); + }) + .onEnd((event) => { + if (hoverInTimeout.current) { + clearTimeout(hoverInTimeout.current); + } + if (props.delayHoverOut) { + hoverOutTimeout.current = setTimeout( + () => props.onHoverOut?.(adaptStateChangeEvent(event)), + props.delayHoverOut + ); + return; + } + props.onHoverOut?.(adaptStateChangeEvent(event)); + }), + [props.onHoverIn, props.onHoverOut, props.delayHoverIn, props.delayHoverOut] + ); + + const pressDelayTimeoutRef = useRef(null); + const pressInHandler = useCallback((event: GestureTouchEvent) => { + props.onPressIn?.(adaptTouchEvent(event)); + isPressCallbackEnabled.current = true; + setPressedState(true); + pressDelayTimeoutRef.current = null; + }, []); + const pressOutHandler = useCallback((event: GestureTouchEvent) => { + if ( + !isPressedDown.current || + event.allTouches.length > event.changedTouches.length + ) { + return; + } + + if (props.unstable_pressDelay && pressDelayTimeoutRef.current !== null) { + // legacy Pressable behaviour - if pressDelay is set, we want to call onPressIn on touch up + clearTimeout(pressDelayTimeoutRef.current); + pressInHandler(event); + } + + props.onPressOut?.(adaptTouchEvent(event)); + + if (isPressCallbackEnabled.current) { + props.onPress?.(adaptTouchEvent(event)); + } + + isPressedDown.current = false; + setPressedState(false); + }, []); + + const handlingOnTouchesDown = useRef(false); + const onEndHandlingTouchesDown = useRef<(() => void) | null>(null); + const cancelledMidPress = useRef(false); + + const touchGesture = useMemo( + () => + Gesture.Manual() + .onTouchesDown((event) => { + handlingOnTouchesDown.current = true; + pressableRef.current?.measure((_x, _y, width, height) => { + if ( + !isTouchWithinInset( + { + width, + height, + }, + normalizedHitSlop, + event.changedTouches.at(-1) + ) || + isPressedDown.current || + cancelledMidPress.current + ) { + cancelledMidPress.current = false; + onEndHandlingTouchesDown.current = null; + handlingOnTouchesDown.current = false; + return; + } + + isPressedDown.current = true; + + if (props.unstable_pressDelay) { + pressDelayTimeoutRef.current = setTimeout(() => { + pressInHandler(event); + }, props.unstable_pressDelay); + } else { + pressInHandler(event); + } + + onEndHandlingTouchesDown.current?.(); + onEndHandlingTouchesDown.current = null; + handlingOnTouchesDown.current = false; + }); + }) + .onTouchesUp((event) => { + if (handlingOnTouchesDown.current) { + onEndHandlingTouchesDown.current = () => pressOutHandler(event); + return; + } + + pressOutHandler(event); + }) + .onTouchesCancelled((event) => { + if ( + !isPressedDown.current || + event.allTouches.length > event.changedTouches.length + ) { + return; + } + + if (handlingOnTouchesDown.current) { + cancelledMidPress.current = true; + onEndHandlingTouchesDown.current = () => pressOutHandler(event); + return; + } + + pressOutHandler(event); + }), + [ + props.onPress, + props.onPressIn, + props.onPressOut, + setPressedState, + isPressedDown, + isPressCallbackEnabled, + normalizedHitSlop, + pressDelayTimeoutRef, + ] + ); + + // rippleGesture lives inside RNButton to enable android's ripple + const rippleGesture = useMemo(() => Gesture.Native(), []); + + pressGesture.minDuration( + (props.delayLongPress ?? DEFAULT_LONG_PRESS_DURATION) + + (props.unstable_pressDelay ?? 0) + ); + + const appliedHitSlop = addInsets( + normalizedHitSlop, + normalizedPressRetentionOffset + ); + + const isPressableEnabled = props.disabled !== true; + + const gestures = [touchGesture, pressGesture, hoverGesture, rippleGesture]; + + for (const gesture of gestures) { + gesture.enabled(isPressableEnabled); + gesture.runOnJS(true); + gesture.hitSlop(appliedHitSlop); + + if (Platform.OS !== 'web') { + gesture.shouldCancelWhenOutside(true); + } + } + + // uses different hitSlop, to activate on hitSlop area instead of pressRetentionOffset area + rippleGesture.hitSlop(normalizedHitSlop); + + const gesture = Gesture.Simultaneous( + hoverGesture, + pressGesture, + touchGesture, + rippleGesture + ); + + const defaultRippleColor = props.android_ripple ? undefined : 'transparent'; + + // `cursor: 'pointer'` on `RNButton` crashes IOS + const pointerStyle: StyleProp = + Platform.OS === 'web' ? { cursor: 'pointer' } : {}; + + const styleProp = + typeof props.style === 'function' + ? props.style({ pressed: pressedState }) + : props.style; + + const childrenProp = + typeof props.children === 'function' + ? props.children({ pressed: pressedState }) + : props.children; + + return ( + + + + {childrenProp} + {__DEV__ ? ( + + ) : null} + + + + ); +} diff --git a/src/components/Pressable/PressableProps.tsx b/src/components/Pressable/PressableProps.tsx new file mode 100644 index 0000000000..88bc08b458 --- /dev/null +++ b/src/components/Pressable/PressableProps.tsx @@ -0,0 +1,149 @@ +import { + ColorValue, + AccessibilityProps, + ViewProps, + Insets, + StyleProp, + ViewStyle, +} from 'react-native'; + +export interface PressableStateCallbackType { + readonly pressed: boolean; +} + +export interface PressableAndroidRippleConfig { + color?: null | ColorValue | undefined; + borderless?: null | boolean | undefined; + radius?: null | number | undefined; + foreground?: null | boolean | undefined; +} + +export type PressEvent = { + changedTouches: PressEvent[]; + identifier: number; + locationX: number; + locationY: number; + pageX: number; + pageY: number; + target: number; + timestamp: number; + touches: PressEvent[]; + force?: number; +}; + +export type PressableEvent = { nativeEvent: PressEvent }; + +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); + + /** + * 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; +} diff --git a/src/components/Pressable/index.ts b/src/components/Pressable/index.ts new file mode 100644 index 0000000000..740b7e1337 --- /dev/null +++ b/src/components/Pressable/index.ts @@ -0,0 +1,2 @@ +export { PressableProps } from './PressableProps'; +export { default } from './Pressable'; diff --git a/src/components/Pressable/utils.ts b/src/components/Pressable/utils.ts new file mode 100644 index 0000000000..61f6236818 --- /dev/null +++ b/src/components/Pressable/utils.ts @@ -0,0 +1,130 @@ +import { Insets } from 'react-native'; +import { LongPressGestureHandlerEventPayload } from '../../handlers/GestureHandlerEventPayload'; +import { + TouchData, + GestureStateChangeEvent, + GestureTouchEvent, +} from '../../handlers/gestureHandlerCommon'; +import { HoverGestureHandlerEventPayload } from '../../handlers/gestures/hoverGesture'; +import { PressEvent, PressableEvent } from './PressableProps'; + +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 touchToPressEvent = ( + data: TouchData, + timestamp: number, + targetId: number +): PressEvent => ({ + 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 changeToTouchData = ( + event: GestureStateChangeEvent< + HoverGestureHandlerEventPayload | LongPressGestureHandlerEventPayload + > +): TouchData => ({ + id: event.handlerTag, + x: event.x, + y: event.y, + absoluteX: event.absoluteX, + absoluteY: event.absoluteY, +}); + +const isTouchWithinInset = ( + dimensions: { width: number; height: number }, + inset: Insets, + touch?: TouchData +) => + (touch?.x ?? 0) < (inset.right ?? 0) + dimensions.width && + (touch?.y ?? 0) < (inset.bottom ?? 0) + dimensions.height && + (touch?.x ?? 0) > -(inset.left ?? 0) && + (touch?.y ?? 0) > -(inset.top ?? 0); + +const adaptStateChangeEvent = ( + event: GestureStateChangeEvent< + HoverGestureHandlerEventPayload | LongPressGestureHandlerEventPayload + > +): 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 touchData = changeToTouchData(event); + + const pressEvent = touchToPressEvent(touchData, 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 adaptTouchEvent = (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 nativeTouches = event.allTouches.map((touch: TouchData) => + touchToPressEvent(touch, timestamp, targetId) + ); + const nativeChangedTouches = event.changedTouches.map((touch: TouchData) => + touchToPressEvent(touch, timestamp, targetId) + ); + + return { + nativeEvent: { + touches: nativeTouches, + changedTouches: nativeChangedTouches, + 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, + touchToPressEvent, + changeToTouchData, + isTouchWithinInset, + adaptStateChangeEvent, + adaptTouchEvent, +}; diff --git a/src/index.ts b/src/index.ts index 4a28df0673..57c141bc82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -144,6 +144,9 @@ export type { export type { SwipeableProps } from './components/Swipeable'; export { default as Swipeable } from './components/Swipeable'; +export type { PressableProps } from './components/Pressable'; +export { default as Pressable } from './components/Pressable'; + export type { DrawerLayoutProps, DrawerPosition,