| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777 |
- import {
- useImperativeHandle,
- type ComponentRef,
- forwardRef,
- useEffect,
- useState,
- useRef
- } from "react";
- import {
- type LayoutChangeEvent,
- PanResponder,
- ScrollView,
- Animated,
- View
- } from "react-native";
- import type IBottomSheetProps from "./type";
- import type {
- IBottomSheetRef
- } from "./type";
- import stylesheet from "./stylesheet";
- import {
- NCoreUIKitTheme
- } from "../../core/hooks";
- import {
- useSafeAreaInsets
- } from "react-native-safe-area-context";
- import type {
- RefForwardingComponent
- } from "../../types";
- import type {
- IModalRef
- } from "../modal/type";
- import {
- windowHeight,
- uuid
- } from "../../utils";
- import Modal from "../modal";
- const BottomSheet: RefForwardingComponent<IBottomSheetRef, IBottomSheetProps> = ({
- renderHeader: RenderHeaderComponent,
- renderBottom: RenderBottomComponent,
- isForceFullScreenOnSwipe = false,
- isCanFullScreenOnSwipe = false,
- handleContainerBackgroundColor,
- handleHeight: handleHeightProp,
- isWrapSafeAreaContext = true,
- backgroundColor = "default",
- isWorkAsFullScreen = false,
- isWorkWithPortal = true,
- isCloseOnOverlay = true,
- handleContainerSpacing,
- isActive: isActiveProp,
- handleBackgroundColor,
- isAutoHeight = false,
- isSwipeClose = true,
- isShowHandle = true,
- isCanSwipe = true,
- onOverlayPressed,
- scrollViewProps,
- scrollViewStyle,
- modalProps,
- snapPoint,
- customKey,
- children,
- onClosed,
- onOpened,
- onClose,
- onOpen,
- style,
- ...props
- }, ref) => {
- const {
- colors,
- spaces
- } = NCoreUIKitTheme.useContext();
- const {
- bottom,
- top
- } = useSafeAreaInsets();
- const [
- isMeasured,
- setIsMeasured
- ] = useState(false);
- const [
- isActive,
- setIsActive
- ] = useState(isActiveProp === undefined ? false : isActiveProp);
- const bottomSheetKey = useRef(customKey ? customKey : uuid());
- let bottomSafeArea = isWrapSafeAreaContext ? bottom : 0;
- let topSafeArea = isWrapSafeAreaContext ? top : 0;
- if(isForceFullScreenOnSwipe) {
- topSafeArea = 0;
- }
- if(!isWorkWithPortal) {
- bottomSafeArea = 0;
- topSafeArea = 0;
- }
- const scrollViewRef = useRef<ComponentRef<ScrollView>>(null);
- const modalRef = useRef<IModalRef>(null);
- const containerHeightRef = useRef(windowHeight);
- const animatedTranslateY = useRef(new Animated.Value(snapPoint && !isWorkAsFullScreen ? snapPoint : containerHeightRef.current)).current;
- const animatedHeight = useRef(new Animated.Value(
- isAutoHeight ? 0 : snapPoint ?? 0
- )).current;
- const TOP_GRAB_AREA = 140;
- const maxHeight = useRef(isWorkAsFullScreen ? containerHeightRef.current - (isWrapSafeAreaContext ? topSafeArea : 0) : containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : isWrapSafeAreaContext ? topSafeArea : 0));
- const heightValue = useRef(isWorkAsFullScreen ? containerHeightRef.current : snapPoint ?? 0);
- const initialTranslateY = useRef(0);
- const translateYValue = useRef(0);
- const contentHeight = useRef(-1);
- const initialHeight = useRef(0);
- const scrollViewContentHeight = useRef(-1);
- const scrollViewLayoutHeight = useRef(-1);
- const initialScrollOffset = useRef(0);
- const scrollOffset = useRef(0);
- const gestureStartY = useRef(0);
- const isCanFullScreenOnSwipeRef = useRef(isCanFullScreenOnSwipe);
- const isWorkAsFullScreenRef = useRef(isWorkAsFullScreen);
- const isSwipeCloseRef = useRef(isSwipeClose);
- const isCanSwipeRef = useRef(isCanSwipe);
- if(!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
- maxHeight.current = snapPoint;
- }
- useImperativeHandle(
- ref,
- () => ({
- close: (callback) => {
- closeAnimation(undefined, callback);
- },
- open: () => {
- setIsActive(true);
- }
- }),
- []
- );
- useEffect(() => {
- isCanSwipeRef.current = isCanSwipe;
- }, [isCanSwipe]);
- useEffect(() => {
- isSwipeCloseRef.current = isSwipeClose;
- }, [isSwipeClose]);
- useEffect(() => {
- isCanFullScreenOnSwipeRef.current = isCanFullScreenOnSwipe;
- }, [isCanFullScreenOnSwipe]);
- useEffect(() => {
- isWorkAsFullScreenRef.current = isWorkAsFullScreen;
- }, [isWorkAsFullScreen]);
- useEffect(() => {
- if(isMeasured && contentHeight.current !== -1) {
- if(isCanFullScreenOnSwipe && !isWorkAsFullScreen) {
- maxHeight.current = containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : topSafeArea);
- } else if(isAutoHeight || !snapPoint) {
- maxHeight.current = contentHeight.current;
- }
- }
- }, [isMeasured]);
- useEffect(() => {
- if(isActive && isMeasured) {
- if(!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
- maxHeight.current = snapPoint;
- } else if(isWorkAsFullScreen) {
- maxHeight.current = containerHeightRef.current - (isWrapSafeAreaContext ? topSafeArea : 0);
- } else {
- maxHeight.current = containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : isWrapSafeAreaContext ? topSafeArea : 0);
- }
- openAnimation();
- }
- }, [
- isActive,
- isMeasured
- ]);
- if(isActiveProp !== undefined) {
- useEffect(() => {
- setIsActive(isActiveProp);
- }, [isActiveProp]);
- }
- useEffect(() => {
- if (!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
- maxHeight.current = snapPoint;
- } else if (!isWorkAsFullScreen) {
- maxHeight.current = containerHeightRef.current - (isForceFullScreenOnSwipe ? 0 : isWrapSafeAreaContext ? topSafeArea : 0);
- } else {
- maxHeight.current = containerHeightRef.current - (isWrapSafeAreaContext ? topSafeArea : 0);
- }
- }, [
- isForceFullScreenOnSwipe,
- isCanFullScreenOnSwipe,
- isWrapSafeAreaContext,
- isWorkAsFullScreen,
- topSafeArea,
- snapPoint
- ]);
- useEffect(() => {
- if (!isActive) {
- const newSnapValue = isWorkAsFullScreen
- ? containerHeightRef.current
- : (snapPoint ?? contentHeight.current);
- animatedHeight.setValue(isAutoHeight ? 0 : newSnapValue);
- animatedTranslateY.setValue(newSnapValue);
- heightValue.current = newSnapValue;
- translateYValue.current = newSnapValue;
- initialHeight.current = newSnapValue;
- }
- if (!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
- maxHeight.current = snapPoint;
- } else if (!isWorkAsFullScreen) {
- maxHeight.current = containerHeightRef.current - (
- isForceFullScreenOnSwipe ? 0 : isWrapSafeAreaContext ? topSafeArea : 0
- );
- } else {
- maxHeight.current = containerHeightRef.current - (
- isWrapSafeAreaContext ? topSafeArea : 0
- );
- }
- }, [
- snapPoint,
- isWorkAsFullScreen
- ]);
- useEffect(() => {
- const listenerAHeightId = animatedHeight.addListener(({
- value
- }) => {
- heightValue.current = value;
- });
- const listenerTYId = animatedTranslateY.addListener(({
- value
- }) => {
- translateYValue.current = value;
- });
- return () => {
- animatedHeight.removeListener(listenerAHeightId);
- animatedTranslateY.removeListener(listenerTYId);
- };
- }, []);
- const openAnimation = () => {
- resetState();
- if(onOpen) onOpen();
- Animated.timing(animatedTranslateY, {
- useNativeDriver: false,
- duration: 300,
- toValue: 0
- }).start(({
- finished
- }) => {
- if(finished) {
- if(onOpened) onOpened();
- }
- });
- };
- const closeAnimation = (toValue?: number, callback?: () => void) => {
- if(onClose) onClose();
- const currentSnapPoint = isWorkAsFullScreen
- ? containerHeightRef.current
- : (isAutoHeight || !snapPoint ? contentHeight.current : snapPoint);
- Animated.timing(animatedTranslateY, {
- toValue: toValue ?? currentSnapPoint,
- useNativeDriver: false,
- duration: 300
- }).start(({
- finished
- }) => {
- if(finished) {
- resetState();
- setIsActive(false);
- if(onClosed) onClosed();
- if(callback) callback();
- }
- });
- };
- const onLayout = (event: LayoutChangeEvent) => {
- if (isMeasured) return;
- const {
- height
- } = 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;
- const pivot = isWorkAsFullScreen
- ? containerHeightRef.current
- : (snapPoint ?? contentHeight.current);
- initialHeight.current = pivot;
- initialTranslateY.current = 0;
- heightValue.current = pivot;
- translateYValue.current = 0;
- };
- const panResponder = useRef(
- PanResponder.create({
- onStartShouldSetPanResponder: () => false,
- onMoveShouldSetPanResponderCapture: () => isCanSwipeRef.current ? true : false,
- onPanResponderGrant: (evt) => {
- if(!isCanSwipeRef.current) return;
- 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;
- animatedHeight.setOffset(heightValue.current);
- animatedHeight.setValue(0);
- animatedTranslateY.setOffset(translateYValue.current);
- animatedTranslateY.setValue(0);
- },
- onPanResponderMove: (_, gestureState) => {
- if(!isCanSwipeRef.current) return;
- const {
- 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;
- const initialH = initialHeight.current;
- const initialS = initialScrollOffset.current;
- const initialT = initialTranslateY.current;
- const maxS = Math.max(0, scrollViewContentHeight.current - scrollViewLayoutHeight.current);
- if (dy < 0) {
- let currentDelta = delta;
- const effectiveInitialT = Math.max(0, initialT);
- const usedForT = Math.min(currentDelta, effectiveInitialT);
- animatedTranslateY.setValue(-usedForT);
- currentDelta -= usedForT;
- if (currentDelta > 0) {
- if (initialH < pivot) {
- const spaceToPivot = pivot - initialH;
- const usedForH = Math.min(currentDelta, spaceToPivot);
- animatedHeight.setValue(usedForH);
- currentDelta -= usedForH;
- }
- const isRestoringHeight = initialH < pivot;
- const canScroll = isCanFullScreenOnSwipeRef.current || isWorkAsFullScreenRef.current || !isRestoringHeight;
- if (currentDelta > 0 && canScroll) {
- const remainingScroll = maxS - initialS;
- if (remainingScroll > 0) {
- const usedForS = Math.min(currentDelta, remainingScroll);
- scrollOffset.current = initialS + usedForS;
- scrollViewRef.current?.scrollTo({
- y: scrollOffset.current,
- animated: false
- });
- currentDelta -= usedForS;
- animatedHeight.setValue(initialH < pivot ? (pivot - initialH) : 0);
- }
- }
- if (currentDelta > 0) {
- if (isCanFullScreenOnSwipeRef.current) {
- const totalUsedBefore = (initialH < pivot ? (pivot - initialH) : 0);
- animatedHeight.setValue(totalUsedBefore + currentDelta);
- } else {
- animatedHeight.setValue(initialH < pivot ? (pivot - initialH) : 0);
- }
- }
- }
- } else {
- let currentDelta = delta;
- if (initialH > pivot || initialS > 0) {
- if (initialS > 0) {
- const usedForS = Math.max(currentDelta, -initialS);
- scrollOffset.current = initialS + usedForS;
- scrollViewRef.current?.scrollTo({
- y: scrollOffset.current,
- animated: false
- });
- currentDelta -= usedForS;
- animatedHeight.setValue(initialH > pivot ? 0 : pivot - initialH);
- }
- if (currentDelta < 0 && initialH > pivot) {
- const distanceToPivot = pivot - initialH;
- const usedForH = Math.max(currentDelta, distanceToPivot);
- animatedHeight.setValue(usedForH);
- currentDelta -= usedForH;
- }
- }
- if (currentDelta < 0) {
- animatedHeight.setValue(initialH > pivot ? (pivot - initialH) : 0);
- animatedTranslateY.setValue(-currentDelta);
- scrollOffset.current = 0;
- scrollViewRef.current?.scrollTo({
- y: 0,
- animated: false
- });
- }
- }
- },
- onPanResponderEnd: (_, gestureState) => {
- if(!isCanSwipeRef.current) return;
- 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 isFastSwipeDown = gestureState.vy > 0.5;
- const isFastSwipeUp = gestureState.vy < -0.5;
- if (currentT <= 0.5) {
- let toValue = pivot;
- if (isFastSwipeUp && isCanFullScreenOnSwipeRef.current) {
- toValue = tavan;
- } else if (isFastSwipeDown) {
- toValue = pivot;
- } else {
- if (!isCanFullScreenOnSwipeRef.current) {
- toValue = pivot;
- } else {
- const totalRange = tavan - pivot;
- const distFromPivot = currentH - 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, {
- useNativeDriver: false,
- toValue: toValue,
- friction: 10,
- tension: 40
- }).start();
- } else {
- let toValueT = 0;
- let isClosing = false;
- if (!isSwipeCloseRef.current) {
- Animated.spring(animatedHeight, {
- useNativeDriver: false,
- toValue: pivot,
- friction: 10,
- tension: 40
- }).start();
- Animated.timing(animatedTranslateY, {
- useNativeDriver: false,
- duration: 300,
- toValue: 0
- }).start();
- } else {
- if (isFastSwipeDown) {
- toValueT = pivot + 100;
- isClosing = true;
- } else {
- if (currentT > pivot * 0.5) {
- toValueT = pivot + 100;
- isClosing = true;
- } else {
- toValueT = 0;
- isClosing = false;
- }
- }
- if(onClose) onClose();
- Animated.timing(animatedTranslateY, {
- useNativeDriver: false,
- toValue: toValueT,
- duration: 300
- }).start(({
- finished
- }) => {
- if (finished && isClosing) {
- setIsActive(false);
- if (onClosed) onClosed();
- }
- });
- }
- }
- },
- onPanResponderTerminationRequest: () => false,
- onShouldBlockNativeResponder: () => true
- })
- ).current;
- const renderView = () => {
- return <Animated.View
- {...props}
- {...panResponder.panHandlers}
- onLayout={onLayout}
- style={[
- style,
- {
- height: (isAutoHeight || !snapPoint) && !isMeasured ? "auto" : animatedHeight,
- backgroundColor: colors.content.container[backgroundColor],
- paddingBottom: bottomSafeArea + spaces.spacingMd,
- opacity: isMeasured || !isAutoHeight ? 1 : 0,
- paddingRight: spaces.spacingMd,
- paddingLeft: spaces.spacingMd,
- paddingTop: spaces.spacingMd,
- maxHeight: maxHeight.current,
- transform: [{
- translateY: animatedTranslateY
- }]
- },
- stylesheet.container
- ]}
- >
- {renderHeader()}
- <ScrollView
- {...scrollViewProps}
- onContentSizeChange={(w, h) => {
- scrollViewContentHeight.current = h;
- }}
- onLayout={(e) => {
- scrollViewLayoutHeight.current = e.nativeEvent.layout.height;
- }}
- onStartShouldSetResponderCapture={() => false}
- onMoveShouldSetResponderCapture={() => false}
- showsHorizontalScrollIndicator={false}
- showsVerticalScrollIndicator={false}
- scrollEnabled={!isCanSwipe}
- scrollEventThrottle={1}
- overScrollMode="never"
- ref={scrollViewRef}
- bounces={false}
- style={[
- scrollViewStyle
- ]}
- >
- {children}
- </ScrollView>
- {renderBottom()}
- {renderHandle()}
- </Animated.View>;
- };
- const renderHeader = () => {
- if(!RenderHeaderComponent) {
- return null;
- }
- return RenderHeaderComponent();
- };
- const renderBottom = () => {
- if(!RenderBottomComponent) {
- return null;
- }
- return <RenderBottomComponent/>;
- };
- const renderHandle = () => {
- if(!isShowHandle || isWorkAsFullScreen) {
- return null;
- }
- const handleHeight = handleHeightProp ? handleHeightProp : 8;
- const handleContainerHeight = handleHeight + ((handleContainerSpacing ? spaces[handleContainerSpacing] : spaces.spacingSm) * 2);
- const handleBorderRadius = Math.ceil(handleHeight / 2);
- return <View
- {...panResponder.panHandlers}
- onStartShouldSetResponderCapture={() => false}
- onMoveShouldSetResponderCapture={() => false}
- style={[
- stylesheet.handleContainer,
- {
- backgroundColor: handleContainerBackgroundColor
- ? colors.content.container[handleContainerBackgroundColor]
- : "transparent",
- height: handleContainerHeight,
- transform: [{
- translateY: -handleContainerHeight
- }]
- }
- ]}
- >
- <View
- style={[
- stylesheet.handle,
- {
- backgroundColor: handleBackgroundColor
- ? colors.content.container[handleBackgroundColor]
- : colors.content.container.default,
- borderRadius: handleBorderRadius,
- height: handleHeight
- }
- ]}
- />
- </View>;
- };
- return <Modal
- {...modalProps}
- isWorkWithPortal={isWorkWithPortal}
- key={`${bottomSheetKey}-modal`}
- isContentRequired={false}
- isActive={isActive}
- alignContent="free"
- isAnimated={false}
- ref={modalRef}
- onContainerLayout={(e) => {
- const containerLayoutHeight = e.nativeEvent.layout.height;
- if (containerLayoutHeight && containerLayoutHeight !== containerHeightRef.current) {
- containerHeightRef.current = containerLayoutHeight;
- if (!isWorkAsFullScreen && !isCanFullScreenOnSwipe && snapPoint) {
- maxHeight.current = snapPoint;
- } else if (!isWorkAsFullScreen) {
- maxHeight.current = containerLayoutHeight - (isForceFullScreenOnSwipe ? 0 : isWrapSafeAreaContext ? topSafeArea : 0);
- } else {
- maxHeight.current = containerLayoutHeight;
- }
- }
- }}
- overlayProps={{
- onStartShouldSetResponderCapture: () => false,
- onMoveShouldSetResponderCapture: () => false
- }}
- onOverlayPress={() => {
- if(onOverlayPressed) onOverlayPressed();
- if(isCloseOnOverlay) {
- closeAnimation();
- }
- }}
- style={[
- {
- paddingTop: topSafeArea
- }
- ]}
- >
- {renderView()}
- </Modal>;
- };
- export default forwardRef(BottomSheet);
|