UNPKG

react-native-modern-elements

Version:

A modern, customizable UI component library for React Native

219 lines (218 loc) 9.79 kB
import React, { forwardRef, memo, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react"; import { Animated, Dimensions, Keyboard, Modal, PanResponder, StatusBar, StyleSheet, TouchableOpacity, View, } from "react-native"; import ChevronUpArrow from "../assets/svg/BottomSheet/chevronUpArrow"; import ChevronDownArrow from "../assets/svg/BottomSheet/ChevronDownArrow"; import { verticalScale } from "../utils/styling"; const { height: SCREEN_HEIGHT } = Dimensions.get("window"); const BottomSheet = forwardRef((props, ref) => { const { snapPoints = ["30%"], openDuration = 350, closeDuration = 300, closeOnPressMask = true, closeOnPressBack = true, draggable = true, dragOnContent = true, onOpen, onClose, children, mainContainer = {}, wrapperColors = {}, defaultOpen = false, // <-- new default // showIndicator = true, statusBar = false, } = props; const defaultHeight = 260; const [modalVisible, setModalVisible] = useState(false); const [isSnapping, setIsSnapping] = useState(false); const animatedHeight = useRef(new Animated.Value(0)).current; const keyboardPadding = useRef(new Animated.Value(0)).current; const startHeight = useRef(defaultHeight); const [dragDirection, setDragDirection] = useState(null); const indicatorOpacity = useRef(new Animated.Value(0)).current; // Keyboard adjustment useEffect(() => { const show = Keyboard.addListener("keyboardDidShow", (e) => { Animated.timing(keyboardPadding, { toValue: e.endCoordinates.height, duration: 150, useNativeDriver: false, }).start(); }); const hide = Keyboard.addListener("keyboardDidHide", () => { Animated.timing(keyboardPadding, { toValue: 0, duration: 150, useNativeDriver: false, }).start(); }); return () => { show.remove(); hide.remove(); }; }, []); // Open automatically if defaultOpen is true useEffect(() => { if (defaultOpen) { setVisible(true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultOpen]); useEffect(() => { if (!props.showIndicator) { Animated.timing(indicatorOpacity, { toValue: 0, duration: 200, useNativeDriver: true, }).start(); return; } Animated.timing(indicatorOpacity, { toValue: 1, duration: 200, useNativeDriver: true, }).start(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [dragDirection, props.showIndicator]); // Convert snap points to pixels const snapPointsPixels = useMemo(() => snapPoints.map((p) => typeof p === "string" && p.includes("%") ? (parseFloat(p) / 100) * SCREEN_HEIGHT : Number(p)), [snapPoints]); const defaultSnap = snapPointsPixels[0] || defaultHeight; const maxSnap = Math.max(...snapPointsPixels); // Snap to index manually const snapToIndex = (index) => { const snapHeight = snapPointsPixels[index]; if (!snapHeight) return; setIsSnapping(true); Animated.timing(animatedHeight, { toValue: snapHeight, duration: 250, useNativeDriver: false, }).start(() => setIsSnapping(false)); }; useImperativeHandle(ref, () => ({ open: () => setVisible(true), close: () => setVisible(false), snapToIndex, })); // Pan responder for drag gestures const panResponder = useMemo(() => PanResponder.create({ onStartShouldSetPanResponder: () => draggable, onMoveShouldSetPanResponder: (_, g) => draggable && dragOnContent && Math.abs(g.dy) > 3, onPanResponderGrant: () => { setIsSnapping(true); animatedHeight.stopAnimation((value) => { startHeight.current = value; }); }, onPanResponderMove: (_, gesture) => { const newHeight = startHeight.current - gesture.dy; animatedHeight.setValue(Math.max(0, Math.min(maxSnap, newHeight))); if (!props.showIndicator) return; if (gesture.dy > 0) setDragDirection("down"); else if (gesture.dy < 0) setDragDirection("up"); }, onPanResponderRelease: (_, gesture) => { animatedHeight.stopAnimation((value) => { const currentHeight = value; const direction = gesture.dy > 0 ? "down" : "up"; setDragDirection(null); // reset after release let targetSnap; if (direction === "down") { const below = snapPointsPixels.filter((p) => p <= currentHeight); targetSnap = below.length > 0 ? below.reduce((prev, curr) => Math.abs(curr - currentHeight) < Math.abs(prev - currentHeight) ? curr : prev) : Math.min(...snapPointsPixels); } else { const above = snapPointsPixels.filter((p) => p >= currentHeight); targetSnap = above.length > 0 ? above.reduce((prev, curr) => Math.abs(curr - currentHeight) < Math.abs(prev - currentHeight) ? curr : prev) : Math.max(...snapPointsPixels); } if (targetSnap === undefined) targetSnap = snapPointsPixels[0]; Animated.spring(animatedHeight, { toValue: targetSnap, useNativeDriver: false, }).start(() => setIsSnapping(false)); }); }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [draggable, dragOnContent, snapPointsPixels]); const setVisible = (visible) => { setIsSnapping(true); if (visible) { setModalVisible(true); onOpen === null || onOpen === void 0 ? void 0 : onOpen(); Animated.timing(animatedHeight, { toValue: defaultSnap, duration: openDuration, useNativeDriver: false, }).start(() => setIsSnapping(false)); } else { Animated.timing(animatedHeight, { toValue: 0, duration: closeDuration, useNativeDriver: false, }).start(() => { setModalVisible(false); setIsSnapping(false); onClose === null || onClose === void 0 ? void 0 : onClose(); }); } }; return (React.createElement(Modal, { transparent: true, animationType: "fade", statusBarTranslucent: true, visible: modalVisible, // onRequestClose={() => setVisible(false)} onRequestClose: () => { // Hardware back press on Android if (!modalVisible) { // Sheet is already closed, let back button work normally return; } if (closeOnPressBack) { // If prop allows closing, close the sheet setVisible(false); } // Else, do nothing: sheet stays open // This effectively blocks the hardware back } }, modalVisible && statusBar && (React.createElement(StatusBar, { backgroundColor: "#828181", barStyle: "light-content", animated: true })), React.createElement(View, { style: [styles.wrapper, wrapperColors] }, React.createElement(TouchableOpacity, { style: styles.mask, activeOpacity: 1, onPress: closeOnPressMask ? () => setVisible(false) : undefined }), React.createElement(Animated.View, Object.assign({}, (dragOnContent ? panResponder.panHandlers : {}), { style: [ styles.container, { height: animatedHeight, paddingBottom: keyboardPadding }, mainContainer, ] }), props.showIndicator && (React.createElement(Animated.View, { style: { alignItems: "center", justifyContent: "center", height: verticalScale(25), opacity: indicatorOpacity, } }, dragDirection === "up" && React.createElement(ChevronUpArrow, null), dragDirection === "down" && React.createElement(ChevronDownArrow, null), dragDirection === null && (React.createElement(View, { style: { width: verticalScale(25), borderRadius: verticalScale(30), height: verticalScale(3.5), backgroundColor: "#4C4C4C", } })))), React.createElement(View, { pointerEvents: isSnapping ? "none" : "auto", style: { flex: 1 } }, children))))); }); BottomSheet.displayName = "BottomSheet"; const styles = StyleSheet.create({ wrapper: { flex: 1, backgroundColor: "#00000077" }, mask: { flex: 1, backgroundColor: "transparent" }, container: { backgroundColor: "#fff", width: "100%", height: 0, overflow: "hidden", borderTopRightRadius: 30, borderTopLeftRadius: 30, }, }); export default memo(BottomSheet);