gorhom / react-native-bottom-sheet

A performant interactive bottom sheet with fully configurable options 🚀
https://ui.gorhom.dev/components/bottom-sheet
MIT License
6.4k stars 722 forks source link

[V4] Dynamic Snap points not working with React Navigation #601

Open thebiltheory opened 2 years ago

thebiltheory commented 2 years ago

Bug

Environment info

Library Version
@gorhom/bottom-sheet 4.0.0-alpha.30
react-native expo sdk-42.0.0
react-native-reanimated 2.2.0
react-native-gesture-handler 1.10.2
react-native-gesture-handler 1.10.2
@react-navigation/drawer 6.1.0
@react-navigation/native 6.0.2
@react-navigation/native-stack 6.1.0
@react-navigation/stack 6.0.3

Steps To Reproduce

Example Snack: https://snack.expo.dev/@thebiltheory/bottom-sheet-v4-with-react-navigation

  1. Implement dynamic snap points example
  2. Implement a navigator

Describe what you expected to happen:

  1. For the bottom sheet to dynamically take an appropriate height based on the content in each screen.
  2. For the bottom sheet to have a minimum height while a "loader" is being shown

Reproducible sample code

Example Snack: https://snack.expo.dev/@thebiltheory/bottom-sheet-v4-with-react-navigation

  const {
    animatedHandleHeight,
    animatedSnapPoints,
    animatedContentHeight,
    handleContentLayout,
  } = useBottomSheetDynamicSnapPoints(["CONTENT_HEIGHT"]);

<NativeViewGestureHandler disallowInterruption={true}>
      <View flex={1}>
        <BottomSheetModal
          name="Select"
          ref={newBookingModal.ref}
          onDismiss={resetCarForm}
          snapPoints={animatedSnapPoints}
          handleHeight={animatedHandleHeight}
          contentHeight={animatedContentHeight}
          enablePanDownToClose={true}
          keyboardBehavior="interactive"
          android_keyboardInputMode="adjustPan"
        >
          <BottomSheetScrollView
            onLayout={handleContentLayout}
            style={{
              flex: 1,
              backgroundColor: "red",
            }}
          >
            <NewBookingNavigator />
          </BottomSheetScrollView>
        </BottomSheetModal>
      </View>
    </NativeViewGestureHandler>

NewBookingNavigator.ts

 <NavigationContainer independent={true}>
      <Stack.Navigator
        screenOptions={screenOptions}
        initialRouteName="ScreenOne"
      >
        <Stack.Screen
          name="ScreenOne"
          options={{ title: "Screen One", headerShown: false }}
          component={ScreenOne}
        />
        <Stack.Screen
          name="ScreenTwo"
          options={{ title: "Screen Two", headerShown: false }}
          component={ScreenTwo}
        />
        ...
      </Stack.Navigator>
    </NavigationContainer>

Originally posted by @thebiltheory in https://github.com/gorhom/react-native-bottom-sheet/discussions/427#discussioncomment-1158471

github-actions[bot] commented 2 years ago

@thebiltheory: hello! :wave:

This issue is being automatically closed because it does not follow the issue template.

thebiltheory commented 2 years ago

Hello,

cc @gorhom

For some reason, the screen heights are unable to be calculated by onLayout. I managed to make this "work" with a dirty hack but not really dynamically. It does the job for now.

Inside the file where lies your <BottomSheet>

// Will serve to hold each screens height.
const [onLayoutHeight, setOnLayoutHeight] = useState(null);

// Bottom Sheet dynamic content code
const initialSnapPoints = useMemo(() => ["CONTENT_HEIGHT"], []);
  const {
    animatedHandleHeight,
    animatedSnapPoints,
    animatedContentHeight,
    handleContentLayout,
  } = useBottomSheetDynamicSnapPoints(initialSnapPoints);

