import type { ReactElement, ReactNode } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import type { StyleProp, ViewStyle, KeyboardAvoidingViewProps, } from 'react-native'; import { Animated, Dimensions, Easing, Modal, Platform } from 'react-native'; import BottomSheetContext from './BottomSheetContext'; import Footer from './Footer'; import Header from './Header'; import ScrollView from './ScrollView'; import { StyledBackdrop, StyledBottomSheet, StyledFloatingBottomSheet, StyledFloatingWrapper, StyledKeyboardAvoidingView, StyledWrapper, } from './StyledBottomSheet'; export interface BottomSheetProps { /** * Bottom sheet open state. */ open: boolean; /** * Bottom sheet's header. */ header?: string | ReactElement; /** * Bottom sheet's footer. */ footer?: ReactNode; /** * Bottom sheet's content. */ children?: ReactNode; /** * Callback is called when the Bottom Sheet finishes running animation. */ onAnimationEnd?: () => void; /** * Callback is called when the Bottom Sheet is opened. */ onOpen?: () => void; /** * Callback is called when the user taps the back button on Android or when the bottom sheet * is being dismiss by interacting outside of the bottom sheet. */ onRequestClose?: () => void; /** * Callback that is called once the bottom sheet has been dismissed. */ onDismiss?: () => void; /** * Displays an X button on bottom sheet header that will invoke onRequestClose callback on press. */ showCloseButton?: boolean; /** * Enable the bottom sheet's backdrop. */ hasBackdrop?: boolean; /** * Displays dividers between header, footer and body. */ showDivider?: boolean; /** * Additional style. */ style?: StyleProp; /** * Testing id of the component. */ testID?: string; /** * keyboardAvoidingView's props * */ keyboardAvoidingViewProps?: KeyboardAvoidingViewProps; /** * Supported orientations for the BottomSheet modal, iOS only. */ supportedOrientations?: ('portrait' | 'landscape')[]; /** * Variant of the bottom sheet. */ variant?: 'fixed' | 'floating'; } const BottomSheet = ({ open, header, footer, children, onAnimationEnd, onOpen, onRequestClose, onDismiss, showCloseButton = true, hasBackdrop = true, showDivider = false, style, testID, keyboardAvoidingViewProps = {}, supportedOrientations = ['portrait'], variant = 'fixed', }: BottomSheetProps): ReactElement => { const { height } = Dimensions.get('window'); // Internal state to control modal open/close timing with animation const [visible, setVisibility] = useState(open); const animatedValue = useRef(new Animated.Value(open ? 0 : 1)); const [internalShowDivider, setInternalShowDivider] = useState(showDivider); const canCallOnDismiss = useRef(false); useEffect(() => { // Prevent calling onDismiss when the component has not yet opened if (open && !canCallOnDismiss.current) { canCallOnDismiss.current = true; } // Show the modal before the open animation start if (open && !visible) { setVisibility(open); } }, [open]); // Animation useEffect(() => { const animation = Animated.timing(animatedValue.current, { toValue: open ? 1 : 0, easing: Easing.inOut(Easing.cubic), useNativeDriver: true, }); animation.start(({ finished }) => { if (finished) { if (!open && canCallOnDismiss.current) { setVisibility(false); onDismiss?.(); } onAnimationEnd?.(); } }); return () => animation.stop(); }, [open]); const interpolateY = animatedValue.current.interpolate({ inputRange: [0, 1], outputRange: [height, 0], }); // Backdrop opacity const interpolateOpacity = hasBackdrop ? animatedValue.current.interpolate({ inputRange: [0, 1], outputRange: [0, 0.48], }) : 0; const BottomSheetContextValue = useMemo( () => ({ setInternalShowDivider }), [setInternalShowDivider] ); const BottomSheetWrapperComponent = variant === 'fixed' ? React.Fragment : StyledFloatingWrapper; const BottomSheetComponent = variant === 'fixed' ? StyledBottomSheet : StyledFloatingBottomSheet; return ( 0 ? 1 : 0 }, { translateY: interpolateY, }, ], }, ]} > {header !== undefined ? (
) : null} {children} {footer ? ( ) : null} ); }; export default Object.assign(BottomSheet, { ScrollView, });