import { useImperativeHandle, type ComponentRef, forwardRef, useEffect, useState, useRef } from "react"; import { type LayoutChangeEvent, PanResponder, ScrollView, Animated, View } from "react-native"; import type IBottomSheetProps from "./type"; import type { IBottomSheetRef } from "./type"; import stylesheet from "./stylesheet"; import { NCoreUIKitTheme } from "../../core/hooks"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { RefForwardingComponent } from "../../types"; import type { IModalRef } from "../modal/type"; import { windowHeight } from "../../utils"; import Modal from "../modal"; const BottomSheet: RefForwardingComponent = ({ renderHeader: RenderHeaderComponent, renderBottom: RenderBottomComponent, isForceFullScreenOnSwipe = false, isCanFullScreenOnSwipe = false, handleContainerBackgroundColor, handleHeight: handleHeightProp, isWrapSafeareaContext = true, backgroundColor = "default", isWorkAsFullScreen = false, isWorkWithPortal = true, isCloseOnOverlay = true, handleContainerSpacing, isActive: isActiveProp, handleBackgroundColor, isAutoHeight = false, isShowHandle = true, isSwipeClose = true, isCanSwipe = true, onOverlayPressed, snapPoint, children, onClosed, onOpened, onClose, onOpen, style, key, ...props }, ref) => { const { colors, spaces } = NCoreUIKitTheme.useContext(); const { bottom, top } = useSafeAreaInsets(); const [ isMeasured, setIsMeasured ] = useState(false); const [ isActive, setIsActive ] = useState(isActiveProp === undefined ? false : isActiveProp); let bottomSafeArea = isWrapSafeareaContext ? bottom : 0; let topSafeArea = isWrapSafeareaContext ? top : 0; if(isForceFullScreenOnSwipe) { topSafeArea = 0; } if(!isWorkWithPortal) { bottomSafeArea = 0; topSafeArea = 0; } const scrollViewRef = useRef>(null); const modalRef = useRef(null); const containerHeightRef = useRef(windowHeight); const animatedTranslateY = useRef(new Animated.Value(snapPoint && !isWorkAsFullScreen ? snapPoint : containerHeightRef.current)).current; const animatedHeight = useRef(new Animated.Value( isAutoHeight ? 0 : snapPoint ?? 0 )).current; const TOP_GRAB_AREA = 140; const maxHeight = useRef(isWorkAsFullScreen ? containerHeightRef.current - (isWrapSafeareaContext ? topSafeArea : 0) : containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : isWrapSafeareaContext ? topSafeArea : 0)); const heightValue = useRef(isWorkAsFullScreen ? containerHeightRef.current : snapPoint ?? 0); const initialTranslateY = useRef(0); const translateYValue = useRef(0); const contentHeight = useRef(-1); const initialHeight = useRef(0); const scrollViewContentHeight = useRef(-1); const scrollViewLayoutHeight = useRef(-1); const initialScrollOffset = useRef(0); const scrollOffset = useRef(0); const gestureStartY = useRef(0); const isCanFullScreenOnSwipeRef = useRef(isCanFullScreenOnSwipe); const isWorkAsFullScreenRef = useRef(isWorkAsFullScreen); const isSwipeCloseRef = useRef(isSwipeClose); const isCanSwipeRef = useRef(isCanSwipe); if(!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) { maxHeight.current = snapPoint; } useImperativeHandle( ref, () => ({ close: () => { closeAnimation(); }, open: () => { setIsActive(true); } }), [] ); useEffect(() => { isCanSwipeRef.current = isCanSwipe; }, [isCanSwipe]); useEffect(() => { isSwipeCloseRef.current = isSwipeClose; }, [isSwipeClose]); useEffect(() => { isCanFullScreenOnSwipeRef.current = isCanFullScreenOnSwipe; }, [isCanFullScreenOnSwipe]); useEffect(() => { isWorkAsFullScreenRef.current = isWorkAsFullScreen; }, [isWorkAsFullScreen]); useEffect(() => { if(isMeasured && contentHeight.current !== -1) { if(isCanFullScreenOnSwipe && !isWorkAsFullScreen) { maxHeight.current = containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : topSafeArea); } else if(isAutoHeight || !snapPoint) { maxHeight.current = contentHeight.current; } } }, [isMeasured]); useEffect(() => { if(isActive && isMeasured) { openAnimation(); } }, [ isActive, isMeasured ]); useEffect(() => { if (!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) { maxHeight.current = snapPoint; } else if (!isWorkAsFullScreen) { maxHeight.current = containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : isWrapSafeareaContext ? topSafeArea : 0); } else { maxHeight.current = containerHeightRef.current - (isWrapSafeareaContext ? topSafeArea : 0); } }, [ isForceFullScreenOnSwipe, isCanFullScreenOnSwipe, isWrapSafeareaContext, isWorkAsFullScreen, topSafeArea, snapPoint ]); useEffect(() => { const listenerAHeightId = animatedHeight.addListener(({ value }) => { heightValue.current = value; }); const listenerTYId = animatedTranslateY.addListener(({ value }) => { translateYValue.current = value; }); return () => { animatedHeight.removeListener(listenerAHeightId); animatedTranslateY.removeListener(listenerTYId); }; }, []); const openAnimation = () => { resetState(); if(onOpen) onOpen(); Animated.timing(animatedTranslateY, { useNativeDriver: false, duration: 300, toValue: 0 }).start(({ finished }) => { if(finished) { if(onOpened) onOpened(); } }); }; const closeAnimation = (toValue?: number) => { if(onClose) onClose(); Animated.timing(animatedTranslateY, { toValue: toValue ? toValue : isAutoHeight || !snapPoint ? contentHeight.current : snapPoint, useNativeDriver: false, duration: 300 }).start(({ finished }) => { if(finished) { resetState(); setIsActive(false); if(onClosed) onClosed(); } }); }; const onLayout = (event: LayoutChangeEvent) => { if (isMeasured) return; const { height } = event.nativeEvent.layout; animatedHeight.setValue(height); contentHeight.current = height; setIsMeasured(true); }; const resetState = () => { animatedHeight.setOffset(0); animatedTranslateY.setOffset(0); scrollOffset.current = 0; initialScrollOffset.current = 0; initialHeight.current = snapPoint ?? contentHeight.current; initialTranslateY.current = 0; heightValue.current = snapPoint ?? contentHeight.current; translateYValue.current = 0; }; const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => false, onMoveShouldSetPanResponderCapture: () => isCanSwipeRef.current ? true : false, onPanResponderGrant: (evt) => { if(!isCanSwipeRef.current) return; gestureStartY.current = evt.nativeEvent.pageY; animatedTranslateY.stopAnimation((currentY) => { translateYValue.current = currentY; }); animatedHeight.stopAnimation((currentH) => { heightValue.current = currentH; }); animatedTranslateY.flattenOffset(); animatedHeight.flattenOffset(); initialTranslateY.current = translateYValue.current; initialScrollOffset.current = scrollOffset.current; initialHeight.current = heightValue.current; animatedHeight.setOffset(heightValue.current); animatedHeight.setValue(0); animatedTranslateY.setOffset(translateYValue.current); animatedTranslateY.setValue(0); }, onPanResponderMove: (_, gestureState) => { if(!isCanSwipeRef.current) return; const { dy } = gestureState; const isAtTop = scrollOffset.current <= 0; const isAtTavan = heightValue.current >= maxHeight.current - 1; const hasScroll = scrollViewContentHeight.current > scrollViewLayoutHeight.current; const isFromTopArea = gestureStartY.current < TOP_GRAB_AREA; if (dy > 0 && isAtTavan && hasScroll && !isAtTop && !isFromTopArea) { const currentDelta = -dy; const initialS = initialScrollOffset.current; const usedForS = Math.max(currentDelta, -initialS); scrollOffset.current = initialS + usedForS; scrollViewRef.current?.scrollTo({ y: scrollOffset.current, animated: false }); return; } const delta = -dy; const pivot = snapPoint ?? contentHeight.current; const initialH = initialHeight.current; const initialS = initialScrollOffset.current; const initialT = initialTranslateY.current; const maxS = Math.max(0, scrollViewContentHeight.current - scrollViewLayoutHeight.current); if (dy < 0) { let currentDelta = delta; const effectiveInitialT = Math.max(0, initialT); const usedForT = Math.min(currentDelta, effectiveInitialT); animatedTranslateY.setValue(-usedForT); currentDelta -= usedForT; if (currentDelta > 0) { if (initialH < pivot) { const spaceToPivot = pivot - initialH; const usedForH = Math.min(currentDelta, spaceToPivot); animatedHeight.setValue(usedForH); currentDelta -= usedForH; } const isRestoringHeight = initialH < pivot; const canScroll = isCanFullScreenOnSwipeRef.current || isWorkAsFullScreenRef.current || !isRestoringHeight; if (currentDelta > 0 && canScroll) { const remainingScroll = maxS - initialS; if (remainingScroll > 0) { const usedForS = Math.min(currentDelta, remainingScroll); scrollOffset.current = initialS + usedForS; scrollViewRef.current?.scrollTo({ y: scrollOffset.current, animated: false }); currentDelta -= usedForS; animatedHeight.setValue(initialH < pivot ? (pivot - initialH) : 0); } } if (currentDelta > 0) { if (isCanFullScreenOnSwipeRef.current) { const totalUsedBefore = (initialH < pivot ? (pivot - initialH) : 0); animatedHeight.setValue(totalUsedBefore + currentDelta); } else { animatedHeight.setValue(initialH < pivot ? (pivot - initialH) : 0); } } } } else { let currentDelta = delta; if (initialH > pivot || initialS > 0) { if (initialS > 0) { const usedForS = Math.max(currentDelta, -initialS); scrollOffset.current = initialS + usedForS; scrollViewRef.current?.scrollTo({ y: scrollOffset.current, animated: false }); currentDelta -= usedForS; animatedHeight.setValue(initialH > pivot ? 0 : pivot - initialH); } if (currentDelta < 0 && initialH > pivot) { const distanceToPivot = pivot - initialH; const usedForH = Math.max(currentDelta, distanceToPivot); animatedHeight.setValue(usedForH); currentDelta -= usedForH; } } if (currentDelta < 0) { animatedHeight.setValue(initialH > pivot ? (pivot - initialH) : 0); animatedTranslateY.setValue(-currentDelta); scrollOffset.current = 0; scrollViewRef.current?.scrollTo({ y: 0, animated: false }); } } }, onPanResponderEnd: (_, gestureState) => { if(!isCanSwipeRef.current) return; const isAtTop = scrollOffset.current <= 1; const isAtTavan = heightValue.current >= maxHeight.current - 1; const hasScroll = scrollViewContentHeight.current > scrollViewLayoutHeight.current; const isFromTopArea = gestureStartY.current < TOP_GRAB_AREA; if (isAtTavan && hasScroll && !isAtTop && !isFromTopArea) { return; } const currentH = (animatedHeight as Animated.Value & { __getValue(): number; }).__getValue(); const currentT = (animatedTranslateY as Animated.Value & { __getValue(): number; }).__getValue(); animatedHeight.flattenOffset(); animatedTranslateY.flattenOffset(); heightValue.current = currentH; translateYValue.current = currentT; const pivot = snapPoint ?? contentHeight.current; const tavan = maxHeight.current; const isFastSwipeDown = gestureState.vy > 0.5; const isFastSwipeUp = gestureState.vy < -0.5; if (currentT <= 0.5) { let toValue = pivot; if (isFastSwipeUp && isCanFullScreenOnSwipeRef.current) { toValue = tavan; } else if (isFastSwipeDown) { toValue = pivot; } else { if (!isCanFullScreenOnSwipeRef.current) { toValue = pivot; } else { const totalRange = tavan - pivot; const distFromPivot = currentH - pivot; if (distFromPivot < totalRange * 0.33) { toValue = pivot; } else if (distFromPivot > totalRange * 0.66) { toValue = tavan; } else { toValue = (tavan - currentH) < (currentH - pivot) ? tavan : pivot; } } } Animated.spring(animatedHeight, { useNativeDriver: false, toValue: toValue, friction: 10, tension: 40 }).start(); } else { let toValueT = 0; let isClosing = false; if (!isSwipeCloseRef.current) { Animated.spring(animatedHeight, { useNativeDriver: false, toValue: pivot, friction: 10, tension: 40 }).start(); Animated.timing(animatedTranslateY, { useNativeDriver: false, duration: 300, toValue: 0 }).start(); } else { if (isFastSwipeDown) { toValueT = pivot + 100; isClosing = true; } else { if (currentT > pivot * 0.5) { toValueT = pivot + 100; isClosing = true; } else { toValueT = 0; isClosing = false; } } Animated.timing(animatedTranslateY, { useNativeDriver: false, toValue: toValueT, duration: 300 }).start(({ finished }) => { if (finished && isClosing) { setIsActive(false); if (onClosed) onClosed(); } }); } } }, onPanResponderTerminationRequest: () => false, onShouldBlockNativeResponder: () => true }) ).current; const renderView = () => { return {renderHeader()} { scrollViewContentHeight.current = h; }} onLayout={(e) => { scrollViewLayoutHeight.current = e.nativeEvent.layout.height; }} onStartShouldSetResponderCapture={() => false} onMoveShouldSetResponderCapture={() => false} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} scrollEnabled={!isCanSwipe} scrollEventThrottle={1} overScrollMode="never" ref={scrollViewRef} bounces={false} > {children} {renderBottom()} {renderHandle()} ; }; const renderHeader = () => { if(!RenderHeaderComponent) { return null; } return ; }; const renderBottom = () => { if(!RenderBottomComponent) { return null; } return ; }; const renderHandle = () => { if(!isShowHandle || isWorkAsFullScreen) { return null; } const handleHeight = handleHeightProp ? handleHeightProp : 8; const handleContainerHeight = handleHeight + ((handleContainerSpacing ? spaces[handleContainerSpacing] : spaces.spacingSm) * 2); const handleBorderRadius = Math.ceil(handleHeight / 2); return false} onMoveShouldSetResponderCapture={() => false} style={[ stylesheet.handleContainer, { backgroundColor: handleContainerBackgroundColor ? colors.content.container[handleContainerBackgroundColor] : "transparent", height: handleContainerHeight, transform: [{ translateY: -handleContainerHeight }] } ]} > ; }; return { const containerLayoutHeight = e.nativeEvent.layout.height; if (containerLayoutHeight && containerLayoutHeight !== containerHeightRef.current) { containerHeightRef.current = containerLayoutHeight; if (!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) { maxHeight.current = snapPoint; } else if (!isWorkAsFullScreen) { maxHeight.current = containerLayoutHeight - (isForceFullScreenOnSwipe ? 0 : isWrapSafeareaContext ? topSafeArea : 0); } else { maxHeight.current = containerLayoutHeight; } } }} overlayProps={{ onStartShouldSetResponderCapture: () => false, onMoveShouldSetResponderCapture: () => false }} onOverlayPress={() => { if(onOverlayPressed) onOverlayPressed(); if(isCloseOnOverlay) { closeAnimation(); } }} style={[ { paddingTop: topSafeArea } ]} > {renderView()} ; }; export default forwardRef(BottomSheet);