react-native-gesture-handler
Version:
Declarative API exposing native platform touch and gesture system to React Native
232 lines (231 loc) • 11.1 kB
JavaScript
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState, } from 'react';
import { GestureObjects as Gesture } from '../../handlers/gestures/gestureObjects';
import { GestureDetector } from '../../handlers/gestures/GestureDetector';
import { Platform, processColor, } from 'react-native';
import NativeButton from '../GestureHandlerButton';
import { gestureToPressableEvent, addInsets, numberAsInset, gestureTouchToPressableEvent, isTouchWithinInset, } from './utils';
import { PressabilityDebugView } from '../../handlers/PressabilityDebugView';
import { INT32_MAX, isFabric, isTestEnv } from '../../utils';
import { applyRelationProp, } from '../utils';
import { getConfiguredStateMachine, StateMachineEvent, } from './stateDefinitions';
const DEFAULT_LONG_PRESS_DURATION = 500;
const IS_TEST_ENV = isTestEnv();
let IS_FABRIC = null;
const Pressable = (props) => {
const { ref, testOnly_pressed, hitSlop, pressRetentionOffset, delayHoverIn, delayHoverOut, delayLongPress, unstable_pressDelay, onHoverIn, onHoverOut, onPress, onPressIn, onPressOut, onLongPress, style, children, android_disableSound, android_ripple, disabled, accessible, simultaneousWithExternalGesture, requireExternalGestureToFail, blocksExternalGesture, dimensionsAfterResize, ...remainingProps } = props;
const relationProps = {
simultaneousWithExternalGesture,
requireExternalGestureToFail,
blocksExternalGesture,
};
// used only if `ref` is undefined
const fallbackRef = useRef(null);
const [pressedState, setPressedState] = useState(testOnly_pressed ?? false);
const longPressTimeoutRef = useRef(null);
const pressDelayTimeoutRef = useRef(null);
const isOnPressAllowed = useRef(true);
const isCurrentlyPressed = useRef(false);
const dimensions = useRef({ width: 0, height: 0 });
const normalizedHitSlop = useMemo(() => typeof hitSlop === 'number' ? numberAsInset(hitSlop) : (hitSlop ?? {}), [hitSlop]);
const normalizedPressRetentionOffset = useMemo(() => typeof pressRetentionOffset === 'number'
? numberAsInset(pressRetentionOffset)
: (pressRetentionOffset ?? {}), [pressRetentionOffset]);
const appliedHitSlop = addInsets(normalizedHitSlop, normalizedPressRetentionOffset);
useLayoutEffect(() => {
if (dimensionsAfterResize) {
dimensions.current = dimensionsAfterResize;
}
else {
requestAnimationFrame(() => {
(ref ?? fallbackRef).current?.measure((_x, _y, width, height) => {
dimensions.current = {
width,
height,
};
});
});
}
}, [dimensionsAfterResize, ref]);
const cancelLongPress = useCallback(() => {
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
longPressTimeoutRef.current = null;
isOnPressAllowed.current = true;
}
}, []);
const cancelDelayedPress = useCallback(() => {
if (pressDelayTimeoutRef.current) {
clearTimeout(pressDelayTimeoutRef.current);
pressDelayTimeoutRef.current = null;
}
}, []);
const startLongPress = useCallback((event) => {
if (onLongPress) {
cancelLongPress();
longPressTimeoutRef.current = setTimeout(() => {
isOnPressAllowed.current = false;
onLongPress(event);
}, delayLongPress ?? DEFAULT_LONG_PRESS_DURATION);
}
}, [onLongPress, cancelLongPress, delayLongPress]);
const innerHandlePressIn = useCallback((event) => {
onPressIn?.(event);
startLongPress(event);
setPressedState(true);
if (pressDelayTimeoutRef.current) {
clearTimeout(pressDelayTimeoutRef.current);
pressDelayTimeoutRef.current = null;
}
}, [onPressIn, startLongPress]);
const handleFinalize = useCallback(() => {
isCurrentlyPressed.current = false;
cancelLongPress();
cancelDelayedPress();
setPressedState(false);
}, [cancelDelayedPress, cancelLongPress]);
const handlePressIn = useCallback((event) => {
if (!isTouchWithinInset(dimensions.current, normalizedHitSlop, event.nativeEvent.changedTouches.at(-1))) {
// Ignoring pressIn within pressRetentionOffset
return;
}
isCurrentlyPressed.current = true;
if (unstable_pressDelay) {
pressDelayTimeoutRef.current = setTimeout(() => {
innerHandlePressIn(event);
}, unstable_pressDelay);
}
else {
innerHandlePressIn(event);
}
}, [innerHandlePressIn, normalizedHitSlop, unstable_pressDelay]);
const handlePressOut = useCallback((event, success = true) => {
if (!isCurrentlyPressed.current) {
// Some prop configurations may lead to handlePressOut being called mutliple times.
return;
}
isCurrentlyPressed.current = false;
if (pressDelayTimeoutRef.current) {
innerHandlePressIn(event);
}
onPressOut?.(event);
if (isOnPressAllowed.current && success) {
onPress?.(event);
}
handleFinalize();
}, [handleFinalize, innerHandlePressIn, onPress, onPressOut]);
const stateMachine = useMemo(() => getConfiguredStateMachine(handlePressIn, handlePressOut), [handlePressIn, handlePressOut]);
const hoverInTimeout = useRef(null);
const hoverOutTimeout = useRef(null);
const hoverGesture = useMemo(() => Gesture.Hover()
.manualActivation(true) // Prevents Hover blocking Gesture.Native() on web
.cancelsTouchesInView(false)
.onBegin((event) => {
if (hoverOutTimeout.current) {
clearTimeout(hoverOutTimeout.current);
}
if (delayHoverIn) {
hoverInTimeout.current = setTimeout(() => onHoverIn?.(gestureToPressableEvent(event)), delayHoverIn);
return;
}
onHoverIn?.(gestureToPressableEvent(event));
})
.onFinalize((event) => {
if (hoverInTimeout.current) {
clearTimeout(hoverInTimeout.current);
}
if (delayHoverOut) {
hoverOutTimeout.current = setTimeout(() => onHoverOut?.(gestureToPressableEvent(event)), delayHoverOut);
return;
}
onHoverOut?.(gestureToPressableEvent(event));
}), [delayHoverIn, delayHoverOut, onHoverIn, onHoverOut]);
const pressAndTouchGesture = useMemo(() => Gesture.LongPress()
.minDuration(INT32_MAX) // Stops long press from blocking Gesture.Native()
.maxDistance(INT32_MAX) // Stops long press from cancelling on touch move
.cancelsTouchesInView(false)
.onTouchesDown((event) => {
const pressableEvent = gestureTouchToPressableEvent(event);
stateMachine.handleEvent(StateMachineEvent.LONG_PRESS_TOUCHES_DOWN, pressableEvent);
})
.onTouchesUp(() => {
if (Platform.OS === 'android') {
// Prevents potential soft-locks
stateMachine.reset();
handleFinalize();
}
})
.onTouchesCancelled((event) => {
const pressableEvent = gestureTouchToPressableEvent(event);
stateMachine.reset();
handlePressOut(pressableEvent, false);
})
.onFinalize(() => {
if (Platform.OS === 'web') {
stateMachine.handleEvent(StateMachineEvent.FINALIZE);
handleFinalize();
}
}), [stateMachine, handleFinalize, handlePressOut]);
// RNButton is placed inside ButtonGesture to enable Android's ripple and to capture non-propagating events
const buttonGesture = useMemo(() => Gesture.Native()
.onTouchesCancelled((event) => {
if (Platform.OS !== 'macos' && Platform.OS !== 'web') {
// On MacOS cancel occurs in middle of gesture
// On Web cancel occurs on mouse move, which is unwanted
const pressableEvent = gestureTouchToPressableEvent(event);
stateMachine.reset();
handlePressOut(pressableEvent, false);
}
})
.onBegin(() => {
stateMachine.handleEvent(StateMachineEvent.NATIVE_BEGIN);
})
.onStart(() => {
if (Platform.OS !== 'android') {
// Gesture.Native().onStart() is broken with Android + hitSlop
stateMachine.handleEvent(StateMachineEvent.NATIVE_START);
}
})
.onFinalize(() => {
if (Platform.OS !== 'web') {
// On Web we use LongPress().onFinalize() instead of Native().onFinalize(),
// as Native cancels on mouse move, and LongPress does not.
stateMachine.handleEvent(StateMachineEvent.FINALIZE);
handleFinalize();
}
}), [stateMachine, handlePressOut, handleFinalize]);
const isPressableEnabled = disabled !== true;
const gestures = [buttonGesture, pressAndTouchGesture, hoverGesture];
for (const gesture of gestures) {
gesture.enabled(isPressableEnabled);
gesture.runOnJS(true);
gesture.hitSlop(appliedHitSlop);
gesture.shouldCancelWhenOutside(Platform.OS !== 'web');
Object.entries(relationProps).forEach(([relationName, relation]) => {
applyRelationProp(gesture, relationName, relation);
});
}
const gesture = Gesture.Simultaneous(...gestures);
// `cursor: 'pointer'` on `RNButton` crashes iOS
const pointerStyle = Platform.OS === 'web' ? { cursor: 'pointer' } : {};
const styleProp = typeof style === 'function' ? style({ pressed: pressedState }) : style;
const childrenProp = typeof children === 'function'
? children({ pressed: pressedState })
: children;
const rippleColor = useMemo(() => {
if (IS_FABRIC === null) {
IS_FABRIC = isFabric();
}
const defaultRippleColor = android_ripple ? undefined : 'transparent';
const unprocessedRippleColor = android_ripple?.color ?? defaultRippleColor;
return IS_FABRIC
? unprocessedRippleColor
: processColor(unprocessedRippleColor);
}, [android_ripple]);
return (<GestureDetector gesture={gesture}>
<NativeButton {...remainingProps} ref={ref ?? fallbackRef} accessible={accessible !== false} hitSlop={appliedHitSlop} enabled={isPressableEnabled} touchSoundDisabled={android_disableSound ?? undefined} rippleColor={rippleColor} rippleRadius={android_ripple?.radius ?? undefined} style={[pointerStyle, styleProp]} testOnly_onPress={IS_TEST_ENV ? onPress : undefined} testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined} testOnly_onPressOut={IS_TEST_ENV ? onPressOut : undefined} testOnly_onLongPress={IS_TEST_ENV ? onLongPress : undefined}>
{childrenProp}
{__DEV__ ? (<PressabilityDebugView color="red" hitSlop={normalizedHitSlop}/>) : null}
</NativeButton>
</GestureDetector>);
};
export default Pressable;