* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
201 lines
5.8 KiB
TypeScript
201 lines
5.8 KiB
TypeScript
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 (
|
|
<Modal
|
|
visible={mounted}
|
|
transparent
|
|
animationType="none"
|
|
onRequestClose={onClose}
|
|
>
|
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
|
<View style={{ flex: 1 }}>
|
|
<Animated.View
|
|
pointerEvents={open ? 'auto' : 'none'}
|
|
style={[
|
|
{ position: 'absolute', inset: 0, backgroundColor: 'black' },
|
|
backdropStyle,
|
|
]}
|
|
>
|
|
<Pressable style={{ flex: 1 }} onPress={onClose} />
|
|
</Animated.View>
|
|
|
|
<Wrapper
|
|
{...wrapperProps}
|
|
style={{ flex: 1, justifyContent: 'flex-end' }}
|
|
pointerEvents="box-none"
|
|
>
|
|
<GestureDetector gesture={sheetPan}>
|
|
<Animated.View
|
|
style={[
|
|
{
|
|
backgroundColor: '#1c1c1c',
|
|
borderTopLeftRadius: 22,
|
|
borderTopRightRadius: 22,
|
|
overflow: 'hidden',
|
|
maxHeight,
|
|
},
|
|
sheetStyle,
|
|
]}
|
|
>
|
|
<SafeAreaView edges={['bottom']}>
|
|
<View className="px-5 pt-3 items-center">
|
|
<View className="bg-white/25 h-1 w-12 rounded-full mb-3" />
|
|
</View>
|
|
{children}
|
|
</SafeAreaView>
|
|
</Animated.View>
|
|
</GestureDetector>
|
|
</Wrapper>
|
|
</View>
|
|
</SafeAreaProvider>
|
|
</Modal>
|
|
);
|
|
}
|