|
| 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