@naarni/design-system
Version:
Naarni React Native Design System for EV Fleet Apps
147 lines (146 loc) • 6 kB
JavaScript
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>);
};