|
|
@@ -1,8 +1,9 @@
|
|
|
import {
|
|
|
+ useImperativeHandle,
|
|
|
type ComponentRef,
|
|
|
+ forwardRef,
|
|
|
useEffect,
|
|
|
useState,
|
|
|
- type FC,
|
|
|
useRef
|
|
|
} from "react";
|
|
|
import {
|
|
|
@@ -13,6 +14,9 @@ import {
|
|
|
View
|
|
|
} from "react-native";
|
|
|
import type IBottomSheetProps from "./type";
|
|
|
+import type {
|
|
|
+ IBottomSheetRef
|
|
|
+} from "./type";
|
|
|
import stylesheet from "./stylesheet";
|
|
|
import {
|
|
|
NCoreUIKitTheme
|
|
|
@@ -20,12 +24,18 @@ import {
|
|
|
import {
|
|
|
useSafeAreaInsets
|
|
|
} from "react-native-safe-area-context";
|
|
|
-import Modal from "../modal";
|
|
|
+import type {
|
|
|
+ RefForwardingComponent
|
|
|
+} from "../../types";
|
|
|
+import type {
|
|
|
+ IModalRef
|
|
|
+} from "../modal/type";
|
|
|
import {
|
|
|
windowHeight
|
|
|
} from "../../utils";
|
|
|
+import Modal from "../modal";
|
|
|
|
|
|
-const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
+const BottomSheet: RefForwardingComponent<IBottomSheetRef, IBottomSheetProps> = ({
|
|
|
renderBottom: RenderBottomComponent,
|
|
|
isForceFullScreenOnSwipe = false,
|
|
|
isCanFullScreenOnSwipe = false,
|
|
|
@@ -34,15 +44,21 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
isWrapSafeareaContext = true,
|
|
|
backgroundColor = "default",
|
|
|
isWorkAsFullScreen = false,
|
|
|
+ isCloseOnOverlay = true,
|
|
|
handleContainerSpacing,
|
|
|
+ isActive: isActiveProp,
|
|
|
handleBackgroundColor,
|
|
|
isAutoHeight = false,
|
|
|
isShowHandle = true,
|
|
|
snapPoint,
|
|
|
children,
|
|
|
+ onClosed,
|
|
|
+ onOpened,
|
|
|
+ onClose,
|
|
|
+ onOpen,
|
|
|
style,
|
|
|
...props
|
|
|
-}) => {
|
|
|
+}, ref) => {
|
|
|
const {
|
|
|
colors,
|
|
|
spaces
|
|
|
@@ -58,6 +74,11 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
setIsMeasured
|
|
|
] = useState(false);
|
|
|
|
|
|
+ const [
|
|
|
+ isActive,
|
|
|
+ setIsActive
|
|
|
+ ] = useState(isActiveProp === undefined ? false : isActiveProp);
|
|
|
+
|
|
|
let bottomSafeArea = isWrapSafeareaContext ? bottom : 0;
|
|
|
let topSafeArea = isWrapSafeareaContext ? top : 0;
|
|
|
|
|
|
@@ -71,12 +92,15 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
}
|
|
|
|
|
|
const scrollViewRef = useRef<ComponentRef<ScrollView>>(null);
|
|
|
+ const modalRef = useRef<IModalRef>(null);
|
|
|
|
|
|
const animatedTranslateY = useRef(new Animated.Value(snapPoint && !isWorkAsFullScreen ? snapPoint : windowHeight)).current;
|
|
|
const animatedHeight = useRef(new Animated.Value(
|
|
|
isAutoHeight ? 0 : snapPoint ?? 0
|
|
|
)).current;
|
|
|
|
|
|
+ const TOP_GRAB_AREA = 140;
|
|
|
+
|
|
|
const maxHeight = useRef(isWorkAsFullScreen ? windowHeight : windowHeight - (isForceFullScreenOnSwipe ? 0 : isWrapSafeareaContext ? topSafeArea : 0));
|
|
|
const heightValue = useRef(isWorkAsFullScreen ? windowHeight : snapPoint ?? 0);
|
|
|
const initialTranslateY = useRef(0);
|
|
|
@@ -89,10 +113,25 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
const initialScrollOffset = useRef(0);
|
|
|
const scrollOffset = useRef(0);
|
|
|
|
|
|
+ const gestureStartY = useRef(0);
|
|
|
+
|
|
|
if(!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
|
|
|
maxHeight.current = snapPoint;
|
|
|
}
|
|
|
|
|
|
+ useImperativeHandle(
|
|
|
+ ref,
|
|
|
+ () => ({
|
|
|
+ close: () => {
|
|
|
+ closeAnimation();
|
|
|
+ },
|
|
|
+ open: () => {
|
|
|
+ setIsActive(true);
|
|
|
+ }
|
|
|
+ }),
|
|
|
+ []
|
|
|
+ );
|
|
|
+
|
|
|
useEffect(() => {
|
|
|
if(isMeasured && contentHeight.current !== -1) {
|
|
|
if(isCanFullScreenOnSwipe && !isWorkAsFullScreen) {
|
|
|
@@ -103,6 +142,15 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
}
|
|
|
}, [isMeasured]);
|
|
|
|
|
|
+ useEffect(() => {
|
|
|
+ if(isActive && isMeasured) {
|
|
|
+ openAnimation();
|
|
|
+ }
|
|
|
+ }, [
|
|
|
+ isActive,
|
|
|
+ isMeasured
|
|
|
+ ]);
|
|
|
+
|
|
|
useEffect(() => {
|
|
|
const listenerAHeightId = animatedHeight.addListener(({
|
|
|
value
|
|
|
@@ -115,7 +163,6 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
}) => {
|
|
|
translateYValue.current = value;
|
|
|
});
|
|
|
- openAnimation();
|
|
|
|
|
|
return () => {
|
|
|
animatedHeight.removeListener(listenerAHeightId);
|
|
|
@@ -124,16 +171,39 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
}, []);
|
|
|
|
|
|
const openAnimation = () => {
|
|
|
- Animated.spring(animatedTranslateY, {
|
|
|
+ resetState();
|
|
|
+ if(onOpen) onOpen();
|
|
|
+
|
|
|
+ Animated.timing(animatedTranslateY, {
|
|
|
useNativeDriver: false,
|
|
|
- friction: 10,
|
|
|
- tension: 40,
|
|
|
+ duration: 300,
|
|
|
toValue: 0
|
|
|
- }).start();
|
|
|
+ }).start(({
|
|
|
+ finished
|
|
|
+ }) => {
|
|
|
+ if(finished) {
|
|
|
+ if(onOpened) onOpened();
|
|
|
+ }
|
|
|
+ });
|
|
|
};
|
|
|
|
|
|
- const closeAnimation = () => {
|
|
|
+ const closeAnimation = (toValue?: number) => {
|
|
|
+ if(onClose) onClose();
|
|
|
+
|
|
|
+ Animated.timing(animatedTranslateY, {
|
|
|
+ toValue: toValue ? toValue : isAutoHeight || !snapPoint ? contentHeight.current : snapPoint,
|
|
|
+ useNativeDriver: false,
|
|
|
+ duration: 300
|
|
|
+ }).start(({
|
|
|
+ finished
|
|
|
+ }) => {
|
|
|
+ if(finished) {
|
|
|
+ resetState();
|
|
|
+ setIsActive(false);
|
|
|
|
|
|
+ if(onClosed) onClosed();
|
|
|
+ }
|
|
|
+ });
|
|
|
};
|
|
|
|
|
|
const onLayout = (event: LayoutChangeEvent) => {
|
|
|
@@ -144,15 +214,44 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
} = event.nativeEvent.layout;
|
|
|
|
|
|
animatedHeight.setValue(height);
|
|
|
+
|
|
|
contentHeight.current = height;
|
|
|
+
|
|
|
setIsMeasured(true);
|
|
|
};
|
|
|
|
|
|
+ const resetState = () => {
|
|
|
+ animatedHeight.setOffset(0);
|
|
|
+ animatedTranslateY.setOffset(0);
|
|
|
+
|
|
|
+ scrollOffset.current = 0;
|
|
|
+ initialScrollOffset.current = 0;
|
|
|
+
|
|
|
+ initialHeight.current = snapPoint ?? contentHeight.current;
|
|
|
+ initialTranslateY.current = 0;
|
|
|
+
|
|
|
+ heightValue.current = snapPoint ?? contentHeight.current;
|
|
|
+ translateYValue.current = 0;
|
|
|
+ };
|
|
|
+
|
|
|
const panResponder = useRef(
|
|
|
PanResponder.create({
|
|
|
onStartShouldSetPanResponder: () => false,
|
|
|
onMoveShouldSetPanResponderCapture: () => true,
|
|
|
- onPanResponderGrant: () => {
|
|
|
+ onPanResponderGrant: (evt) => {
|
|
|
+ gestureStartY.current = evt.nativeEvent.pageY;
|
|
|
+
|
|
|
+ animatedTranslateY.stopAnimation((currentY) => {
|
|
|
+ translateYValue.current = currentY;
|
|
|
+ });
|
|
|
+
|
|
|
+ animatedHeight.stopAnimation((currentH) => {
|
|
|
+ heightValue.current = currentH;
|
|
|
+ });
|
|
|
+
|
|
|
+ animatedTranslateY.flattenOffset();
|
|
|
+ animatedHeight.flattenOffset();
|
|
|
+
|
|
|
initialTranslateY.current = translateYValue.current;
|
|
|
initialScrollOffset.current = scrollOffset.current;
|
|
|
initialHeight.current = heightValue.current;
|
|
|
@@ -168,6 +267,29 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
dy
|
|
|
} = gestureState;
|
|
|
|
|
|
+ const isAtTop = scrollOffset.current <= 0;
|
|
|
+ const isAtTavan = heightValue.current >= maxHeight.current - 1;
|
|
|
+ const hasScroll = scrollViewContentHeight.current > scrollViewLayoutHeight.current;
|
|
|
+
|
|
|
+ const isFromTopArea = gestureStartY.current < TOP_GRAB_AREA;
|
|
|
+
|
|
|
+ if (dy > 0 && isAtTavan && hasScroll && !isAtTop && !isFromTopArea) {
|
|
|
+ const currentDelta = -dy;
|
|
|
+
|
|
|
+ const initialS = initialScrollOffset.current;
|
|
|
+
|
|
|
+ const usedForS = Math.max(currentDelta, -initialS);
|
|
|
+
|
|
|
+ scrollOffset.current = initialS + usedForS;
|
|
|
+
|
|
|
+ scrollViewRef.current?.scrollTo({
|
|
|
+ y: scrollOffset.current,
|
|
|
+ animated: false
|
|
|
+ });
|
|
|
+
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
const delta = -dy;
|
|
|
|
|
|
const pivot = snapPoint ?? contentHeight.current;
|
|
|
@@ -268,15 +390,33 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
}
|
|
|
},
|
|
|
onPanResponderEnd: (_, gestureState) => {
|
|
|
+ const isAtTop = scrollOffset.current <= 1;
|
|
|
+ const isAtTavan = heightValue.current >= maxHeight.current - 1;
|
|
|
+ const hasScroll = scrollViewContentHeight.current > scrollViewLayoutHeight.current;
|
|
|
+
|
|
|
+ const isFromTopArea = gestureStartY.current < TOP_GRAB_AREA;
|
|
|
+
|
|
|
+ if (isAtTavan && hasScroll && !isAtTop && !isFromTopArea) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const currentH = (animatedHeight as Animated.Value & {
|
|
|
+ __getValue(): number;
|
|
|
+ }).__getValue();
|
|
|
+
|
|
|
+ const currentT = (animatedTranslateY as Animated.Value & {
|
|
|
+ __getValue(): number;
|
|
|
+ }).__getValue();
|
|
|
+
|
|
|
animatedHeight.flattenOffset();
|
|
|
animatedTranslateY.flattenOffset();
|
|
|
|
|
|
+ heightValue.current = currentH;
|
|
|
+ translateYValue.current = currentT;
|
|
|
+
|
|
|
const pivot = snapPoint ?? contentHeight.current;
|
|
|
const tavan = maxHeight.current;
|
|
|
|
|
|
- const currentH = heightValue.current;
|
|
|
- const currentT = translateYValue.current;
|
|
|
-
|
|
|
const isFastSwipeDown = gestureState.vy > 0.5;
|
|
|
const isFastSwipeUp = gestureState.vy < -0.5;
|
|
|
|
|
|
@@ -288,10 +428,20 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
} else if (isFastSwipeDown) {
|
|
|
toValue = pivot;
|
|
|
} else {
|
|
|
- const distToTavan = Math.max(0, tavan - currentH);
|
|
|
- const distToPivot = Math.max(0, Math.abs(currentH - pivot));
|
|
|
+ if (!isCanFullScreenOnSwipe) {
|
|
|
+ toValue = pivot;
|
|
|
+ } else {
|
|
|
+ const totalRange = tavan - pivot;
|
|
|
+ const distFromPivot = currentH - pivot;
|
|
|
|
|
|
- toValue = (distToTavan < distToPivot && isCanFullScreenOnSwipe) ? tavan : pivot;
|
|
|
+ if (distFromPivot < totalRange * 0.33) {
|
|
|
+ toValue = pivot;
|
|
|
+ } else if (distFromPivot > totalRange * 0.66) {
|
|
|
+ toValue = tavan;
|
|
|
+ } else {
|
|
|
+ toValue = (tavan - currentH) < (currentH - pivot) ? tavan : pivot;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
Animated.spring(animatedHeight, {
|
|
|
@@ -302,23 +452,37 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
}).start();
|
|
|
} else {
|
|
|
let toValueT = 0;
|
|
|
+ let isClosing = false;
|
|
|
|
|
|
if (isFastSwipeDown) {
|
|
|
toValueT = pivot + 100;
|
|
|
+
|
|
|
+ isClosing = true;
|
|
|
} else {
|
|
|
- if (currentT > pivot * 0.4) {
|
|
|
+ if (currentT > pivot * 0.5) {
|
|
|
toValueT = pivot + 100;
|
|
|
+
|
|
|
+ isClosing = true;
|
|
|
} else {
|
|
|
toValueT = 0;
|
|
|
+
|
|
|
+ isClosing = false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- Animated.spring(animatedTranslateY, {
|
|
|
+ Animated.timing(animatedTranslateY, {
|
|
|
useNativeDriver: false,
|
|
|
toValue: toValueT,
|
|
|
- friction: 10,
|
|
|
- tension: 40
|
|
|
- }).start();
|
|
|
+ duration: 300
|
|
|
+ }).start(({
|
|
|
+ finished
|
|
|
+ }) => {
|
|
|
+ if (finished && isClosing) {
|
|
|
+ setIsActive(false);
|
|
|
+
|
|
|
+ if (onClosed) onClosed();
|
|
|
+ }
|
|
|
+ });
|
|
|
}
|
|
|
},
|
|
|
onPanResponderTerminationRequest: () => false,
|
|
|
@@ -424,13 +588,17 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
|
|
|
return <Modal
|
|
|
isContentRequired={false}
|
|
|
+ isActive={isActive}
|
|
|
isAnimated={false}
|
|
|
+ ref={modalRef}
|
|
|
overlayProps={{
|
|
|
onStartShouldSetResponderCapture: () => false,
|
|
|
onMoveShouldSetResponderCapture: () => false
|
|
|
}}
|
|
|
onOverlayPress={() => {
|
|
|
- console.error("pressed overlay");
|
|
|
+ if(isCloseOnOverlay) {
|
|
|
+ closeAnimation();
|
|
|
+ }
|
|
|
}}
|
|
|
style={[
|
|
|
{
|
|
|
@@ -441,4 +609,4 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
{renderView()}
|
|
|
</Modal>;
|
|
|
};
|
|
|
-export default BottomSheet;
|
|
|
+export default forwardRef(BottomSheet);
|