Procházet zdrojové kódy

Continue: BottomSheet component continue.

lfabl před 2 měsíci
rodič
revize
1bc40e6461

+ 19 - 3
example/src/index.tsx

@@ -5,10 +5,10 @@ import {
 import {
     setupNCoreUIKit,
     NCoreUIKitTheme,
+    BottomSheet,
     SelectBox,
     Button,
-    Text,
-    BottomSheet
+    Text
 } from "ncore-ui-kit-mobile";
 import {
     useFonts
@@ -66,7 +66,23 @@ const App = () => {
             title="Ahmet"
             variant="filled"
         />
-        <BottomSheet>
+        <BottomSheet
+            isCanFullScreenOnSwipe={true}
+            isWrapSafeareaContext={true}
+            snapPoint={300}
+        >
+            <View
+                style={{
+                    backgroundColor: "red",
+                    height: 400
+                }}
+            />
+            <Text>Deneme 123</Text>
+            <Text>Deneme 123</Text>
+            <Text>Deneme 123</Text>
+            <Text>Deneme 123</Text>
+            <Text>Deneme 123</Text>
+            <Text>Deneme 123</Text>
             <Text>Deneme 123</Text>
         </BottomSheet>
     </View>;

+ 321 - 22
src/components/bottomSheet/index.tsx

@@ -1,7 +1,14 @@
 import {
-    type FC
+    useState,
+    type FC,
+    useRef,
+    useEffect
 } from "react";
 import {
+    type LayoutChangeEvent,
+    PanResponder,
+    ScrollView,
+    Animated,
     View
 } from "react-native";
 import type IBottomSheetProps from "./type";
@@ -10,15 +17,27 @@ import {
     NCoreUIKitTheme
 } from "../../core/hooks";
 import {
-    SafeAreaView
+    useSafeAreaInsets
 } from "react-native-safe-area-context";
 import Modal from "../modal";
+import {
+    windowHeight
+} from "../../utils";
 
 const BottomSheet: FC<IBottomSheetProps> = ({
+    renderBottom: RenderBottomComponent,
+    isForceFullScreenOnSwipe = false,
+    isCanFullScreenOnSwipe = false,
+    handleContainerBackgroundColor,
+    handleHeight: handleHeightProp,
     isWrapSafeareaContext = true,
-    safeAreaViewBackgroundColor,
     backgroundColor = "default",
-    safeAreaViewStyle,
+    isWorkAsFullScreen = false,
+    handleContainerSpacing,
+    handleBackgroundColor,
+    isAutoHeight = false,
+    isShowHandle = true,
+    snapPoint,
     children,
     style,
     ...props
@@ -28,42 +47,322 @@ const BottomSheet: FC<IBottomSheetProps> = ({
         spaces
     } = 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 = () => {
-        return <View
+        const currentHeight = (isAutoHeight && !isMeasured) ? undefined : animatedHeight;
+
+        return <Animated.View
             {...props}
+            {...panResponder.panHandlers}
+            onLayout={onLayout}
             style={[
                 {
                     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,
                 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={[
-                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>;
 };
 export default BottomSheet;

+ 16 - 4
src/components/bottomSheet/stylesheet.ts

@@ -1,17 +1,29 @@
 import {
     StyleSheet
 } from "react-native";
+import {
+    windowWidth
+} from "../../utils";
 
 const stylesheet = StyleSheet.create({
     container: {
+        transformOrigin: "bottom",
         backgroundColor: "blue",
         position: "absolute",
-        zIndex: 99999
+        zIndex: 99999,
+        bottom: 0,
+        right: 0,
+        left: 0
     },
-    safeAreaViewContainer: {
-        backgroundColor: "red",
+    handleContainer: {
+        justifyContent: "center",
+        alignItems: "center",
         position: "absolute",
-        zIndex: 99998
+        right: 0,
+        left: 0
+    },
+    handle: {
+        width: windowWidth / 10
     }
 });
 export default stylesheet;

+ 11 - 2
src/components/bottomSheet/type.ts

@@ -7,12 +7,21 @@ import type {
 } from "react-native";
 
 interface IBottomSheetProps {
-    safeAreaViewBackgroundColor?: keyof NCoreUIKit.ContainerContentColors;
+    handleContainerBackgroundColor?: keyof NCoreUIKit.ContainerContentColors;
+    handleContainerSpacing?: keyof NCoreUIKit.ActivePalette["spaces"];
+    handleBackgroundColor?: keyof NCoreUIKit.ContainerContentColors;
     backgroundColor?: keyof NCoreUIKit.ContainerContentColors;
-    safeAreaViewStyle?: StyleProp<ViewStyle>;
+    isForceFullScreenOnSwipe?: boolean;
+    isCanFullScreenOnSwipe?: boolean;
     isWrapSafeareaContext?: boolean;
+    renderBottom?: () => ReactNode;
+    isWorkAsFullScreen?: boolean;
     style?: StyleProp<ViewStyle>;
+    isShowHandle?: boolean;
+    isAutoHeight?: boolean;
+    handleHeight?: number;
     children?: ReactNode;
+    snapPoint?: number;
 }
 export type {
     IBottomSheetProps as default

+ 42 - 24
src/components/modal/index.tsx

@@ -5,6 +5,7 @@ import {
     useRef
 } from "react";
 import {
+    TouchableWithoutFeedback,
     Animated,
     Easing,
     View
@@ -31,10 +32,13 @@ import {
  */
 const Modal: RefForwardingComponent<IModalRef, IModalProps> = ({
     isDisabledOverlay = false,
+    isContentRequired = true,
     isOverlayVisible = true,
     alignContent = "center",
+    isAnimated = true,
     onOverlayPress,
     contentStyle,
+    overlayProps,
     children,
     style
 }, ref) => {
@@ -42,15 +46,17 @@ const Modal: RefForwardingComponent<IModalRef, IModalProps> = ({
         colors
     } = NCoreUIKitTheme.useContext();
 
-    const scaleAnim = useRef(new Animated.Value(0)).current;
+    const scaleAnim = useRef(new Animated.Value(isAnimated ? 0 : 1)).current;
 
     useEffect(() => {
-        Animated.timing(scaleAnim, {
-            easing: Easing.out(Easing.back(1.5)),
-            useNativeDriver: true,
-            duration: 350,
-            toValue: 1
-        }).start();
+        if(isAnimated) {
+            Animated.timing(scaleAnim, {
+                easing: Easing.out(Easing.back(1.5)),
+                useNativeDriver: true,
+                duration: 350,
+                toValue: 1
+            }).start();
+        }
     }, []);
 
     useImperativeHandle(
@@ -62,18 +68,20 @@ const Modal: RefForwardingComponent<IModalRef, IModalProps> = ({
     );
 
     const closeAnimation = (onClosed?: () => void) => {
-        Animated.timing(scaleAnim, {
-            easing: Easing.in(Easing.ease),
-            useNativeDriver: true,
-            duration: 250,
-            toValue: 0
-        }).start(({
-            finished
-        }) => {
-            if(finished) {
-                if(onClosed) onClosed();
-            }
-        });
+        if(isAnimated) {
+            Animated.timing(scaleAnim, {
+                easing: Easing.in(Easing.ease),
+                useNativeDriver: true,
+                duration: 250,
+                toValue: 0
+            }).start(({
+                finished
+            }) => {
+                if(finished) {
+                    if(onClosed) onClosed();
+                }
+            });
+        }
     };
 
     const renderOverlay = () => {
@@ -81,11 +89,12 @@ const Modal: RefForwardingComponent<IModalRef, IModalProps> = ({
             return null;
         }
 
-        return <View
+        return <TouchableWithoutFeedback
+            {...overlayProps}
             style={[
                 stylesheet.overlay
             ]}
-            onClick={() => {
+            onPress={() => {
                 if(isDisabledOverlay) {
                     return;
                 }
@@ -94,7 +103,16 @@ const Modal: RefForwardingComponent<IModalRef, IModalProps> = ({
                     closeAnimation
                 });
             }}
-        />;
+        >
+            <View
+                style={[
+                    stylesheet.overlayContent,
+                    {
+                        backgroundColor: colors.system.scrim
+                    }
+                ]}
+            />
+        </TouchableWithoutFeedback>;
     };
 
     const renderContent = () => {
@@ -116,10 +134,10 @@ const Modal: RefForwardingComponent<IModalRef, IModalProps> = ({
         <Animated.View
             style={[
                 style,
+                stylesheet.container,
                 {
                     justifyContent: alignContent === "center" ? "center" : undefined,
                     alignItems: alignContent === "center" ? "center" : "baseline",
-                    backgroundColor: colors.system.scrim,
                     transform: [{
                         scale: scaleAnim
                     }]
@@ -127,7 +145,7 @@ const Modal: RefForwardingComponent<IModalRef, IModalProps> = ({
             ]}
         >
             {renderOverlay()}
-            {renderContent()}
+            {isContentRequired ? renderContent() : children}
         </Animated.View>
     </Portal>;
 };

+ 10 - 2
src/components/modal/stylesheet.ts

@@ -4,7 +4,7 @@ import {
 
 const stylesheet = StyleSheet.create({
     container: {
-        position: "static",
+        position: "absolute",
         display: "flex",
         zIndex: 99997,
         bottom: 0,
@@ -13,7 +13,15 @@ const stylesheet = StyleSheet.create({
         top: 0
     },
     overlay: {
-        position: "static",
+        position: "absolute",
+        zIndex: 99998,
+        bottom: 0,
+        right: 0,
+        left: 0,
+        top: 0
+    },
+    overlayContent: {
+        position: "absolute",
         zIndex: 99998,
         bottom: 0,
         right: 0,

+ 4 - 0
src/components/modal/type.ts

@@ -3,6 +3,7 @@ import {
 } from "react";
 import {
     type StyleProp,
+    type ViewProps,
     type ViewStyle
 } from "react-native";
 
@@ -20,7 +21,10 @@ export type ModalInternalProps = {
     style?: StyleProp<ViewStyle>[] | StyleProp<ViewStyle>;
     alignContent?: ModalAlignContentProp;
     isDisabledOverlay?: boolean;
+    isContentRequired?: boolean;
     isOverlayVisible?: boolean;
+    overlayProps?: ViewProps;
+    isAnimated?: boolean;
     children?: ReactNode;
 };
 

+ 6 - 0
src/utils/index.ts

@@ -1,7 +1,13 @@
 import {
+    Dimensions,
     Platform
 } from "react-native";
 
+const dimensions = Dimensions.get("window");
+
+export const windowHeight = dimensions.height;
+export const windowWidth = dimensions.width;
+
 export const uuid = () => {
     return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) {
         const r = Math.random() * 16 | 0, v = c == "x" ? r : (r & 0x3 | 0x8);