Implement `Pressable` based on `Touchable` (#4411)
## Description
This PR splits `Pressable` into two components - current `Pressable`
with state machine and new `Pressable` based on `Touchable`. Public API
remains unchanged - the split is internal. `Pressable` based on
`Touchable` is chosen by default. Old implementation is used only when
relation properties are passed.
## Test plan
Added an example under new_api > Tests > "Pressable engines (Touchable
vs Stateful)" that renders both engines side by side with identical
props and logs the callback order. Toggle each prop and confirm both
columns behave the same.
<details>
<summary>Example code</summary>
```tsx
import React, { useCallback, useRef, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import { ScrollView } from 'react-native-gesture-handler';
import type { PressableProps } from 'react-native-gesture-handler/src/components/Pressable/PressableProps';
// Internal engines imported directly so the two implementations can be compared
// side by side, independent of the public `Pressable` wrapper's routing.
import PressableWithTouchable from 'react-native-gesture-handler/src/v3/components/PressableWithTouchable';
import StatefulPressable from 'react-native-gesture-handler/src/v3/components/StatefulPressable';
type Engine = {
key: string;
label: string;
short: string;
Component: React.ComponentType<PressableProps>;
};
const ENGINES: Engine[] = [
{
key: 'stateful',
label: 'Stateful\n(relation props)',
short: 'STATEFUL',
Component: StatefulPressable,
},
{
key: 'touchable',
label: 'Touchable\n(default)',
short: 'TOUCHABLE',
Component: PressableWithTouchable,
},
];
type Toggle = {
label: string;
value: boolean;
onChange: (value: boolean) => void;
};
const ToggleRow = ({ label, value, onChange }: Toggle) => (
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>{label}</Text>
<Switch value={value} onValueChange={onChange} />
</View>
);
export default function PressableTouchableExample() {
const [log, setLog] = useState<string[]>([]);
const counter = useRef(0);
const [pressDelay, setPressDelay] = useState(false);
const [longPress, setLongPress] = useState(true);
const [disabled, setDisabled] = useState(false);
const [hitSlop, setHitSlop] = useState(false);
const [retention, setRetention] = useState(false);
const addLog = useCallback((engine: string, name: string) => {
// Capture the sequence number here, not inside the (deferred, batched)
// setLog updater — otherwise several updaters read the same later value.
const seq = (counter.current += 1);
setLog((prev) => [`${seq}. [${engine}] ${name}`, ...prev].slice(0, 60));
}, []);
const clearLog = useCallback(() => {
counter.current = 0;
setLog([]);
}, []);
return (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.hint}>
Press each box and compare the callback order in the log. Both columns
get identical props — the left runs the state-machine engine, the right
the native-button Touchable engine.
</Text>
<View style={styles.controls}>
<ToggleRow
label="unstable_pressDelay (300ms)"
value={pressDelay}
onChange={setPressDelay}
/>
<ToggleRow
label="onLongPress"
value={longPress}
onChange={setLongPress}
/>
<ToggleRow label="disabled" value={disabled} onChange={setDisabled} />
<ToggleRow label="hitSlop (20)" value={hitSlop} onChange={setHitSlop} />
<ToggleRow
label="pressRetentionOffset (40)"
value={retention}
onChange={setRetention}
/>
</View>
<View style={styles.columns}>
{ENGINES.map((engine) => {
const { Component } = engine;
return (
<View key={engine.key} style={styles.column}>
<Text style={styles.columnTitle}>{engine.label}</Text>
<Component
disabled={disabled}
unstable_pressDelay={pressDelay ? 300 : undefined}
delayLongPress={500}
hitSlop={hitSlop ? 20 : undefined}
pressRetentionOffset={retention ? 40 : undefined}
android_ripple={{ color: '#ffffff55' }}
onPressIn={() => addLog(engine.short, 'onPressIn')}
onPressOut={() => addLog(engine.short, 'onPressOut')}
onPress={() => addLog(engine.short, 'onPress')}
onLongPress={
longPress
? () => addLog(engine.short, 'onLongPress')
: undefined
}
onHoverIn={() => addLog(engine.short, 'onHoverIn')}
onHoverOut={() => addLog(engine.short, 'onHoverOut')}
style={({ pressed }) => [
styles.box,
{ backgroundColor: pressed ? '#2e7d32' : '#546e7a' },
disabled && styles.boxDisabled,
]}>
{({ pressed }) => (
<Text style={styles.boxText}>
{pressed ? 'PRESSED' : engine.short}
</Text>
)}
</Component>
</View>
);
})}
</View>
<View style={styles.logHeader}>
<Text style={styles.logTitle}>Event log (newest first)</Text>
<Text style={styles.clear} onPress={clearLog}>
clear
</Text>
</View>
<View style={styles.logBox}>
{log.length === 0 ? (
<Text style={styles.logEmpty}>No events yet</Text>
) : (
log.map((line) => (
<Text
key={line}
style={[
styles.logLine,
line.includes('STATEFUL')
? styles.logStateful
: styles.logTouchable,
]}>
{line}
</Text>
))
)}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
paddingTop: 40,
},
hint: {
fontSize: 13,
color: '#607d8b',
marginBottom: 12,
},
controls: {
marginBottom: 16,
},
toggleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 4,
},
toggleLabel: {
fontSize: 15,
color: '#37474f',
fontFamily: 'monospace',
},
columns: {
flexDirection: 'row',
gap: 12,
},
column: {
flex: 1,
alignItems: 'center',
},
columnTitle: {
fontSize: 13,
fontWeight: '600',
textAlign: 'center',
marginBottom: 8,
color: '#455a64',
},
box: {
width: '100%',
height: 90,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
boxDisabled: {
opacity: 0.4,
},
boxText: {
color: 'white',
fontWeight: '700',
letterSpacing: 1,
},
logHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 20,
marginBottom: 6,
},
logTitle: {
fontSize: 14,
fontWeight: '600',
color: '#37474f',
},
clear: {
fontSize: 14,
color: '#1976d2',
padding: 4,
},
logBox: {
minHeight: 120,
backgroundColor: '#eceff1',
borderRadius: 8,
padding: 10,
},
logEmpty: {
color: '#90a4ae',
fontStyle: 'italic',
},
logLine: {
fontFamily: 'monospace',
fontSize: 13,
paddingVertical: 1,
},
logStateful: {
color: '#6a1b9a',
},
logTouchable: {
color: '#00695c',
},
});
```
</details> M
Michał Bert committed
8b661c90ed9c40e4eeea333f4c51ad866928f212
Parent: cb0f953
Committed by GitHub <noreply@github.com>
on 8/13/2026, 8:16:29 AM