import { useCallback, useEffect, useRef, useState } from 'react'; import { Dimensions, KeyboardAvoidingView, Modal, Platform, Pressable, View, } from 'react-native'; import { initialWindowMetrics, SafeAreaProvider, SafeAreaView, } from 'react-native-safe-area-context'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, Extrapolation, interpolate, runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming, } from 'react-native-reanimated'; const SCREEN_HEIGHT = Dimensions.get('window').height; const ANIMATION_MS = 240; interface BottomSheetProps { open: boolean; onClose: () => void; /** * Fires after the close animation completes and the underlying Modal has * unmounted. Use this to chain a follow-up sheet/Alert without stacking * iOS Modals — presenting a second Modal while another is still mounted * silently fails on iOS and leaves the app looking frozen. */ onClosed?: () => void; /** When true, wrap content in KeyboardAvoidingView so the sheet floats above the keyboard. */ avoidKeyboard?: boolean; /** * Cap on the sheet's height. Defaults to 85%; pass a string like "60%" or * a number of px when content has a stable footprint. */ maxHeight?: number | `${number}%`; children: React.ReactNode; } /** * Shared modal sheet shell. Handles slide-in animation, backdrop fade, * drag-to-dismiss, and modal-safe SafeAreaProvider seeding so iOS modals get * correct insets on the first frame. The drag handle at the top is rendered * here too, so callers don't need to draw it themselves. */ export function BottomSheet({ open, onClose, onClosed, avoidKeyboard = false, maxHeight = '85%', children, }: BottomSheetProps) { // Mount slightly past `open` so the slide-in animation has its starting // position rendered, and the slide-out animation can play before unmount. const [mounted, setMounted] = useState(false); const translateY = useSharedValue(SCREEN_HEIGHT); // Mount as soon as we open; the close path unmounts after the exit animation. if (open && !mounted) setMounted(true); // Latest onClosed in a ref so the worklet→JS bridge always invokes the // current callback even if the parent re-rendered with a new closure. const onClosedRef = useRef(onClosed); useEffect(() => { onClosedRef.current = onClosed; }, [onClosed]); const handleClosed = useCallback(() => { setMounted(false); onClosedRef.current?.(); }, []); useEffect(() => { if (open) { requestAnimationFrame(() => { translateY.value = withSpring(0, { damping: 24, stiffness: 260, mass: 0.7, }); }); } else if (mounted) { translateY.value = withTiming( SCREEN_HEIGHT, { duration: ANIMATION_MS, easing: Easing.in(Easing.cubic) }, (finished) => { if (finished) runOnJS(handleClosed)(); }, ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const sheetPan = Gesture.Pan() .activeOffsetY(10) .failOffsetX([-25, 25]) .onUpdate((e) => { 'worklet'; // Reanimated shared values are mutated by design; react-hooks/immutability // doesn't model worklets, so the mutations below are flagged spuriously. // eslint-disable-next-line react-hooks/immutability translateY.value = Math.max(0, e.translationY); }) .onEnd((e) => { 'worklet'; if (e.translationY > 120 || e.velocityY > 800) { runOnJS(onClose)(); } else { // eslint-disable-next-line react-hooks/immutability translateY.value = withSpring(0, { damping: 24, stiffness: 260, mass: 0.7, }); } }); const sheetStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], })); const backdropStyle = useAnimatedStyle(() => { const opacity = interpolate( translateY.value, [0, SCREEN_HEIGHT * 0.7], [0.55, 0], Extrapolation.CLAMP, ); return { opacity }; }); if (!mounted) return null; const Wrapper = avoidKeyboard ? KeyboardAvoidingView : View; const wrapperProps = avoidKeyboard ? { behavior: Platform.OS === 'ios' ? ('padding' as const) : undefined } : {}; return ( {children} ); }