|
@@ -1,7 +1,14 @@
|
|
|
import {
|
|
import {
|
|
|
- type FC
|
|
|
|
|
|
|
+ useState,
|
|
|
|
|
+ type FC,
|
|
|
|
|
+ useRef,
|
|
|
|
|
+ useEffect
|
|
|
} from "react";
|
|
} from "react";
|
|
|
import {
|
|
import {
|
|
|
|
|
+ type LayoutChangeEvent,
|
|
|
|
|
+ PanResponder,
|
|
|
|
|
+ ScrollView,
|
|
|
|
|
+ Animated,
|
|
|
View
|
|
View
|
|
|
} from "react-native";
|
|
} from "react-native";
|
|
|
import type IBottomSheetProps from "./type";
|
|
import type IBottomSheetProps from "./type";
|
|
@@ -10,15 +17,27 @@ import {
|
|
|
NCoreUIKitTheme
|
|
NCoreUIKitTheme
|
|
|
} from "../../core/hooks";
|
|
} from "../../core/hooks";
|
|
|
import {
|
|
import {
|
|
|
- SafeAreaView
|
|
|
|
|
|
|
+ useSafeAreaInsets
|
|
|
} from "react-native-safe-area-context";
|
|
} from "react-native-safe-area-context";
|
|
|
import Modal from "../modal";
|
|
import Modal from "../modal";
|
|
|
|
|
+import {
|
|
|
|
|
+ windowHeight
|
|
|
|
|
+} from "../../utils";
|
|
|
|
|
|
|
|
const BottomSheet: FC<IBottomSheetProps> = ({
|
|
const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
|
|
+ renderBottom: RenderBottomComponent,
|
|
|
|
|
+ isForceFullScreenOnSwipe = false,
|
|
|
|
|
+ isCanFullScreenOnSwipe = false,
|
|
|
|
|
+ handleContainerBackgroundColor,
|
|
|
|
|
+ handleHeight: handleHeightProp,
|
|
|
isWrapSafeareaContext = true,
|
|
isWrapSafeareaContext = true,
|
|
|
- safeAreaViewBackgroundColor,
|
|
|
|
|
backgroundColor = "default",
|
|
backgroundColor = "default",
|
|
|
- safeAreaViewStyle,
|
|
|
|
|
|
|
+ isWorkAsFullScreen = false,
|
|
|
|
|
+ handleContainerSpacing,
|
|
|
|
|
+ handleBackgroundColor,
|
|
|
|
|
+ isAutoHeight = false,
|
|
|
|
|
+ isShowHandle = true,
|
|
|
|
|
+ snapPoint,
|
|
|
children,
|
|
children,
|
|
|
style,
|
|
style,
|
|
|
...props
|
|
...props
|
|
@@ -28,42 +47,322 @@ const BottomSheet: FC<IBottomSheetProps> = ({
|
|
|
spaces
|
|
spaces
|
|
|
} = NCoreUIKitTheme.useContext();
|
|
} = NCoreUIKitTheme.useContext();
|
|
|
|
|
|
|
|
|
|
+ const {
|
|
|
|
|
+ bottom,
|
|
|
|
|
+ top
|
|
|
|
|
+ } = useSafeAreaInsets();
|
|
|
|
|
+
|
|
|
|
|
+ const [
|
|
|
|
|
+ isMeasured,
|
|
|
|
|
+ setIsMeasured
|
|
|
|
|
+ ] = useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ const animatedHeight = useRef(new Animated.Value(
|
|
|
|
|
+ (!isAutoHeight && typeof snapPoint === "number") ? snapPoint : 0
|
|
|
|
|
+ )).current;
|
|
|
|
|
+ const animatedTranslateY = useRef(new Animated.Value(0)).current;
|
|
|
|
|
+
|
|
|
|
|
+ const lastTranslateY = useRef(0);
|
|
|
|
|
+ const isAtBottom = useRef(false);
|
|
|
|
|
+ const lastHeight = useRef(0);
|
|
|
|
|
+ const startDy = useRef(0);
|
|
|
|
|
+
|
|
|
|
|
+ const scrollOffset = useRef(0);
|
|
|
|
|
+
|
|
|
|
|
+ let bottomSafeArea = isWrapSafeareaContext ? bottom : 0;
|
|
|
|
|
+ let topSafeArea = isWrapSafeareaContext ? top : 0;
|
|
|
|
|
+
|
|
|
|
|
+ if(isWorkAsFullScreen) {
|
|
|
|
|
+ bottomSafeArea = 0;
|
|
|
|
|
+ topSafeArea = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if(isForceFullScreenOnSwipe) {
|
|
|
|
|
+ topSafeArea = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const maxHeight = windowHeight - topSafeArea;
|
|
|
|
|
+
|
|
|
|
|
+ let initialHeight: string | number = "auto";
|
|
|
|
|
+
|
|
|
|
|
+ if(!isAutoHeight && snapPoint) {
|
|
|
|
|
+ initialHeight = snapPoint;
|
|
|
|
|
+
|
|
|
|
|
+ if(initialHeight > maxHeight) initialHeight = maxHeight;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!isAutoHeight && typeof initialHeight === "number") {
|
|
|
|
|
+ animatedHeight.setValue(initialHeight);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [initialHeight]);
|
|
|
|
|
+
|
|
|
|
|
+ const onLayout = (event: LayoutChangeEvent) => {
|
|
|
|
|
+ if (isMeasured) return;
|
|
|
|
|
+
|
|
|
|
|
+ const {
|
|
|
|
|
+ height
|
|
|
|
|
+ } = event.nativeEvent.layout;
|
|
|
|
|
+
|
|
|
|
|
+ let _height = height;
|
|
|
|
|
+
|
|
|
|
|
+ if(height > maxHeight) _height = maxHeight;
|
|
|
|
|
+
|
|
|
|
|
+ animatedHeight.setValue(_height);
|
|
|
|
|
+ setIsMeasured(true);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const snapTo = (targetHeight: number, targetTranslateY: number) => {
|
|
|
|
|
+ Animated.parallel([
|
|
|
|
|
+ Animated.spring(animatedHeight, {
|
|
|
|
|
+ useNativeDriver: true,
|
|
|
|
|
+ toValue: targetHeight,
|
|
|
|
|
+ tension: 50,
|
|
|
|
|
+ friction: 8
|
|
|
|
|
+ }),
|
|
|
|
|
+ Animated.spring(animatedTranslateY, {
|
|
|
|
|
+ toValue: targetTranslateY,
|
|
|
|
|
+ useNativeDriver: true,
|
|
|
|
|
+ tension: 50,
|
|
|
|
|
+ friction: 8
|
|
|
|
|
+ })
|
|
|
|
|
+ ]).start();
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const panResponder = useRef(
|
|
|
|
|
+ PanResponder.create({
|
|
|
|
|
+ onStartShouldSetPanResponder: () => false,
|
|
|
|
|
+ onMoveShouldSetPanResponder: (evt, gestureState) => {
|
|
|
|
|
+ const {
|
|
|
|
|
+ dy,
|
|
|
|
|
+ dx
|
|
|
|
|
+ } = gestureState;
|
|
|
|
|
+ // @ts-ignore
|
|
|
|
|
+ const currentH = animatedHeight._value;
|
|
|
|
|
+ const isAtMax = currentH >= maxHeight - 5;
|
|
|
|
|
+
|
|
|
|
|
+ // Yatay kaydırmayı engelle (içeride slider varsa çakışmasın)
|
|
|
|
|
+ if (Math.abs(dx) > Math.abs(dy)) return false;
|
|
|
|
|
+
|
|
|
|
|
+ // DURUM 1: Aşağı çekiyoruz (dy > 0)
|
|
|
|
|
+ if (dy > 0) {
|
|
|
|
|
+ // Scroll en tepedeyse kontrolü PanResponder alır
|
|
|
|
|
+ if (scrollOffset.current <= 0) return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // DURUM 2: Yukarı çekiyoruz (dy < 0)
|
|
|
|
|
+ if (dy < 0) {
|
|
|
|
|
+ // Sayfa tam açık değilse VEYA scroll en sona dayandıysa kontrolü al
|
|
|
|
|
+ // (isCanFullScreenOnSwipe false olsa bile snapPoint'e kadar çekebilmeli)
|
|
|
|
|
+ if (!isAtMax || isAtBottom.current) return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return false;
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ onPanResponderGrant: (evt, gestureState) => {
|
|
|
|
|
+ startDy.current = gestureState.dy;
|
|
|
|
|
+ // @ts-ignore
|
|
|
|
|
+ lastHeight.current = animatedHeight._value;
|
|
|
|
|
+ // @ts-ignore
|
|
|
|
|
+ lastTranslateY.current = animatedTranslateY._value;
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ onPanResponderMove: (evt, gestureState) => {
|
|
|
|
|
+ const correctedDy = gestureState.dy - startDy.current;
|
|
|
|
|
+ const currentSnap = typeof snapPoint === "number" ? snapPoint : lastHeight.current;
|
|
|
|
|
+
|
|
|
|
|
+ if (correctedDy < 0) { // YUKARI HAREKET
|
|
|
|
|
+ if (lastTranslateY.current > 0) {
|
|
|
|
|
+ // Sayfa aşağı kaymışsa önce translateY'i sıfırla
|
|
|
|
|
+ const nextY = Math.max(0, lastTranslateY.current + correctedDy);
|
|
|
|
|
+ animatedTranslateY.setValue(nextY);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // ÜST LİMİT KONTROLÜ: Tam ekran izni yoksa snapPoint/lastHeight'ta kilitle
|
|
|
|
|
+ // Not: isCanFullScreenOnSwipe prop'unu buradan kontrol ediyoruz
|
|
|
|
|
+ const upperLimit = isCanFullScreenOnSwipe ? maxHeight : currentSnap;
|
|
|
|
|
+
|
|
|
|
|
+ const nextH = Math.min(upperLimit, lastHeight.current - correctedDy);
|
|
|
|
|
+ animatedHeight.setValue(nextH);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ else { // AŞAĞI HAREKET
|
|
|
|
|
+ if (lastHeight.current > currentSnap) {
|
|
|
|
|
+ // Boyu snapPoint'e kadar düşür
|
|
|
|
|
+ const nextH = Math.max(currentSnap, lastHeight.current - correctedDy);
|
|
|
|
|
+ animatedHeight.setValue(nextH);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // snapPoint'teyiz, artık komple aşağı kaydır
|
|
|
|
|
+ const nextY = lastTranslateY.current + correctedDy;
|
|
|
|
|
+ animatedTranslateY.setValue(nextY);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ onPanResponderRelease: (evt, gestureState) => {
|
|
|
|
|
+ const {
|
|
|
|
|
+ dy,
|
|
|
|
|
+ vy
|
|
|
|
|
+ } = gestureState;
|
|
|
|
|
+ // @ts-ignore
|
|
|
|
|
+ const currentH = animatedHeight._value;
|
|
|
|
|
+ const currentSnap = typeof snapPoint === "number" ? snapPoint : lastHeight.current;
|
|
|
|
|
+
|
|
|
|
|
+ // 1. Karar: Aşağı fırlatma
|
|
|
|
|
+ if (vy > 0.5 || dy > 150) {
|
|
|
|
|
+ if (currentH > currentSnap + 50) {
|
|
|
|
|
+ // Tam ekrandan snapPoint'e düş
|
|
|
|
|
+ snapTo(currentSnap, 0);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // snapPoint'ten aşağı, yani kapat
|
|
|
|
|
+ snapTo(currentSnap, windowHeight);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 2. Karar: Yukarı fırlatma
|
|
|
|
|
+ else if (vy < -0.5 || dy < -100) {
|
|
|
|
|
+ if (isCanFullScreenOnSwipe) {
|
|
|
|
|
+ snapTo(maxHeight, 0);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ snapTo(currentSnap, 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 3. Karar: Yavaş bırakma (En yakın noktaya mıknatısla)
|
|
|
|
|
+ else {
|
|
|
|
|
+ // @ts-ignore
|
|
|
|
|
+ const currentY = animatedTranslateY._value;
|
|
|
|
|
+
|
|
|
|
|
+ if (isCanFullScreenOnSwipe && currentH > (maxHeight + currentSnap) / 2) {
|
|
|
|
|
+ snapTo(maxHeight, 0);
|
|
|
|
|
+ } else if (currentY > 100) {
|
|
|
|
|
+ snapTo(currentSnap, windowHeight); // Çok aşağıda kaldıysa kapat
|
|
|
|
|
+ } else {
|
|
|
|
|
+ snapTo(currentSnap, 0); // Diğer durumlarda snapPoint/Mevcut boyda kal
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ onPanResponderTerminationRequest: () => false,
|
|
|
|
|
+ onShouldBlockNativeResponder: () => true
|
|
|
|
|
+ }),
|
|
|
|
|
+ ).current;
|
|
|
|
|
+
|
|
|
|
|
+ const handleScroll = (event: any) => {
|
|
|
|
|
+ const {
|
|
|
|
|
+ layoutMeasurement,
|
|
|
|
|
+ contentOffset,
|
|
|
|
|
+ contentSize
|
|
|
|
|
+ } = event.nativeEvent;
|
|
|
|
|
+ scrollOffset.current = contentOffset.y;
|
|
|
|
|
+
|
|
|
|
|
+ const isBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 5;
|
|
|
|
|
+ isAtBottom.current = isBottom;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
const renderView = () => {
|
|
const renderView = () => {
|
|
|
- return <View
|
|
|
|
|
|
|
+ const currentHeight = (isAutoHeight && !isMeasured) ? undefined : animatedHeight;
|
|
|
|
|
+
|
|
|
|
|
+ return <Animated.View
|
|
|
{...props}
|
|
{...props}
|
|
|
|
|
+ {...panResponder.panHandlers}
|
|
|
|
|
+ onLayout={onLayout}
|
|
|
style={[
|
|
style={[
|
|
|
{
|
|
{
|
|
|
backgroundColor: colors.content.container[backgroundColor],
|
|
backgroundColor: colors.content.container[backgroundColor],
|
|
|
- padding: spaces.spacingMd
|
|
|
|
|
|
|
+ paddingBottom: bottomSafeArea + spaces.spacingMd,
|
|
|
|
|
+ opacity: isMeasured || !isAutoHeight ? 1 : 0,
|
|
|
|
|
+ paddingRight: spaces.spacingMd,
|
|
|
|
|
+ paddingLeft: spaces.spacingMd,
|
|
|
|
|
+ paddingTop: spaces.spacingMd,
|
|
|
|
|
+ height: currentHeight,
|
|
|
|
|
+ maxHeight: maxHeight,
|
|
|
|
|
+ transform: [{
|
|
|
|
|
+ translateY: animatedTranslateY
|
|
|
|
|
+ }]
|
|
|
},
|
|
},
|
|
|
stylesheet.container,
|
|
stylesheet.container,
|
|
|
style
|
|
style
|
|
|
]}
|
|
]}
|
|
|
>
|
|
>
|
|
|
- {children}
|
|
|
|
|
- </View>;
|
|
|
|
|
|
|
+ <ScrollView
|
|
|
|
|
+ onStartShouldSetResponderCapture={() => false}
|
|
|
|
|
+ onMoveShouldSetResponderCapture={() => false}
|
|
|
|
|
+ showsHorizontalScrollIndicator={false}
|
|
|
|
|
+ showsVerticalScrollIndicator={false}
|
|
|
|
|
+ scrollEventThrottle={16}
|
|
|
|
|
+ onScroll={handleScroll}
|
|
|
|
|
+ overScrollMode="never"
|
|
|
|
|
+ bounces={false}
|
|
|
|
|
+ >
|
|
|
|
|
+ {children}
|
|
|
|
|
+ </ScrollView>
|
|
|
|
|
+ {renderBottom()}
|
|
|
|
|
+ {renderHandle()}
|
|
|
|
|
+ </Animated.View>;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const renderWithSafeareaView = () => {
|
|
|
|
|
- return <SafeAreaView
|
|
|
|
|
|
|
+ const renderBottom = () => {
|
|
|
|
|
+ if(!RenderBottomComponent) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return <RenderBottomComponent/>;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const renderHandle = () => {
|
|
|
|
|
+ if(!isShowHandle) {
|
|
|
|
|
+ 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={[
|
|
style={[
|
|
|
- safeAreaViewStyle,
|
|
|
|
|
|
|
+ stylesheet.handleContainer,
|
|
|
{
|
|
{
|
|
|
- backgroundColor: safeAreaViewBackgroundColor ? colors.content.container[safeAreaViewBackgroundColor] : colors.content.container[backgroundColor]
|
|
|
|
|
- },
|
|
|
|
|
- stylesheet.safeAreaViewContainer
|
|
|
|
|
|
|
+ backgroundColor: handleContainerBackgroundColor ? colors.content.container[handleContainerBackgroundColor] : "transparent",
|
|
|
|
|
+ height: handleContainerHeight,
|
|
|
|
|
+ transform: [{
|
|
|
|
|
+ translateY: -handleContainerHeight
|
|
|
|
|
+ }]
|
|
|
|
|
+ }
|
|
|
]}
|
|
]}
|
|
|
>
|
|
>
|
|
|
- {renderView()}
|
|
|
|
|
- </SafeAreaView>;
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- const renderSafeareaContext = () => {
|
|
|
|
|
- return isWrapSafeareaContext ? renderWithSafeareaView() : renderView();
|
|
|
|
|
|
|
+ <View
|
|
|
|
|
+ style={[
|
|
|
|
|
+ stylesheet.handle,
|
|
|
|
|
+ {
|
|
|
|
|
+ backgroundColor: handleBackgroundColor ? colors.content.container[handleBackgroundColor] : colors.content.container.default,
|
|
|
|
|
+ borderRadius: handleBorderRadius,
|
|
|
|
|
+ height: handleHeight
|
|
|
|
|
+ }
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </View>;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- return <Modal>
|
|
|
|
|
- {renderSafeareaContext()}
|
|
|
|
|
|
|
+ return <Modal
|
|
|
|
|
+ isContentRequired={false}
|
|
|
|
|
+ isAnimated={false}
|
|
|
|
|
+ overlayProps={{
|
|
|
|
|
+ ...panResponder.panHandlers,
|
|
|
|
|
+ onStartShouldSetResponderCapture: () => false,
|
|
|
|
|
+ onMoveShouldSetResponderCapture: () => false
|
|
|
|
|
+ }}
|
|
|
|
|
+ onOverlayPress={() => {
|
|
|
|
|
+ console.error("pressed overlay");
|
|
|
|
|
+ }}
|
|
|
|
|
+ style={[
|
|
|
|
|
+ {
|
|
|
|
|
+ paddingTop: topSafeArea
|
|
|
|
|
+ }
|
|
|
|
|
+ ]}
|
|
|
|
|
+ >
|
|
|
|
|
+ {renderView()}
|
|
|
</Modal>;
|
|
</Modal>;
|
|
|
};
|
|
};
|
|
|
export default BottomSheet;
|
|
export default BottomSheet;
|