UNPKG

@naarni/design-system

Version:

Naarni React Native Design System for EV Fleet Apps

147 lines (146 loc) 6 kB
import React, { useCallback, useEffect, useRef } from 'react'; import { View, Dimensions, PanResponder, Platform, Animated, TouchableWithoutFeedback, } from 'react-native'; import { useDeviceTheme } from '../../theme/deviceTheme'; import { Text } from '../Text'; import { Icon } from '../Icon'; import { Button } from '../Button'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { styles } from './BottomSheet.styles'; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); const MARGIN = 16; export const BottomSheet = ({ visible, onClose, title, children, showDragIndicator = true, initialSnapPoint = 0.5, snapPoints = [0.25, 0.5, 0.75], style, contentStyle, enablePanDownToClose = true, onConfirm, confirmText = 'Confirm', cancelText = 'Cancel', }) => { const { colors } = useDeviceTheme(); const insets = useSafeAreaInsets(); const translateY = useRef(new Animated.Value(SCREEN_HEIGHT)).current; const backdropOpacity = useRef(new Animated.Value(0)).current; const isAnimating = useRef(false); const animateTo = useCallback((toValue) => { isAnimating.current = true; Animated.parallel([ Animated.spring(translateY, { toValue, useNativeDriver: true, stiffness: 100, damping: 15, mass: 1, }), Animated.timing(backdropOpacity, { toValue: toValue === SCREEN_HEIGHT ? 0 : 0.5, duration: 200, useNativeDriver: true, }), ]).start(() => { isAnimating.current = false; }); }, [translateY, backdropOpacity]); useEffect(() => { if (visible) { translateY.setValue(SCREEN_HEIGHT); backdropOpacity.setValue(0); requestAnimationFrame(() => { const targetY = SCREEN_HEIGHT * (1 - initialSnapPoint); animateTo(targetY); }); } else { animateTo(SCREEN_HEIGHT); } }, [visible, initialSnapPoint, animateTo]); const panResponder = useRef(PanResponder.create({ onStartShouldSetPanResponder: () => !isAnimating.current, onMoveShouldSetPanResponder: (_, gestureState) => { return Math.abs(gestureState.dy) > 5; }, onPanResponderMove: (_, gestureState) => { if (isAnimating.current) return; const newY = Math.max(0, gestureState.dy); translateY.setValue(newY); const progress = 1 - newY / SCREEN_HEIGHT; backdropOpacity.setValue(progress * 0.5); }, onPanResponderRelease: (_, gestureState) => { if (isAnimating.current) return; const velocity = gestureState.vy; const currentY = translateY._value; const currentSnapPoint = 1 - currentY / SCREEN_HEIGHT; let targetSnapPoint = initialSnapPoint; if (Math.abs(velocity) > 0.5) { if (velocity > 0) { targetSnapPoint = snapPoints[0]; } else { targetSnapPoint = snapPoints[snapPoints.length - 1]; } } else { const distances = snapPoints.map((point) => Math.abs(point - currentSnapPoint)); const closestIndex = distances.indexOf(Math.min(...distances)); targetSnapPoint = snapPoints[closestIndex]; } if (enablePanDownToClose && velocity > 0.5 && currentSnapPoint < 0.25) { onClose(); } else { const targetY = SCREEN_HEIGHT * (1 - targetSnapPoint); animateTo(targetY); } }, })).current; const handleBackdropPress = useCallback(() => { if (!isAnimating.current) { onClose(); } }, [onClose]); const handleClosePress = useCallback(() => { if (!isAnimating.current) { onClose(); } }, [onClose]); if (!visible) return null; return (<View style={styles.modalContainer}> <TouchableWithoutFeedback onPress={handleBackdropPress}> <Animated.View style={[ styles.backdrop, { opacity: backdropOpacity, }, ]}/> </TouchableWithoutFeedback> <Animated.View style={[ styles.container, { transform: [{ translateY }], backgroundColor: colors.background.paper, borderTopColor: colors.grey[300], paddingBottom: insets.bottom, minHeight: SCREEN_HEIGHT * initialSnapPoint, }, Platform.OS === 'ios' ? styles.iosContainer : {}, style, ]} {...panResponder.panHandlers}> {showDragIndicator && (<View style={styles.dragIndicatorContainer}> <View style={[styles.dragIndicator, { backgroundColor: colors.grey[300] }]}/> </View>)} {title && (<View style={styles.header}> <Text variant="h3"> {title} </Text> <Button variant="ghost" onPress={handleClosePress} style={styles.closeButton}> <Icon name="close" size={24} color={colors.text.primary}/> </Button> </View>)} <View style={[styles.content, contentStyle]}>{children}</View> {(onConfirm !== undefined || onClose !== undefined) && (<View style={styles.footer}> {onClose !== undefined && (<Button variant="ghost" onPress={handleClosePress} style={styles.closeButton}> {cancelText} </Button>)} {onConfirm !== undefined && (<Button variant="primary" onPress={onConfirm} style={styles.closeButton}> {confirmText} </Button>)} </View>)} </Animated.View> </View>); };