react-native-modern-elements
Version:
A modern, customizable UI component library for React Native
219 lines (218 loc) • 9.79 kB
JavaScript
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);