Skip to content

Commit 59989dd

Browse files
committed
Make the new impl Android-only
1 parent 77ff2c0 commit 59989dd

2 files changed

Lines changed: 340 additions & 75 deletions

File tree

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import React, { use, useCallback, useRef, useState } from 'react';
2+
import type { NativeSyntheticEvent } from 'react-native';
3+
import { Platform } from 'react-native';
4+
5+
import GestureHandlerButton, {
6+
type ButtonEvent,
7+
} from '../../../components/GestureHandlerButton';
8+
import { getTVProps } from '../../../components/utils';
9+
import { getNextHandlerTag } from '../../../handlers/getNextHandlerTag';
10+
import {
11+
isKeyboardDismissingTap,
12+
JSResponderContext,
13+
} from '../ScrollViewResponderInterceptor';
14+
import type { AnimationDuration, TouchableProps } from './TouchableProps';
15+
16+
const isAndroid = Platform.OS === 'android';
17+
const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const };
18+
const DEFAULT_IN_DURATION_MS = 50;
19+
const DEFAULT_OUT_DURATION_MS = 100;
20+
21+
// Clamp user-supplied durations to finite, non-negative milliseconds.
22+
// Negative, NaN, or Infinity values would produce invalid CSS transitions
23+
// on web and negative setTimeout delays in branch 3 of the press-out path.
24+
function sanitizeDuration(value: number): number {
25+
return Number.isFinite(value) && value >= 0 ? value : 0;
26+
}
27+
28+
function resolveAnimationDuration(value: AnimationDuration | undefined) {
29+
if (value === undefined) {
30+
return {
31+
tapAnimationInDuration: DEFAULT_IN_DURATION_MS,
32+
tapAnimationOutDuration: DEFAULT_OUT_DURATION_MS,
33+
longPressAnimationOutDuration: DEFAULT_OUT_DURATION_MS,
34+
hoverAnimationInDuration: DEFAULT_IN_DURATION_MS,
35+
hoverAnimationOutDuration: DEFAULT_OUT_DURATION_MS,
36+
};
37+
}
38+
39+
if (typeof value === 'number') {
40+
const sanitized = sanitizeDuration(value);
41+
return {
42+
tapAnimationInDuration: sanitized,
43+
tapAnimationOutDuration: sanitized,
44+
longPressAnimationOutDuration: sanitized,
45+
hoverAnimationInDuration: sanitized,
46+
hoverAnimationOutDuration: sanitized,
47+
};
48+
}
49+
50+
// The union guarantees variant 2 supplies top-level `in`/`out`, variant 3
51+
// supplies both category objects — so per-category fallback to base is
52+
// always defined for well-typed input; the 0 fallbacks here are unreachable.
53+
const baseIn = 'in' in value ? value.in : 0;
54+
const baseOut = 'out' in value ? value.out : 0;
55+
const tapOut = value.tap?.out ?? baseOut;
56+
57+
return {
58+
tapAnimationInDuration: sanitizeDuration(value.tap?.in ?? baseIn),
59+
tapAnimationOutDuration: sanitizeDuration(tapOut),
60+
longPressAnimationOutDuration: sanitizeDuration(
61+
value.longPress?.out ?? tapOut
62+
),
63+
hoverAnimationInDuration: sanitizeDuration(value.hover?.in ?? baseIn),
64+
hoverAnimationOutDuration: sanitizeDuration(value.hover?.out ?? baseOut),
65+
};
66+
}
67+
68+
export const Touchable = (props: TouchableProps) => {
69+
const {
70+
underlayColor = 'transparent',
71+
defaultUnderlayOpacity = 0,
72+
activeUnderlayOpacity = 0.105,
73+
defaultOpacity = 1,
74+
animationDuration,
75+
androidRipple,
76+
delayLongPress = 600,
77+
onLongPress,
78+
onPress,
79+
onPressIn,
80+
onPressOut,
81+
children,
82+
disabled = false,
83+
cancelOnLeave = true,
84+
ref,
85+
...rest
86+
} = props;
87+
const [handlerTag] = useState(() => getNextHandlerTag());
88+
89+
const resolvedDurations = resolveAnimationDuration(animationDuration);
90+
const resolvedDelayLongPress = sanitizeDuration(delayLongPress);
91+
92+
const shouldUseNativeRipple = isAndroid && androidRipple !== undefined;
93+
94+
const jsResponderContext = use(JSResponderContext);
95+
const dropKeyboardTapRef = useRef<boolean | null>(null);
96+
97+
const internalOnPress = useCallback(
98+
(e: NativeSyntheticEvent<ButtonEvent>) => {
99+
if (dropKeyboardTapRef.current) {
100+
return;
101+
}
102+
103+
onPress?.(e.nativeEvent);
104+
},
105+
[onPress]
106+
);
107+
108+
const internalOnPressIn = useCallback(
109+
(e: NativeSyntheticEvent<ButtonEvent>) => {
110+
// PressIn opens every press sequence; capture the verdict once per
111+
// sequence so a re-entry PressIn (with cancelOnLeave={false}) doesn't
112+
// overwrite it after the keyboard is already dismissed.
113+
dropKeyboardTapRef.current ??=
114+
isKeyboardDismissingTap(jsResponderContext);
115+
116+
if (dropKeyboardTapRef.current) {
117+
return;
118+
}
119+
120+
onPressIn?.(e.nativeEvent);
121+
},
122+
[jsResponderContext, onPressIn]
123+
);
124+
125+
const internalOnPressOut = useCallback(
126+
(e: NativeSyntheticEvent<ButtonEvent>) => {
127+
if (dropKeyboardTapRef.current) {
128+
return;
129+
}
130+
131+
onPressOut?.(e.nativeEvent);
132+
},
133+
[onPressOut]
134+
);
135+
136+
const internalOnLongPress = useCallback(
137+
(e: NativeSyntheticEvent<ButtonEvent>) => {
138+
if (dropKeyboardTapRef.current) {
139+
return;
140+
}
141+
142+
onLongPress?.(e.nativeEvent);
143+
},
144+
[onLongPress]
145+
);
146+
147+
// InteractionFinished is dispatched after the terminal PressOut/Press
148+
// events, so resetting synchronously here is safe.
149+
const internalOnInteractionFinished = useCallback(() => {
150+
dropKeyboardTapRef.current = null;
151+
}, []);
152+
153+
const rippleProps = shouldUseNativeRipple
154+
? {
155+
rippleColor: androidRipple?.color,
156+
rippleRadius: androidRipple?.radius,
157+
borderless: androidRipple?.borderless,
158+
foreground: androidRipple?.foreground,
159+
}
160+
: TRANSPARENT_RIPPLE;
161+
162+
const tvProps = getTVProps(rest);
163+
164+
const hitSlop =
165+
typeof props.hitSlop === 'number'
166+
? {
167+
top: props.hitSlop,
168+
left: props.hitSlop,
169+
bottom: props.hitSlop,
170+
right: props.hitSlop,
171+
}
172+
: (props.hitSlop ?? undefined);
173+
174+
return (
175+
<GestureHandlerButton
176+
{...rest}
177+
{...tvProps}
178+
{...rippleProps}
179+
{...resolvedDurations}
180+
ref={ref ?? null}
181+
enabled={!disabled}
182+
handlerTag={handlerTag}
183+
cancelOnLeave={cancelOnLeave}
184+
gestureTestID={props.testID}
185+
gestureHitSlop={hitSlop}
186+
defaultOpacity={defaultOpacity}
187+
defaultUnderlayOpacity={defaultUnderlayOpacity}
188+
activeUnderlayOpacity={activeUnderlayOpacity}
189+
underlayColor={underlayColor}
190+
longPressDuration={resolvedDelayLongPress}
191+
hasLongPressHandler={onLongPress !== undefined}
192+
onPress={internalOnPress}
193+
onPressIn={internalOnPressIn}
194+
onPressOut={internalOnPressOut}
195+
onLongPress={internalOnLongPress}
196+
onInteractionFinished={internalOnInteractionFinished}>
197+
{children}
198+
</GestureHandlerButton>
199+
);
200+
};

0 commit comments

Comments
 (0)