// Custom handle
  const dynamicContentLayoutHandle = (event) => {

     // Sets the "new height" every time this function is rendered
    setOnLayoutHeight(event.nativeEvent.layout.height);

     // Bottom Sheet `onLayout` Handle, which needs 
     // { nativeEvent: { layout: { height: <YOUR SCREEN HEIGHT> } } }
    handleContentLayout(event);
  };

  return (
    <BottomSheetModal
      name="Select Title"
      ref={newBookingModal.ref}
      onDismiss={resetCarForm}
      keyboardBehavior="interactive"
      snapPoints={animatedSnapPoints}
      handleHeight={animatedHandleHeight}
      contentHeight={animatedContentHeight}
      enablePanDownToClose={true}
      keyboardBlurBehavior="restore"
      android_keyboardInputMode="adjustPan"
    >
      <NavigationContainer independent={true}>
        <BottomSheetView
          style={{
            flex: 1,
            // Set the new height every time it changes
            height: onLayoutHeight,
            backgroundColor: "green",
          }}
          onLayout={dynamicContentLayoutHandle}
        >
          // Pass your custom `onLayout` handle to your @react/navigation navigator
          <NewBookingNavigator onLayout={dynamicContentLayoutHandle} />
        </BottomSheetView>
      </NavigationContainer>
    </BottomSheetModal>
  );
});

Inside your navigator, on your Stack.Screen

<Stack.Screen
        name="ScreenName"
        component={SelectVehiculeScreen}
        // 🤢 pass your layout function as initial param
        // I know, it doesn't feel right
        initialParams={{ onLayout }}
      />

Inside your screens


// Use react navigation's `useOnFocus` to trigger
// the `onLayout` function you have passed to the navigator

useFocusEffect(() => {
    route.params?.onLayout({ nativeEvent: { layout: { height: 750 } } });
  });
gorhom commented 2 years ago

try this

import React, { useCallback, useMemo, useRef } from "react";
import { StyleSheet, Text, View, Button } from "react-native";
import BottomSheet, {
  BottomSheetDraggableView,
  useBottomSheetDynamicSnapPoints,
} from "@gorhom/bottom-sheet";
import {
  NavigationContainer,
  useFocusEffect,
  useNavigation,
} from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
import { useAnimatedStyle } from "react-native-reanimated";

const Stack = createStackNavigator();

const ScreenA = ({ animatedContentHeight }) => {
  const { navigate } = useNavigation();
  const isFocus = useRef(false);
  const contentHeight = useRef(0);

  const handleContentLayout = useCallback(
    ({
      nativeEvent: {
        layout: { height },
      },
    }) => {
      if (isFocus.current) {
        contentHeight.current = height;
        animatedContentHeight.value = height;
      }
    },
    [animatedContentHeight]
  );

  useFocusEffect(() => {
    isFocus.current = true;
    animatedContentHeight.value = contentHeight.current;
    return () => {
      isFocus.current = false;
    };
  });
  return (
    <View onLayout={handleContentLayout}>
      <View style={[{ height: 400 }, styles.dummyScreen]}>
        <Text>Screen A</Text>
        <Button
          title="navigate to Screen B"
          onPress={() => navigate("ScreenB")}
        />
      </View>
    </View>
  );
};

const ScreenB = ({ animatedContentHeight }) => {
  const isFocus = useRef(false);
  const contentHeight = useRef(0);

  const handleContentLayout = useCallback(
    ({
      nativeEvent: {
        layout: { height },
      },
    }) => {
      if (isFocus.current) {
        contentHeight.current = height;
        animatedContentHeight.value = height;
      }
    },
    [animatedContentHeight]
  );

  useFocusEffect(() => {
    isFocus.current = true;
    animatedContentHeight.value = contentHeight.current;
    return () => {
      isFocus.current = false;
    };
  });
  return (
    <View onLayout={handleContentLayout}>
      <View style={[{ height: 200 }, styles.dummyScreen]}>
        <Text>Screen B</Text>
      </View>
    </View>
  );
};

