import type { ForwardedRef } from 'react'; import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, } from 'react'; import type { LayoutChangeEvent } from 'react-native'; import { I18nManager, Platform, StyleSheet, View } from 'react-native'; import Animated, { interpolate, isSharedValue, measure, ReduceMotion, useAnimatedRef, useAnimatedStyle, useSharedValue, withSpring, } from 'react-native-reanimated'; import { isWorkletRuntime, scheduleOnRN, scheduleOnUI, } from 'react-native-worklets'; import { tagMessage } from '../../utils'; import { GestureDetector } from '../../v3/detectors'; import type { PanGestureActiveEvent } from '../../v3/hooks/gestures'; import { usePanGesture, useTapGesture } from '../../v3/hooks/gestures'; import { maybeUnpackValue, SHARED_VALUE_OFFSET, } from '../../v3/hooks/utils/reanimatedUtils'; import type { SharedValueOrT } from '../../v3/types'; import type { SwipeableMethods, SwipeableProps, } from './ReanimatedSwipeableProps'; import { SwipeDirection } from './ReanimatedSwipeableProps'; const DRAG_TOSS = 0.05; const DEFAULT_FRICTION = 1; const DEFAULT_OVERSHOOT_FRICTION = 1; const DEFAULT_DRAG_OFFSET = 10; const DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE = false; function useEventCallback( callback: ((...args: Args) => void) | undefined ): ((...args: Args) => void) | undefined { const callbackRef = useRef(callback); callbackRef.current = callback; const stableCallback = useCallback((...args: Args) => { callbackRef.current?.(...args); }, []); // Keep a stable wrapper only while a user callback exists, so the existing // truthiness checks can still skip `scheduleOnRN` when the prop is absent. return callback ? stableCallback : undefined; } const Swipeable = (props: SwipeableProps) => { const { ref, leftThreshold, rightThreshold, enabled, containerStyle, childrenContainerStyle, animationOptions, overshootLeft, overshootRight, testID, children, enableTrackpadTwoFingerGesture = DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE, dragOffsetFromLeft = DEFAULT_DRAG_OFFSET, dragOffsetFromRight = -DEFAULT_DRAG_OFFSET, friction = DEFAULT_FRICTION, overshootFriction = DEFAULT_OVERSHOOT_FRICTION, onSwipeableOpenStartDrag: onSwipeableOpenStartDragProp, onSwipeableCloseStartDrag: onSwipeableCloseStartDragProp, onSwipeableWillOpen: onSwipeableWillOpenProp, onSwipeableWillClose: onSwipeableWillCloseProp, onSwipeableOpen: onSwipeableOpenProp, onSwipeableClose: onSwipeableCloseProp, renderLeftActions, renderRightActions, simultaneousWith, requireToFail, block, hitSlop, ...remainingProps } = props; const onSwipeableOpenStartDrag = useEventCallback( onSwipeableOpenStartDragProp ); const onSwipeableCloseStartDrag = useEventCallback( onSwipeableCloseStartDragProp ); const onSwipeableWillOpen = useEventCallback(onSwipeableWillOpenProp); const onSwipeableWillClose = useEventCallback(onSwipeableWillCloseProp); const onSwipeableOpen = useEventCallback(onSwipeableOpenProp); const onSwipeableClose = useEventCallback(onSwipeableCloseProp); if (__DEV__) { const checkValue = (value: SharedValueOrT) => { 'worklet'; if (maybeUnpackValue(value) > 0) { throw new Error( tagMessage('dragOffsetFromRight should be non-positive.') ); } }; checkValue(dragOffsetFromRight); // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { if (!isSharedValue(dragOffsetFromRight)) { return; } const listenerId = Math.random() + SHARED_VALUE_OFFSET; scheduleOnUI(() => { 'worklet'; dragOffsetFromRight.addListener(listenerId, checkValue); }); return () => { scheduleOnUI(() => { 'worklet'; dragOffsetFromRight.removeListener(listenerId); }); }; }, [dragOffsetFromRight, checkValue]); } const shouldEnableTap = useSharedValue(false); const rowState = useSharedValue(0); const userDrag = useSharedValue(0); const appliedTranslation = useSharedValue(0); const rowWidth = useSharedValue(0); const leftWidth = useSharedValue(0); const rightWidth = useSharedValue(0); const showLeftProgress = useSharedValue(0); const showRightProgress = useSharedValue(0); const updateAnimatedEvent = useCallback(() => { 'worklet'; const shouldOvershootLeft = overshootLeft ?? leftWidth.value > 0; const shouldOvershootRight = overshootRight ?? rightWidth.value > 0; const startOffset = rowState.value === 1 ? leftWidth.value : rowState.value === -1 ? -rightWidth.value : 0; const offsetDrag = userDrag.value / friction + startOffset; appliedTranslation.value = interpolate( offsetDrag, [ -rightWidth.value - 1, -rightWidth.value, leftWidth.value, leftWidth.value + 1, ], [ -rightWidth.value - (shouldOvershootRight ? 1 / overshootFriction : 0), -rightWidth.value, leftWidth.value, leftWidth.value + (shouldOvershootLeft ? 1 / overshootFriction : 0), ] ); showLeftProgress.value = leftWidth.value > 0 ? interpolate( appliedTranslation.value, [-1, 0, leftWidth.value], [0, 0, 1] ) : 0; showRightProgress.value = rightWidth.value > 0 ? interpolate( appliedTranslation.value, [-rightWidth.value, 0, 1], [1, 0, 0] ) : 0; }, [ appliedTranslation, friction, leftWidth, overshootFriction, rightWidth, rowState, showLeftProgress, showRightProgress, userDrag, overshootLeft, overshootRight, ]); const dispatchImmediateEvents = useCallback( (fromValue: number, toValue: number) => { 'worklet'; if (onSwipeableWillOpen && toValue !== 0) { scheduleOnRN( onSwipeableWillOpen, toValue > 0 ? SwipeDirection.RIGHT : SwipeDirection.LEFT ); } if (onSwipeableWillClose && toValue === 0) { scheduleOnRN( onSwipeableWillClose, fromValue > 0 ? SwipeDirection.LEFT : SwipeDirection.RIGHT ); } }, [onSwipeableWillClose, onSwipeableWillOpen] ); const dispatchEndEvents = useCallback( (fromValue: number, toValue: number) => { 'worklet'; if (onSwipeableOpen && toValue !== 0) { scheduleOnRN( onSwipeableOpen, toValue > 0 ? SwipeDirection.RIGHT : SwipeDirection.LEFT ); } if (onSwipeableClose && toValue === 0) { scheduleOnRN( onSwipeableClose, fromValue > 0 ? SwipeDirection.LEFT : SwipeDirection.RIGHT ); } }, [onSwipeableClose, onSwipeableOpen] ); const animateRow: (toValue: number, velocityX?: number) => void = useCallback( (toValue: number, velocityX = 0) => { 'worklet'; const translationSpringConfig = { mass: 2, damping: 1000, stiffness: 700, velocity: velocityX, overshootClamping: true, reduceMotion: ReduceMotion.System, ...animationOptions, }; const isClosing = toValue === 0; const moveToRight = isClosing ? rowState.value < 0 : toValue > 0; const usedWidth = isClosing ? moveToRight ? rightWidth.value : leftWidth.value : moveToRight ? leftWidth.value : rightWidth.value; const progressSpringConfig = { ...translationSpringConfig, restDisplacementThreshold: 0.01, restSpeedThreshold: 0.01, velocity: velocityX && interpolate(velocityX, [-usedWidth, usedWidth], [-1, 1]), }; const frozenRowState = rowState.value; appliedTranslation.value = withSpring( toValue, translationSpringConfig, (isFinished) => { if (isFinished) { dispatchEndEvents(frozenRowState, toValue); } } ); const progressTarget = toValue === 0 ? 0 : 1 * Math.sign(toValue); showLeftProgress.value = withSpring( Math.max(progressTarget, 0), progressSpringConfig ); showRightProgress.value = withSpring( Math.max(-progressTarget, 0), progressSpringConfig ); dispatchImmediateEvents(frozenRowState, toValue); rowState.value = Math.sign(toValue); shouldEnableTap.value = rowState.value !== 0; }, [ rowState, animationOptions, appliedTranslation, showLeftProgress, leftWidth, showRightProgress, rightWidth, dispatchImmediateEvents, dispatchEndEvents, ] ); const leftLayoutRef = useAnimatedRef(); const leftWrapperLayoutRef = useAnimatedRef(); const rightLayoutRef = useAnimatedRef(); const updateElementWidths = useCallback(() => { 'worklet'; const leftLayout = measure(leftLayoutRef); const leftWrapperLayout = measure(leftWrapperLayoutRef); const rightLayout = measure(rightLayoutRef); leftWidth.value = (leftLayout?.pageX ?? 0) - (leftWrapperLayout?.pageX ?? 0); rightWidth.value = rowWidth.value - (rightLayout?.pageX ?? rowWidth.value) + (leftWrapperLayout?.pageX ?? 0); }, [ leftLayoutRef, leftWrapperLayoutRef, rightLayoutRef, leftWidth, rightWidth, rowWidth, ]); const swipeableMethods = useMemo( () => ({ close() { 'worklet'; if (isWorkletRuntime()) { animateRow(0); return; } scheduleOnUI(() => { animateRow(0); }); }, openLeft() { 'worklet'; if (isWorkletRuntime()) { updateElementWidths(); animateRow(leftWidth.value); return; } scheduleOnUI(() => { updateElementWidths(); animateRow(leftWidth.value); }); }, openRight() { 'worklet'; if (isWorkletRuntime()) { updateElementWidths(); animateRow(-rightWidth.value); return; } scheduleOnUI(() => { updateElementWidths(); animateRow(-rightWidth.value); }); }, reset() { 'worklet'; userDrag.value = 0; showLeftProgress.value = 0; appliedTranslation.value = 0; rowState.value = 0; }, }), [ animateRow, updateElementWidths, leftWidth, rightWidth, userDrag, showLeftProgress, appliedTranslation, rowState, ] ); const onRowLayout = useCallback( ({ nativeEvent }: LayoutChangeEvent) => { rowWidth.value = nativeEvent.layout.width; }, [rowWidth] ); // As stated in `Dimensions.get` docstring, this function should be called on every render // since dimensions may change (e.g. orientation change) const leftActionAnimation = useAnimatedStyle(() => { return { // Both action containers use `absoluteFill` and overlap, so the // inactive one must not intercept touches meant for the visible // actions. pointerEvents: showLeftProgress.value === 0 ? 'none' : 'auto', }; }); const leftElement = useCallback( () => ( {renderLeftActions?.( showLeftProgress, appliedTranslation, swipeableMethods )} ), [ appliedTranslation, leftActionAnimation, leftLayoutRef, leftWrapperLayoutRef, renderLeftActions, showLeftProgress, swipeableMethods, ] ); const rightActionAnimation = useAnimatedStyle(() => { return { // Both action containers use `absoluteFill` and overlap, so the // inactive one must not intercept touches meant for the visible // actions. pointerEvents: showRightProgress.value === 0 ? 'none' : 'auto', }; }); const rightElement = useCallback( () => ( {renderRightActions?.( showRightProgress, appliedTranslation, swipeableMethods )} ), [ appliedTranslation, renderRightActions, rightActionAnimation, rightLayoutRef, showRightProgress, swipeableMethods, ] ); const handleRelease = useCallback( (event: PanGestureActiveEvent) => { 'worklet'; const { velocityX } = event; userDrag.value = event.translationX; const leftThresholdProp = leftThreshold ?? leftWidth.value / 2; const rightThresholdProp = rightThreshold ?? rightWidth.value / 2; const translationX = (userDrag.value + DRAG_TOSS * velocityX) / friction; let toValue = 0; if (rowState.value === 0) { if (translationX > leftThresholdProp) { toValue = leftWidth.value; } else if (translationX < -rightThresholdProp) { toValue = -rightWidth.value; } } else if (rowState.value === 1) { // Swiped to left if (translationX > -leftThresholdProp) { toValue = leftWidth.value; } } else { // Swiped to right if (translationX < rightThresholdProp) { toValue = -rightWidth.value; } } animateRow(toValue, velocityX / friction); }, [ animateRow, friction, leftThreshold, leftWidth, rightThreshold, rightWidth, rowState, userDrag, ] ); const close = useCallback(() => { 'worklet'; animateRow(0); }, [animateRow]); const dragStarted = useSharedValue(false); const handleFinalize = useCallback(() => { 'worklet'; dragStarted.value = false; }, [dragStarted]); const handleUpdate = useCallback( (event: PanGestureActiveEvent) => { 'worklet'; userDrag.value = event.translationX; const direction = rowState.value === -1 ? SwipeDirection.RIGHT : rowState.value === 1 ? SwipeDirection.LEFT : event.translationX > 0 ? SwipeDirection.RIGHT : SwipeDirection.LEFT; if (!dragStarted.value) { dragStarted.value = true; if (rowState.value === 0 && onSwipeableOpenStartDrag) { scheduleOnRN(onSwipeableOpenStartDrag, direction); } else if (onSwipeableCloseStartDrag) { scheduleOnRN(onSwipeableCloseStartDrag, direction); } } updateAnimatedEvent(); }, [ dragStarted, onSwipeableCloseStartDrag, onSwipeableOpenStartDrag, rowState, updateAnimatedEvent, userDrag, ] ); const tapConfig = useMemo( () => ({ shouldCancelWhenOutside: true, enabled: shouldEnableTap, simultaneousWith, requireToFail, block, onActivate: () => { 'worklet'; if (rowState.value !== 0) { close(); } }, }), [block, close, requireToFail, rowState, shouldEnableTap, simultaneousWith] ); const tapGesture = useTapGesture(tapConfig); const panConfig = useMemo( () => ({ enabled: enabled ?? true, enableTrackpadTwoFingerGesture: enableTrackpadTwoFingerGesture, activeOffsetX: [dragOffsetFromRight, dragOffsetFromLeft] as [ typeof dragOffsetFromRight, typeof dragOffsetFromLeft, ], simultaneousWith, requireToFail, block, hitSlop: hitSlop, onActivate: updateElementWidths, onUpdate: handleUpdate, onDeactivate: handleRelease, onFinalize: handleFinalize, }), [ block, dragOffsetFromLeft, dragOffsetFromRight, enableTrackpadTwoFingerGesture, enabled, handleFinalize, handleRelease, handleUpdate, hitSlop, requireToFail, simultaneousWith, updateElementWidths, ] ); const panGesture = usePanGesture(panConfig); useImperativeHandle(ref, () => swipeableMethods, [swipeableMethods]); const animatedStyle = useAnimatedStyle( () => ({ transform: [{ translateX: appliedTranslation.value }], pointerEvents: rowState.value === 0 ? 'auto' : 'box-only', }), Platform.OS === 'web' ? [appliedTranslation, rowState] : undefined ); const swipeableComponent = ( {leftElement()} {rightElement()} {children} ); return testID ? ( {swipeableComponent} ) : ( swipeableComponent ); }; export default Swipeable; export type SwipeableRef = ForwardedRef; const styles = StyleSheet.create({ container: { overflow: 'hidden', }, leftActions: { ...StyleSheet.absoluteFill, flexDirection: I18nManager.isRTL ? 'row-reverse' : 'row', overflow: 'hidden', }, rightActions: { ...StyleSheet.absoluteFill, flexDirection: I18nManager.isRTL ? 'row' : 'row-reverse', overflow: 'hidden', }, });