SIGN IN SIGN UP

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