const App = () => {
  const initialSnapPoints = useMemo(() => [100, "CONTENT_HEIGHT"], []);
  const {
    animatedContentHeight,
    animatedHandleHeight,
    animatedSnapPoints,
    handleContentLayout,
  } = useBottomSheetDynamicSnapPoints(initialSnapPoints);

  const sheetContentAnimatedStyle = useAnimatedStyle(
    () => ({
      minHeight:
        animatedContentHeight.value === 0 ? 200 : animatedContentHeight.value,
    }),
    [animatedContentHeight]
  );
  return (
    <View style={styles.container}>
      <BottomSheet
        handleHeight={animatedHandleHeight}
        contentHeight={animatedContentHeight}
        snapPoints={animatedSnapPoints}
      >
        <BottomSheetDraggableView style={sheetContentAnimatedStyle}>
          <NavigationContainer>
            <Stack.Navigator
              screenOptions={{
                headerShown: false,
                cardStyle: { backgroundColor: "white" },
              }}
            >
              <Stack.Screen name="ScreenA">
                {() => (
                  <ScreenA animatedContentHeight={animatedContentHeight} />
                )}
              </Stack.Screen>
              <Stack.Screen name="ScreenB">
                {() => (
                  <ScreenB animatedContentHeight={animatedContentHeight} />
                )}
              </Stack.Screen>
            </Stack.Navigator>
          </NavigationContainer>
        </BottomSheetDraggableView>
      </BottomSheet>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#333",
  },
  sheetContent: {
    minHeight: 1,
  },
  dummyScreen: {
    alignItems: "center",
    justifyContent: "center",
  },
});

export default App;
gorhom commented 2 years ago

@thebiltheory does the example above fixed your issue ?

thebiltheory commented 2 years ago

@thebiltheory does the example above fixed your issue ?

Nope. But I've improved my version of my implementation until better approach.

I will give a second shot to yours in a couple of days.

Svarto commented 2 years ago

For the ones coming after me, a very straight forward example for this exists in the docs: https://gorhom.github.io/react-native-bottom-sheet/hooks#usebottomsheetdynamicsnappoints

christianrank commented 2 years ago

The solution of @thebiltheory just sets a fixed height for the child screen of 750 in useFocusEffect. I had to figure out how to also have a variable height for that, and ended up with an additional dirty hack.

