UNPKG

react-native-gesture-handler

Version:

Experimental implementation of a new declarative API for gesture handling in react-native

309 lines (252 loc) 13.5 kB
import React, { useCallback, useMemo, useRef, useState } from 'react'; import { GestureObjects as Gesture } from '../../handlers/gestures/gestureObjects'; import { GestureDetector } from '../../handlers/gestures/GestureDetector'; import { Platform, View, processColor, StyleSheet } from 'react-native'; import NativeButton from '../GestureHandlerButton'; import { numberAsInset, gestureToPressableEvent, isTouchWithinInset, gestureTouchToPressableEvent, addInsets, splitStyles } from './utils'; import { PressabilityDebugView } from '../../handlers/PressabilityDebugView'; const DEFAULT_LONG_PRESS_DURATION = 500; export default function Pressable(props) { var _props$testOnly_press, _props$delayLongPress, _props$unstable_press, _props$android_disabl, _props$android_ripple, _props$android_ripple2, _props$android_ripple3, _props$android_ripple4; const [pressedState, setPressedState] = useState((_props$testOnly_press = props.testOnly_pressed) !== null && _props$testOnly_press !== void 0 ? _props$testOnly_press : false); const pressableRef = useRef(null); // Disabled when onLongPress has been called const isPressCallbackEnabled = useRef(true); const hasPassedBoundsChecks = useRef(false); const shouldPreventNativeEffects = useRef(false); const normalizedHitSlop = useMemo(() => { var _props$hitSlop; return typeof props.hitSlop === 'number' ? numberAsInset(props.hitSlop) : (_props$hitSlop = props.hitSlop) !== null && _props$hitSlop !== void 0 ? _props$hitSlop : {}; }, [props.hitSlop]); const normalizedPressRetentionOffset = useMemo(() => { var _props$pressRetention; return typeof props.pressRetentionOffset === 'number' ? numberAsInset(props.pressRetentionOffset) : (_props$pressRetention = props.pressRetentionOffset) !== null && _props$pressRetention !== void 0 ? _props$pressRetention : {}; }, [props.pressRetentionOffset]); const hoverInTimeout = useRef(null); const hoverOutTimeout = useRef(null); const hoverGesture = useMemo(() => Gesture.Hover().manualActivation(true) // Stops Hover from blocking Native gesture activation on web .cancelsTouchesInView(false).onBegin(event => { var _props$onHoverIn2; if (hoverOutTimeout.current) { clearTimeout(hoverOutTimeout.current); } if (props.delayHoverIn) { hoverInTimeout.current = setTimeout(() => { var _props$onHoverIn; return (_props$onHoverIn = props.onHoverIn) === null || _props$onHoverIn === void 0 ? void 0 : _props$onHoverIn.call(props, gestureToPressableEvent(event)); }, props.delayHoverIn); return; } (_props$onHoverIn2 = props.onHoverIn) === null || _props$onHoverIn2 === void 0 ? void 0 : _props$onHoverIn2.call(props, gestureToPressableEvent(event)); }).onFinalize(event => { var _props$onHoverOut2; if (hoverInTimeout.current) { clearTimeout(hoverInTimeout.current); } if (props.delayHoverOut) { hoverOutTimeout.current = setTimeout(() => { var _props$onHoverOut; return (_props$onHoverOut = props.onHoverOut) === null || _props$onHoverOut === void 0 ? void 0 : _props$onHoverOut.call(props, gestureToPressableEvent(event)); }, props.delayHoverOut); return; } (_props$onHoverOut2 = props.onHoverOut) === null || _props$onHoverOut2 === void 0 ? void 0 : _props$onHoverOut2.call(props, gestureToPressableEvent(event)); }), [props]); const pressDelayTimeoutRef = useRef(null); const isTouchPropagationAllowed = useRef(false); // iOS only: due to varying flow of gestures, events sometimes have to be saved for later use const deferredEventPayload = useRef(null); const pressInHandler = useCallback(event => { var _props$onPressIn; if (handlingOnTouchesDown.current) { deferredEventPayload.current = event; } if (!isTouchPropagationAllowed.current) { return; } deferredEventPayload.current = null; (_props$onPressIn = props.onPressIn) === null || _props$onPressIn === void 0 ? void 0 : _props$onPressIn.call(props, event); isPressCallbackEnabled.current = true; pressDelayTimeoutRef.current = null; setPressedState(true); }, [props]); const pressOutHandler = useCallback(event => { var _props$onPressOut; if (!hasPassedBoundsChecks.current || event.nativeEvent.touches.length > event.nativeEvent.changedTouches.length) { return; } if (props.unstable_pressDelay && pressDelayTimeoutRef.current !== null) { // When delay is preemptively finished by lifting touches, // we want to immediately activate it's effects - pressInHandler, // even though we are located at the pressOutHandler clearTimeout(pressDelayTimeoutRef.current); pressInHandler(event); } if (deferredEventPayload.current) { var _props$onPressIn2; (_props$onPressIn2 = props.onPressIn) === null || _props$onPressIn2 === void 0 ? void 0 : _props$onPressIn2.call(props, deferredEventPayload.current); deferredEventPayload.current = null; } (_props$onPressOut = props.onPressOut) === null || _props$onPressOut === void 0 ? void 0 : _props$onPressOut.call(props, event); if (isPressCallbackEnabled.current) { var _props$onPress; (_props$onPress = props.onPress) === null || _props$onPress === void 0 ? void 0 : _props$onPress.call(props, event); } if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.current); longPressTimeoutRef.current = null; } isTouchPropagationAllowed.current = false; hasPassedBoundsChecks.current = false; isPressCallbackEnabled.current = true; setPressedState(false); }, [pressInHandler, props]); const handlingOnTouchesDown = useRef(false); const onEndHandlingTouchesDown = useRef(null); const cancelledMidPress = useRef(false); const activateLongPress = useCallback(event => { if (!isTouchPropagationAllowed.current) { return; } if (hasPassedBoundsChecks.current) { var _props$onLongPress; (_props$onLongPress = props.onLongPress) === null || _props$onLongPress === void 0 ? void 0 : _props$onLongPress.call(props, gestureTouchToPressableEvent(event)); isPressCallbackEnabled.current = false; } if (longPressTimeoutRef.current) { clearTimeout(longPressTimeoutRef.current); longPressTimeoutRef.current = null; } }, [props]); const longPressTimeoutRef = useRef(null); const longPressMinDuration = ((_props$delayLongPress = props.delayLongPress) !== null && _props$delayLongPress !== void 0 ? _props$delayLongPress : DEFAULT_LONG_PRESS_DURATION) + ((_props$unstable_press = props.unstable_pressDelay) !== null && _props$unstable_press !== void 0 ? _props$unstable_press : 0); const pressAndTouchGesture = useMemo(() => Gesture.LongPress().minDuration(Number.MAX_SAFE_INTEGER) // Stops long press from blocking native gesture .maxDistance(Number.MAX_SAFE_INTEGER) // Stops long press from cancelling after set distance .cancelsTouchesInView(false).onTouchesDown(event => { var _pressableRef$current; handlingOnTouchesDown.current = true; (_pressableRef$current = pressableRef.current) === null || _pressableRef$current === void 0 ? void 0 : _pressableRef$current.measure((_x, _y, width, height) => { var _onEndHandlingTouches; if (!isTouchWithinInset({ width, height }, normalizedHitSlop, event.changedTouches.at(-1)) || hasPassedBoundsChecks.current || cancelledMidPress.current) { cancelledMidPress.current = false; onEndHandlingTouchesDown.current = null; handlingOnTouchesDown.current = false; return; } hasPassedBoundsChecks.current = true; // In case of multiple touches, the first one starts long press gesture if (longPressTimeoutRef.current === null) { // Start long press gesture timer longPressTimeoutRef.current = setTimeout(() => activateLongPress(event), longPressMinDuration); } if (props.unstable_pressDelay) { pressDelayTimeoutRef.current = setTimeout(() => { pressInHandler(gestureTouchToPressableEvent(event)); }, props.unstable_pressDelay); } else { pressInHandler(gestureTouchToPressableEvent(event)); } (_onEndHandlingTouches = onEndHandlingTouchesDown.current) === null || _onEndHandlingTouches === void 0 ? void 0 : _onEndHandlingTouches.call(onEndHandlingTouchesDown); onEndHandlingTouchesDown.current = null; handlingOnTouchesDown.current = false; }); }).onTouchesUp(event => { if (handlingOnTouchesDown.current) { onEndHandlingTouchesDown.current = () => pressOutHandler(gestureTouchToPressableEvent(event)); return; } // On iOS, short taps will make LongPress gesture call onTouchesUp before Native gesture calls onStart // This variable ensures that onStart isn't detected as the first gesture since Pressable is pressed. if (deferredEventPayload.current !== null) { shouldPreventNativeEffects.current = true; } pressOutHandler(gestureTouchToPressableEvent(event)); }).onTouchesCancelled(event => { isPressCallbackEnabled.current = false; if (handlingOnTouchesDown.current) { cancelledMidPress.current = true; onEndHandlingTouchesDown.current = () => pressOutHandler(gestureTouchToPressableEvent(event)); return; } if (!hasPassedBoundsChecks.current || event.allTouches.length > event.changedTouches.length) { return; } pressOutHandler(gestureTouchToPressableEvent(event)); }), [activateLongPress, longPressMinDuration, normalizedHitSlop, pressInHandler, pressOutHandler, props.unstable_pressDelay]); // RNButton is placed inside ButtonGesture to enable Android's ripple and to capture non-propagating events const buttonGesture = useMemo(() => Gesture.Native().onBegin(() => { // Android sets BEGAN state on press down if (Platform.OS === 'android') { isTouchPropagationAllowed.current = true; } }).onStart(() => { if (Platform.OS === 'web') { isTouchPropagationAllowed.current = true; } // iOS sets ACTIVE state on press down if (Platform.OS !== 'ios') { return; } if (deferredEventPayload.current) { isTouchPropagationAllowed.current = true; if (hasPassedBoundsChecks.current) { pressInHandler(deferredEventPayload.current); deferredEventPayload.current = null; } else { pressOutHandler(deferredEventPayload.current); isTouchPropagationAllowed.current = false; } return; } if (hasPassedBoundsChecks.current) { isTouchPropagationAllowed.current = true; return; } if (shouldPreventNativeEffects.current) { shouldPreventNativeEffects.current = false; return; } isTouchPropagationAllowed.current = true; }), [pressInHandler, pressOutHandler]); const appliedHitSlop = addInsets(normalizedHitSlop, normalizedPressRetentionOffset); const isPressableEnabled = props.disabled !== true; const gestures = [pressAndTouchGesture, hoverGesture, buttonGesture]; for (const gesture of gestures) { gesture.enabled(isPressableEnabled); gesture.runOnJS(true); gesture.hitSlop(appliedHitSlop); gesture.shouldCancelWhenOutside(false); if (Platform.OS !== 'web') { gesture.shouldCancelWhenOutside(true); } } // Uses different hitSlop, to activate on hitSlop area instead of pressRetentionOffset area buttonGesture.hitSlop(normalizedHitSlop); const gesture = Gesture.Simultaneous(...gestures); const defaultRippleColor = props.android_ripple ? undefined : 'transparent'; // `cursor: 'pointer'` on `RNButton` crashes iOS const pointerStyle = Platform.OS === 'web' ? { cursor: 'pointer' } : {}; const styleProp = typeof props.style === 'function' ? props.style({ pressed: pressedState }) : props.style; const childrenProp = typeof props.children === 'function' ? props.children({ pressed: pressedState }) : props.children; const flattenedStyles = StyleSheet.flatten(styleProp !== null && styleProp !== void 0 ? styleProp : {}); const [innerStyles, outerStyles] = splitStyles(flattenedStyles); return /*#__PURE__*/React.createElement(View, { style: outerStyles }, /*#__PURE__*/React.createElement(GestureDetector, { gesture: gesture }, /*#__PURE__*/React.createElement(NativeButton, { ref: pressableRef, testID: props.testID, hitSlop: appliedHitSlop, enabled: isPressableEnabled, touchSoundDisabled: (_props$android_disabl = props.android_disableSound) !== null && _props$android_disabl !== void 0 ? _props$android_disabl : undefined, rippleColor: processColor((_props$android_ripple = (_props$android_ripple2 = props.android_ripple) === null || _props$android_ripple2 === void 0 ? void 0 : _props$android_ripple2.color) !== null && _props$android_ripple !== void 0 ? _props$android_ripple : defaultRippleColor), rippleRadius: (_props$android_ripple3 = (_props$android_ripple4 = props.android_ripple) === null || _props$android_ripple4 === void 0 ? void 0 : _props$android_ripple4.radius) !== null && _props$android_ripple3 !== void 0 ? _props$android_ripple3 : undefined, style: [StyleSheet.absoluteFill, pointerStyle, innerStyles] }, childrenProp, __DEV__ ? /*#__PURE__*/React.createElement(PressabilityDebugView, { color: "red", hitSlop: normalizedHitSlop }) : null))); } //# sourceMappingURL=Pressable.js.map