import React, { ReactNode, useEffect, useRef, useState } from 'react'; import { Animated, BackHandler, Easing, Image, LayoutChangeEvent, PanResponder, TouchableOpacity, View, } from 'react-native'; import { CloseIcon } from '../../assets/images'; import { useSafeAreaDimensions } from '../../hooks/useSafeAreaDimensions'; import { getStylesObject } from './BottomSheet.styles'; import { useIsBackFromLongBackground } from '../../hooks/useAppStateListener'; import { BottomSheetNativePlaceType } from '../../types/internal/bottomSheet.types'; import { UNBaseView } from '../../nativeComponents/UNBaseView'; export interface BottomSheetProps { isOpen: boolean, children: ReactNode, onOpen: () => void, onClose: () => void, height: number, animationDuration: number, expandToMaxHeightEnabled: boolean, handleWebViewScroll: () => void, shouldEnableBottomSheetScroll: boolean, nativePlace?: BottomSheetNativePlaceType, isComponentLoading?: boolean, sliderMaxHeight: number } const BottomSheet = (props: BottomSheetProps) => { const isStatic = props.nativePlace === BottomSheetNativePlaceType.modal; const panelHeightValue = useRef(new Animated.Value(props.sliderMaxHeight)).current; const fadeAnim = useRef(new Animated.Value(0)).current; const styles = getStylesObject(fadeAnim); // Initialize to false: BottomSheet mounts fresh each time (conditionally rendered), // so useEffect will trigger fadeIn() when isOpen=true on mount. const [isPanelOpened, setIsPanelOpened] = useState(false); const [contentHeight, setContentHeight] = useState(undefined); const { insets } = useSafeAreaDimensions(); const [currentHeight, setCurrentHeight] = useState(props.sliderMaxHeight); const [dy, setDy] = useState(0); const isAppInactive = useIsBackFromLongBackground(); const _parentPanResponder = PanResponder.create({ onMoveShouldSetPanResponderCapture: (_, gestureState) => { if (isStatic) return false; return props.shouldEnableBottomSheetScroll && Math.abs(gestureState.dy) > 10; }, onPanResponderMove(_, gestureState) { if (props.shouldEnableBottomSheetScroll && gestureState.dy < 5) { props.handleWebViewScroll(); } if (currentHeight + gestureState.dy < 0) return; if (!props.expandToMaxHeightEnabled && contentHeight && currentHeight + gestureState.dy < props.sliderMaxHeight - contentHeight) return; panelHeightValue.setValue(currentHeight + gestureState.dy); setDy(gestureState.dy); }, onPanResponderRelease: () => { if (dy > 0) { collapse(); } else if (!props.expandToMaxHeightEnabled) { contentHeight && setToContentHeight(contentHeight); } else { expand(); } }, }); useEffect(() => { // on App Active State change reset the bottom-sheet if (isAppInactive) { setContentHeight(0); _dismiss(false); } }, [isAppInactive]); useEffect(() => { if (props.isOpen && !isPanelOpened) { fadeIn(); } else if (isPanelOpened) { setContentHeight(0); _dismiss(); } }, [props.isOpen]); useEffect(() => { const backHandlerSubscription = BackHandler.addEventListener('hardwareBackPress', _onBackPress); const id = panelHeightValue.addListener(e => setCurrentHeight(e.value)); return (() => { backHandlerSubscription.remove(); panelHeightValue.removeListener(id); }); }, []); const fadeIn = () => { setIsPanelOpened(true); Animated.timing(fadeAnim, { toValue: 0.3, duration: 500, useNativeDriver: false, }).start(); }; const fadeOut = () => { /* withTimeout - most of the time we declare 'Close' after the animation is end. In a case that the app is not active we do it without timeout. (Otherwise a race condition may occur.) */ setIsPanelOpened(false); Animated.timing(fadeAnim, { toValue: 0, duration: 500, useNativeDriver: false, }).start(); }; const setToContentHeight = (contentHeight: number) => { Animated.timing(panelHeightValue, { duration: props.animationDuration, easing: Easing.quad, toValue: props.sliderMaxHeight - contentHeight, useNativeDriver: false, }).start(); }; const expand = () => { const { animationDuration } = props; props.onOpen(); if (contentHeight && currentHeight > (props.sliderMaxHeight - contentHeight)) { setToContentHeight(contentHeight); return; } Animated.timing(panelHeightValue, { duration: animationDuration, easing: Easing.quad, toValue: 0, useNativeDriver: false, }).start(); }; const collapse = () => { if (contentHeight && currentHeight > (props.sliderMaxHeight - contentHeight)) { _dismiss(); return; } contentHeight && setToContentHeight(contentHeight); }; const _onBackPress = () => { isPanelOpened && collapse(); return isPanelOpened; }; // Track the last height we animated to, so we skip duplicate animations // (prevents visual jitter when _setSize fires multiple times with the same height) // but still re-animate when the height actually changes. const lastAnimatedHeight = useRef(0); // Reset when new content starts loading, so the next opening can animate. useEffect(() => { if (props.isComponentLoading) { lastAnimatedHeight.current = 0; } }, [props.isComponentLoading]); const _setSize = (e: LayoutChangeEvent) => { const newContentHeight = e.nativeEvent.layout.height; setContentHeight(newContentHeight); if (newContentHeight > 0 && newContentHeight !== lastAnimatedHeight.current) { lastAnimatedHeight.current = newContentHeight; setToContentHeight(newContentHeight); } }; const _dismiss = (withAnimation = true) => { const { animationDuration } = props; Animated.timing(panelHeightValue, { duration: withAnimation ? animationDuration : 0, easing: Easing.quad, toValue: props.sliderMaxHeight, useNativeDriver: false, }).start(() => { props.onClose(); }); fadeOut(); }; const { children, } = props; const isModal = props.nativePlace === BottomSheetNativePlaceType.modal; return ( <> _dismiss()} > _dismiss()} > {children} ); }; export default BottomSheet;