/* eslint-disable react-native/no-inline-styles */ import * as React from 'react'; import type { PropsWithChildren } from 'react'; import { Dimensions, KeyboardAvoidingView, LayoutChangeEvent, Modal, Platform, Pressable, ScrollViewProps, StyleSheet, View, ViewStyle, } from 'react-native'; import { GestureHandlerRootView, PanGestureHandler, ScrollView, } from 'react-native-gesture-handler'; import Animated, { SharedValue, runOnJS, useAnimatedStyle, useDerivedValue, useSharedValue, } from 'react-native-reanimated'; import { AnimatedContainer } from '../components/AnimatedContainer'; import { useBottomSheetGestureHandler } from '../hooks/useBottomSheetGestureHandler'; import { useTimedAction } from '../hooks/useTimedAction'; import { useChecks } from '../internal/useChecks'; import { useKeyboard } from '../internal/useKeyboard'; export type BottomSheetProps = { animationDuration?: number; autoCloseDelay?: number; avoidKeyboard?: boolean; bottomSheetStyle?: ViewStyle | ViewStyle[]; closeActionAccessibilityLabel: string; closeDistance?: number; footerComponent?: JSX.Element; handleComponent?: JSX.Element | 'none'; handleStyle?: ViewStyle | ViewStyle[]; headerComponent?: JSX.Element; maxHeight?: number; minVelocityToClose?: number; onBottomSheetHidden?: () => void; onClose: () => void; overlayOpacity?: number; overlayStyle?: ViewStyle | ViewStyle[]; panGestureEnabled?: boolean; persistent?: boolean; scrollEnabled?: boolean; scrollViewProps?: Omit; testID?: string; topInset: number; visible: boolean; ref?: React.RefObject; shouldHandleKeyboardEvents?: boolean; }; export type BottomSheetActions = { close: () => Promise; isVisible: () => boolean; }; const DEFAULT_MAX_HEIGHT = Dimensions.get('window').height * 0.9; const isIOS = Platform.OS === 'ios'; export const BottomSheetBase = React.forwardRef< BottomSheetActions, React.PropsWithChildren >( ( { children, visible, onClose, handleStyle = {}, bottomSheetStyle = {}, overlayStyle, closeActionAccessibilityLabel, headerComponent, animationDuration = 300, closeDistance = 0.3, handleComponent, scrollEnabled = false, scrollViewProps, persistent = false, autoCloseDelay, panGestureEnabled = true, testID, overlayOpacity = 1, footerComponent, avoidKeyboard, maxHeight = DEFAULT_MAX_HEIGHT, minVelocityToClose = 1000, topInset, onBottomSheetHidden, shouldHandleKeyboardEvents = true, }, ref, ) => { // This is used to let Reanimated animate the view out, before removing it from the tree. const [renderContent, setRenderContent] = React.useState(visible); const [isModalVisible, setIsModalVisible] = React.useState(false); const translateY = useSharedValue(0); const contentHeight = useSharedValue(0); const dragOpacity = useSharedValue(0); const { onTimeout } = useTimedAction(); const isMounted = React.useRef(false); const [headerHeight, setHeaderHeight] = React.useState(-1); const [footerHeight, setFooterHeight] = React.useState(-1); const [handleHeight, setHandleHeight] = React.useState(-1); const promiseResolver = React.useRef<(() => void) | null>(null); const [maxScrollViewHeight, setMaxScrollViewHeight] = React.useState(0); const { keyboardHeight, keyboardFinalHeight } = useKeyboard( shouldHandleKeyboardEvents, ); const checks = __DEV__ ? useChecks?.() : null; const debugStyle = __DEV__ ? checks?.debugStyle : {}; __DEV__ && checks?.noUndefinedProperty({ properties: { closeActionAccessibilityLabel, children }, property: 'closeActionAccessibilityLabel', rule: 'BOTTOM_SHEET_CLOSE_ACTION', }); __DEV__ && checks?.noUppercaseStringChecker({ text: closeActionAccessibilityLabel, }); React.useImperativeHandle(ref, () => ({ close: async () => { if (!renderContent) { return Promise.resolve(); } return new Promise(resolve => { promiseResolver.current = resolve; onClose(); }); }, isVisible: () => { return renderContent; }, })); React.useEffect(() => { if (visible) { translateY.value = 0; setIsModalVisible(true); setRenderContent(true); if (autoCloseDelay) { onTimeout(() => { if (isMounted.current) { onClose(); } }, autoCloseDelay); } } else if (isModalVisible) { setRenderContent(false); /** * We need to give Reanimated the time to finish animating the view out. * Otherwise, the content will suddenly disappear. */ setTimeout(() => { setIsModalVisible(false); onBottomSheetHidden?.(); promiseResolver.current?.(); }, animationDuration); } }, [ animationDuration, autoCloseDelay, isModalVisible, onBottomSheetHidden, onClose, onTimeout, translateY, visible, ]); React.useEffect(() => { isMounted.current = true; return () => { isMounted.current = false; }; }, []); const dragStyle = useAnimatedStyle(() => { return { opacity: dragOpacity.value, }; }); const maxHeightValue = useDerivedValue(() => { return maxHeight - keyboardHeight.value; }, [keyboardHeight, maxHeight]); const animatedStyle = useAnimatedStyle(() => { const keyboard = isIOS ? keyboardHeight.value : 0; return { transform: [{ translateY: translateY.value - keyboard }], maxHeight: maxHeightValue.value, }; }, [maxHeightValue, translateY, keyboardHeight]); useDerivedValue(() => { const maxScrollHeight = Math.ceil( maxHeight - keyboardFinalHeight.value - footerHeight - headerHeight - handleHeight - topInset, ); if ( !Number.isNaN(maxScrollHeight) && maxScrollHeight !== maxScrollViewHeight ) { runOnJS(setMaxScrollViewHeight)(maxScrollHeight); } }, [ footerHeight, headerHeight, handleHeight, keyboardFinalHeight, topInset, maxHeight, ]); const handleOnLayout = (event: LayoutChangeEvent) => { contentHeight.value = event.nativeEvent.layout.height; }; const maybeCloseBottomSheet = persistent ? undefined : onClose; const Wrapper = avoidKeyboard && !shouldHandleKeyboardEvents ? BottomSheetKeyboardAvoidingView : React.Fragment; const opacity = [footerHeight, headerHeight, handleHeight].every( h => h >= 0, ) ? 1 : 0; return ( onClose()} ref={ref as any} testID={testID}> {renderContent ? ( { setHandleHeight(event.nativeEvent.layout.height); }}> {handleComponent === 'none' ? null : handleComponent || ( )} { setHeaderHeight(event.nativeEvent.layout.height); }}> {headerComponent} {children} { setFooterHeight(event.nativeEvent.layout.height); }}> {footerComponent} ) : null} ); }, ); export const BottomSheet = React.memo(BottomSheetBase); const BottomSheetKeyboardAvoidingView = ({ children, }: React.PropsWithChildren<{}>) => { return ( {children} ); }; type GestureHandlerProps = PropsWithChildren<{ translateY: SharedValue; contentHeight: SharedValue; dragOpacity: SharedValue; overlayOpacity: number; closeDistance: number; onClose: () => void; panGestureEnabled: boolean; testID?: string; minVelocityToClose: number; }>; const GestureHandler = ({ translateY, closeDistance, contentHeight, children, onClose, panGestureEnabled, testID, dragOpacity, overlayOpacity, minVelocityToClose, }: GestureHandlerProps) => { const { gestureHandler } = useBottomSheetGestureHandler({ translateY, closeDistance, contentHeight, onClose, dragOpacity, overlayOpacity, minVelocityToClose, }); return ( {children} ); }; const styles = StyleSheet.create({ overlay: { backgroundColor: 'rgba(0, 0, 0, 0.5)', flex: 1, }, closeButton: { flex: 1, }, contentWrapper: { flexDirection: 'column', flex: 1, position: 'absolute', bottom: 0, alignSelf: 'flex-end', width: '100%', }, content: { width: '100%', backgroundColor: '#fff', flex: 1, }, line: { width: 48, height: 4, backgroundColor: 'grey', alignSelf: 'center', marginBottom: 24, borderRadius: 2, marginTop: 12, }, });