Somehow it only works when I call onLayout inside of a ScrollView which is actually not scrolling. Also, to support the SafeArea you can add a paddingBottom of insets.bottom (and using 20 here if the device doesn't have the SafeArea).

Maybe this is useful for someone here.

interface Props {
  route: RouteProp<BottomScreenParams, 'ExampleChildScreen'>
}

export default function ExampleChildScreen(props: Props) {
  const onLayout = props.route.params.onLayout
  const insets = useSafeAreaInsets()

  return (
    <ScrollView scrollEnabled={false}>
      <View onLayout={onLayout} style={{ paddingBottom: Math.max(insets.bottom, 20) }}>
        <Text>Test</Text>
      </View>
    </ScrollView>
  )
}

EDIT: for multiple screens, useFocusEffect is needed. I updated my code like this:

interface Props {
  route: RouteProp<BottomScreenParams, 'ExampleChildScreen'>
}

export default function ExampleChildScreen(props: Props) {
  const onLayout = props.route.params.onLayout
  const insets = useSafeAreaInsets()
  const [layout, setLayout] = useState<LayoutChangeEvent>()

  useFocusEffect(useCallback(() => {
    if (layout && onLayout) {
      onLayout(layout)
    }
  }, [layout]))

  return (
    <ScrollView scrollEnabled={false}>
      <View
        onLayout={(event) => {
          event.persist()
          setLayout(event)
        }}
        style={{ paddingBottom: Math.max(insets.bottom, 20) }}
      >
        <Text>Test</Text>
      </View>
    </ScrollView>
  )
}
adamward459 commented 2 years ago

try this

import React, { useCallback, useMemo, useRef } from "react";
import { StyleSheet, Text, View, Button } from "react-native";
import BottomSheet, {
  BottomSheetDraggableView,
  useBottomSheetDynamicSnapPoints,
} from "@gorhom/bottom-sheet";
import {
  NavigationContainer,
  useFocusEffect,
  useNavigation,
} from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
import { useAnimatedStyle } from "react-native-reanimated";

const Stack = createStackNavigator();

const ScreenA = ({ animatedContentHeight }) => {
  const { navigate } = useNavigation();
  const isFocus = useRef(false);
  const contentHeight = useRef(0);

  const handleContentLayout = useCallback(
    ({
      nativeEvent: {
        layout: { height },
      },
    }) => {
      if (isFocus.current) {
        contentHeight.current = height;
        animatedContentHeight.value = height;
      }
    },
    [animatedContentHeight]
  );

  useFocusEffect(() => {
    isFocus.current = true;
    animatedContentHeight.value = contentHeight.current;
    return () => {
      isFocus.current = false;
    };
  });
  return (
    <View onLayout={handleContentLayout}>
      <View style={[{ height: 400 }, styles.dummyScreen]}>
        <Text>Screen A</Text>
        <Button
          title="navigate to Screen B"
          onPress={() => navigate("ScreenB")}
        />
      </View>
    </View>
  );
};

const ScreenB = ({ animatedContentHeight }) => {
  const isFocus = useRef(false);
  const contentHeight = useRef(0);

  const handleContentLayout = useCallback(
    ({
      nativeEvent: {
        layout: { height },
      },
    }) => {
      if (isFocus.current) {
        contentHeight.current = height;
        animatedContentHeight.value = height;
      }
    },
    [animatedContentHeight]
  );

  useFocusEffect(() => {
    isFocus.current = true;
    animatedContentHeight.value = contentHeight.current;
    return () => {
      isFocus.current = false;
    };
  });
  return (
    <View onLayout={handleContentLayout}>
      <View style={[{ height: 200 }, styles.dummyScreen]}>
        <Text>Screen B</Text>
      </View>
    </View>
  );
};

const App = () => {
  const initialSnapPoints = useMemo(() => [100, "CONTENT_HEIGHT"], []);
  const {
    animatedContentHeight,
    animatedHandleHeight,
    animatedSnapPoints,
    handleContentLayout,
  } = useBottomSheetDynamicSnapPoints(initialSnapPoints);

  const sheetContentAnimatedStyle = useAnimatedStyle(
    () => ({
      minHeight:
        animatedContentHeight.value === 0 ? 200 : animatedContentHeight.value,
    }),
    [animatedContentHeight]
  );
  return (
    <View style={styles.container}>
      <BottomSheet
        handleHeight={animatedHandleHeight}
        contentHeight={animatedContentHeight}
        snapPoints={animatedSnapPoints}
      >
        <BottomSheetDraggableView style={sheetContentAnimatedStyle}>
          <NavigationContainer>
            <Stack.Navigator
              screenOptions={{
                headerShown: false,
                cardStyle: { backgroundColor: "white" },
              }}
            >
              <Stack.Screen name="ScreenA">
                {() => (
                  <ScreenA animatedContentHeight={animatedContentHeight} />
                )}
              </Stack.Screen>
              <Stack.Screen name="ScreenB">
                {() => (
                  <ScreenB animatedContentHeight={animatedContentHeight} />
                )}
              </Stack.Screen>
            </Stack.Navigator>
          </NavigationContainer>
        </BottomSheetDraggableView>
      </BottomSheet>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#333",
  },
  sheetContent: {
    minHeight: 1,
  },
  dummyScreen: {
    alignItems: "center",
    justifyContent: "center",
  },
});

export default App;

We need to add navigation header height otherwise it will only calculate content

 import { useHeaderHeight } from '@react-navigation/elements';

  const headerHeight = useHeaderHeight();

  const handleContentLayout = useCallback(
    ({
      nativeEvent: {
        layout: { height },
      },
    }) => {
      if (isFocus.current) {
        contentHeight.current = height + headerHeight; <--
        animatedContentHeight.value = height + headerHeight ; <--
      }
    },
    [animatedContentHeight]
  );