react-native-expo-bottomsheet
Version:
A lightWeight and customizeble bottom sheet components for React Native Expo support. Includes Smooth animation
297 lines (291 loc) • 12.1 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 { verticalScale } from "../utils/styling";
import ChevronUpArrow from "../assets/svg/BottomSheet/chevronUpArrow";
import ChevronDownArrow from "../assets/svg/BottomSheet/ChevronDownArrow";
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, enableTouchThrough = false, useModal, } = 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,
// }));
useImperativeHandle(ref, () => ({
open: () => {
setModalVisible(true);
onOpen?.();
Animated.timing(animatedHeight, {
toValue: defaultSnap,
duration: openDuration,
useNativeDriver: false,
}).start(() => setIsSnapping(false));
},
close: () => new Promise((resolve) => {
setIsSnapping(true);
Animated.timing(animatedHeight, {
toValue: 0,
duration: closeDuration,
useNativeDriver: false,
}).start(() => {
setModalVisible(false);
setIsSnapping(false);
onClose?.();
resolve(); // 🔑 Android-safe
});
}),
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?.();
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?.();
});
}
};
return useModal ? (<Modal transparent animationType="fade" statusBarTranslucent 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 && (<StatusBar backgroundColor="#828181" barStyle="light-content" animated/>)}
<View pointerEvents={enableTouchThrough ? "box-none" : "auto"} style={[styles.wrapper, wrapperColors]}>
<TouchableOpacity style={styles.mask} activeOpacity={1} onPress={closeOnPressMask ? () => setVisible(false) : undefined}/>
<Animated.View {...(dragOnContent ? panResponder.panHandlers : {})} style={[
styles.container,
{ height: animatedHeight, paddingBottom: keyboardPadding },
mainContainer,
]}>
{/* Indicator */}
{props.showIndicator && (<Animated.View style={{
alignItems: "center",
justifyContent: "center",
height: verticalScale(25),
opacity: indicatorOpacity,
}}>
{dragDirection === "up" && <ChevronUpArrow />}
{dragDirection === "down" && <ChevronDownArrow />}
{dragDirection === null && (<View style={{
width: verticalScale(25),
borderRadius: verticalScale(30),
height: verticalScale(3.5),
backgroundColor: "#4C4C4C",
}}/>)}
</Animated.View>)}
<View pointerEvents={isSnapping ? "none" : "auto"} style={{ flex: 1 }}>
{children}
</View>
</Animated.View>
</View>
</Modal>) : modalVisible ? (<View pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.wrapper, wrapperColors]}>
{/* Optional dim mask */}
{!enableTouchThrough && (<TouchableOpacity style={styles.mask} activeOpacity={1} onPress={closeOnPressMask ? () => setVisible(false) : undefined}/>)}
{/* {!enableTouchThrough && closeOnPressMask && (
<TouchableOpacity
style={styles.mask}
activeOpacity={1}
onPress={() => setVisible(false)}
/>
)} */}
{/* Bottom Sheet */}
<Animated.View {...(dragOnContent ? panResponder.panHandlers : {})} style={[
styles.container,
{
height: animatedHeight,
paddingBottom: keyboardPadding,
position: "absolute",
bottom: 0,
},
mainContainer,
]}>
{/* Indicator */}
{props.showIndicator && (<Animated.View style={{
alignItems: "center",
justifyContent: "center",
height: verticalScale(25),
opacity: indicatorOpacity,
}}>
{dragDirection === "up" && <ChevronUpArrow />}
{dragDirection === "down" && <ChevronDownArrow />}
{dragDirection === null && (<View style={{
width: verticalScale(25),
borderRadius: verticalScale(30),
height: verticalScale(3.5),
backgroundColor: "#4C4C4C",
}}/>)}
</Animated.View>)}
<View pointerEvents={isSnapping ? "none" : "auto"} style={{ flex: 1 }}>
{children}
</View>
</Animated.View>
</View>) : null;
});
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);