import React, { createContext, forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { Animated, BackHandler, Dimensions, Easing, Keyboard, type NativeEventSubscription, PanResponder, Platform, StyleSheet, TouchableOpacity, View, type ViewStyle, } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import ZeroSheetBox from './ZeroSheetBox'; import ZeroSheetContent from './ZeroSheetContent'; import ZeroSheetFooter from './ZeroSheetFooter'; import ZeroSheetHeader from './ZeroSheetHeader'; import { useZeroSheetConfig } from './ZeroSheetProvider'; const winHeight = Dimensions.get('window').height; type CustomStyles = { wrapper?: ViewStyle; container?: ViewStyle; draggableIcon?: ViewStyle; }; interface ZeroSheetProps { draggable?: boolean; // Возможность перетаскивания onChange?: (open: boolean) => void; backdrop?: boolean; //Показывать фон closeByBackdropClick?: boolean; //Закрытие по клику на фон before?: React.ReactNode; // Элемент перед контентом backgroundColor?: string; visible?: boolean; customStyles?: CustomStyles; noBottomInset?: boolean; // Не добавлять нижний отступ noTopInset?: boolean; // Не добавлять верхний отступ extraTopInset?: number; // Extra top inset extraBottomInset?: number; // Extra bottom inset footer?: React.ReactNode; header?: React.ReactNode; fixedFooterHeight?: number; fixedHeaderHeight?: number; fixedContentHeight?: number; onSheetOpen?: () => void; onSheetClose?: () => void; // onSheetOpened?: () => void; // onSheetClosed?: () => void; onManualClose?: () => void; renderBackground?: React.ReactNode; buttonColor?: string; children?: React.ReactNode; draggableHeight?: number; //Default 25 scrollEnabled?: boolean; // Управление скроллом контента (если не задано, определяется автоматически) } export interface ZeroSheetHandle { open: () => void; close: () => void; expand: () => void; } const SWIPE_CLOSE_THRESHOLD = 100; const openDuration = 300; const closeDuration = 200; type ZeroSheetRegistry = { footerContentHeight: number; headerContentHeight: number; contentContentHeight: number; keyboardContentHeight: number; setFooterContentHeight: (height: number) => void; setHeaderContentHeight: (height: number) => void; setContentContentHeight: (height: number) => void; setKeyboardContentHeight: (height: number) => void; fixedFooterHeight?: number; fixedHeaderHeight?: number; fixedContentHeight?: number; draggableHeight: number; topInsets: number; buttonInsets: number; }; const ZeroSheetContext = createContext(null); export const useZeroSheetContext = () => { const ctx = useContext(ZeroSheetContext); if (!ctx) throw new Error('useZeroSheetContext must be used within '); return ctx; }; const ZeroSheet = forwardRef((props, ref) => { const config = useZeroSheetConfig(); const { fixedFooterHeight, fixedHeaderHeight, fixedContentHeight, draggableHeight = config?.draggableHeight ?? 25, draggable = config?.draggable ?? true, footer, before, header, closeByBackdropClick = config?.closeByBackdropClick ?? true, backgroundColor = config?.backgroundColor ?? '#fff', backdrop = config?.backdrop ?? true, renderBackground, customStyles: propsCustomStyles, onSheetOpen, onChange, onManualClose, buttonColor = config?.buttonColor, onSheetClose, // onSheetOpened, // onSheetClosed, scrollEnabled, children = } = props; const insets = useSafeAreaInsets(); const noBottomInset = props.noBottomInset ?? config?.noBottomInset ?? false; const noTopInset = props.noTopInset ?? config?.noTopInset ?? false; const extraBottomInset = props.extraBottomInset ?? config?.extraBottomInset ?? 0; const extraTopInset = props.extraTopInset ?? config?.extraTopInset ?? 0; const buttonInsets = noBottomInset ? 0 : extraBottomInset + insets.bottom; const topInsets = noTopInset ? 0 : extraTopInset + insets.top; // Объединяем customStyles из провайдера и props (props имеют приоритет) const customStyles = useMemo(() => { const providerStyles = config?.customStyles ?? {}; const mergedStyles = { ...providerStyles }; if (propsCustomStyles) { mergedStyles.wrapper = { ...providerStyles.wrapper, ...propsCustomStyles.wrapper }; mergedStyles.container = { ...providerStyles.container, ...propsCustomStyles.container }; mergedStyles.draggableIcon = { ...providerStyles.draggableIcon, ...propsCustomStyles.draggableIcon }; } return mergedStyles; }, [config?.customStyles, propsCustomStyles]); const [modalVisible, setModalVisible] = useState(false); const modalVisibleRef = useRef(modalVisible); const onManualCloseRef = useRef(onManualClose); const handleSetVisibleRef = useRef<((visible: boolean) => void) | null>(null); const onChangeRef = useRef(onChange); const onSheetOpenRef = useRef(onSheetOpen); const onSheetCloseRef = useRef(onSheetClose); // Синхронизируем ref с состоянием и функциями useEffect(() => { modalVisibleRef.current = modalVisible; }, [modalVisible]); useEffect(() => { onManualCloseRef.current = onManualClose; }, [onManualClose]); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { onSheetOpenRef.current = onSheetOpen; }, [onSheetOpen]); useEffect(() => { onSheetCloseRef.current = onSheetClose; }, [onSheetClose]); const translateY = useRef(new Animated.Value(0)).current; useImperativeHandle(ref, () => ({ open: () => handleSetVisible(true), close: () => handleSetVisible(false), expand: () => handleSetVisible(true) })); const backHandlerRef = useRef(null); useEffect(() => { if (modalVisible) { // Добавляем слушатель кнопки назад только на Android и только если есть возможность закрытия if (Platform.OS === 'android' && (draggable || closeByBackdropClick)) { if (backHandlerRef.current) { backHandlerRef.current.remove(); backHandlerRef.current = null; } //Делаем перехват события кнопки назад на андроиде backHandlerRef.current = BackHandler.addEventListener('hardwareBackPress', () => { if (modalVisibleRef.current && handleSetVisibleRef.current) { onManualCloseRef.current?.(); handleSetVisibleRef.current(false); return true; } return false; }); } onSheetOpenRef.current?.(); // Keyboard.dismiss(); Animated.parallel([ Animated.timing(translateY, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.ease), useNativeDriver: false, }), ]).start(() => { onChangeRef.current?.(true); }); } else { // Удаляем слушатель при закрытии, чтобы другие слушатели работали if (backHandlerRef.current) { backHandlerRef.current.remove(); backHandlerRef.current = null; } onChangeRef.current?.(false); } }, [modalVisible, draggable, closeByBackdropClick, translateY]) const handleSetVisible = useCallback((visible: boolean) => { if (visible) { setModalVisible(true); } else { onSheetCloseRef.current?.(); Animated.parallel([ Animated.timing(translateY, { toValue: winHeight, duration: closeDuration, easing: Easing.out(Easing.ease), useNativeDriver: false, }), ]).start(() => { setModalVisible(false); }); } }, [translateY]); useEffect(() => { handleSetVisibleRef.current = handleSetVisible; }, [handleSetVisible]); useEffect(() => { if (props.visible !== undefined && handleSetVisibleRef.current) { handleSetVisibleRef.current(props.visible); } }, [props.visible]); const delayUpdateKeyboardHeight = useRef(null); useEffect(() => { const keyboardWillShowListener = Keyboard.addListener( Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', (e) => { clearTimeout(delayUpdateKeyboardHeight.current!); const keyboardHeight = e.endCoordinates.height; delayUpdateKeyboardHeight.current = setTimeout(() => { if (Platform.OS == 'ios') { setKeyboardContentHeight(keyboardHeight - buttonInsets); } else { setKeyboardContentHeight(keyboardHeight); } }, 200); } ); const keyboardWillHideListener = Keyboard.addListener( Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', () => { clearTimeout(delayUpdateKeyboardHeight.current!); setKeyboardContentHeight(0); } ); return () => { keyboardWillShowListener.remove(); keyboardWillHideListener.remove(); }; }, [buttonInsets]); const panResponder = useMemo( () => PanResponder.create({ onMoveShouldSetPanResponder: (_, gestureState) => { return Math.abs(gestureState.dy) > 5; }, onPanResponderMove: (_, gestureState) => { if (gestureState.dy > 0) { translateY.setValue(gestureState.dy); } }, onPanResponderRelease: (_, gestureState) => { if (gestureState.dy > SWIPE_CLOSE_THRESHOLD) { onManualClose?.(); handleSetVisible(false); } else { Animated.spring(translateY, { toValue: 0, useNativeDriver: false }).start(); } } }), [onManualClose, translateY, handleSetVisible] ); const onCloseByBackdropClick = () => { if (closeByBackdropClick) { onManualClose?.(); handleSetVisible(false); } }; const backdropOpacity = translateY.interpolate({ inputRange: [0, winHeight], outputRange: [1, 0], extrapolate: 'clamp' }); const [footerContentHeight, setFooterContentHeight] = useState(0); const [headerContentHeight, setHeaderContentHeight] = useState(0); const [contentContentHeight, setContentContentHeight] = useState(0); const [keyboardContentHeight, setKeyboardContentHeight] = useState(0); if (!modalVisible) return null; return ( {backdrop && ( )} {backdrop ? ( ) : ( )} {!!before && before} {renderBackground} {draggable && } {header} {children} {!!footer && ( {footer} )} ); }); const styles = StyleSheet.create({ wrapper: { flex: 1 }, mask: { flex: 1, backgroundColor: 'transparent' }, container: { width: '100%', overflow: 'hidden' }, draggableContainer: { width: '100%' }, draggableIcon: { width: 34, height: 5, borderRadius: 24, margin: 10, backgroundColor: 'gray' } }); export default ZeroSheet;