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,