import React, { useState, useRef, useImperativeHandle, forwardRef, useCallback, } from 'react'; import { View, Animated, PanResponder, StyleSheet, Dimensions, } from 'react-native'; type BottomSheetProps = { children: React.ReactNode; onClose?: () => void; snapPoints?: string[]; }; export type BottomSheetHandle = { open: () => void; close: (doNotTriggerCallback?: boolean) => void; opened: boolean; }; const BottomSheet = forwardRef( ({children, onClose, snapPoints}, ref) => { const screenHeight = Dimensions.get('window').height; const [height] = useState(new Animated.Value(0)); const [isVisible, setIsVisible] = useState(false); const [currentSnapPointIndex, setCurrentSnapPointIndex] = useState(0); const validSnapPoints = snapPoints && snapPoints.length > 0 ? snapPoints : ['60%']; const snapPointsInPixels = validSnapPoints.map(snapPoint => { const percentageValue = parseFloat(snapPoint) / 100; return screenHeight * percentageValue; }); const openBottomSheet = useCallback(() => { setIsVisible(true); let nextSnapPointIndex = (currentSnapPointIndex + 1) % validSnapPoints.length; // Cycle through snap points setCurrentSnapPointIndex(nextSnapPointIndex); Animated.timing(height, { toValue: snapPointsInPixels[nextSnapPointIndex], duration: 300, useNativeDriver: false, }).start(); }, [ currentSnapPointIndex, validSnapPoints.length, height, snapPointsInPixels, ]); const closeBottomSheet = useCallback( (doNotTriggerCallback?: boolean) => { Animated.timing(height, { toValue: 0, duration: 300, useNativeDriver: false, }).start(() => { setIsVisible(false); if (onClose && !doNotTriggerCallback) { onClose(); } // Call the onClose callback }); }, [height, onClose], ); useImperativeHandle( ref, () => ({ open: openBottomSheet, close: doNotTriggerCallback => { console.log(doNotTriggerCallback); closeBottomSheet(doNotTriggerCallback); }, opened: isVisible, }), [closeBottomSheet, isVisible, openBottomSheet], ); // Updated PanResponder within the BottomSheet component const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => true, onPanResponderMove: (e, gestureState) => { // Directly using dy to adjust the height can cause inverted behavior // because the gesture dy is positive when moving down, and negative when moving up, // which is the opposite of how we visually perceive the bottom sheet's "height" changing. if (isVisible) { // Calculate the new height based on the gesture movement const newHeight = gestureState.dy > 0 ? Math.max( snapPointsInPixels[currentSnapPointIndex] - gestureState.dy, 0, ) // Moving down reduces height : Math.min( snapPointsInPixels[currentSnapPointIndex] - gestureState.dy, snapPointsInPixels[snapPointsInPixels.length - 1], ); // Moving up increases height but caps at the max height // Apply the new height immediately to follow the finger height.setValue(newHeight); } }, onPanResponderRelease: (e, gestureState) => { // Determine the final action based on the gesture velocity and direction if (gestureState.vy > 0.5 || gestureState.dy > 0) { // Quick or significant move down: Close the bottom sheet closeBottomSheet(); } else if (gestureState.vy < -0.5 || gestureState.dy < 0) { // Quick or significant move up: Open to the next snap point const nextSnapPointIndex = (currentSnapPointIndex + 1) % snapPointsInPixels.length; setCurrentSnapPointIndex(nextSnapPointIndex); // Animate to the next snap point's height Animated.timing(height, { toValue: snapPointsInPixels[nextSnapPointIndex], duration: 300, useNativeDriver: false, }).start(); } else { // Otherwise, revert to the nearest snap point based on the ending position // This can be enhanced to calculate the nearest snap point dynamically Animated.spring(height, { toValue: snapPointsInPixels[currentSnapPointIndex], useNativeDriver: false, }).start(); } }, }), ).current; return ( {/* Inner View for visual decoration */} {children} ); }, ); const styles = StyleSheet.create({ bottomSheet: { position: 'absolute', zIndex: 1000, bottom: 0, alignSelf: 'center', width: '100%', backgroundColor: 'whitesmoke', borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingHorizontal: 10, // ...other styling for the bottom sheet }, handle: { width: 40, height: 5, backgroundColor: 'grey', borderRadius: 2.5, alignSelf: 'center', marginTop: 8, // ...other styling for the handle }, handleHitBox: { width: '100%', // Larger hitbox width height: 40, // Larger hitbox height justifyContent: 'center', alignItems: 'center', // No backgroundColor needed, it's just for increasing the touchable area }, }); export default BottomSheet;