0%
October 3, 2023

Custom BottomSheet

react-native

Result

Code Implementation

As usual code implementation is a mess of ad-hoc detail, but the usage is very simple!

import { ReactNode, forwardRef, useImperativeHandle } from "react";
import Animated, { useAnimatedGestureHandler, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring, withTiming } from "react-native-reanimated";
import { useHeaderHeight, } from '@react-navigation/elements';
import Constants from 'expo-constants';
import { PanGestureHandler, PanGestureHandlerGestureEvent } from "react-native-gesture-handler";
import { Dimensions, StyleSheet, TouchableOpacity, View } from "react-native";

const { height } = Dimensions.get("window");

export type BottomSheetHandle = {
  show: () => void;
  close: () => void;
}

export type BottomSheetProps = {
  component: ReactNode,
  maxHeight?: number
}

const BottomSheet = forwardRef<BottomSheetHandle, BottomSheetProps>((props, ref) => {
  const bottomSheetShiftY = useSharedValue(0);
  const headerHeight = useHeaderHeight();
  const statusbarHeight = Constants.statusBarHeight;
  const topHeaderHeight = headerHeight + statusbarHeight
  const screenHeight = height - topHeaderHeight;
  const { maxHeight = screenHeight * 9 / 10 } = props;

  const show = () => {
    bottomSheetShiftY.value = withSpring(-maxHeight / 2)
  }
  const close = () => {
    bottomSheetShiftY.value = withTiming(0);
  }
  useImperativeHandle(ref, () => ({
    show,
    close,
  }))

  const bottomSheetClampedY = useDerivedValue(() => {
    return Math.max(-maxHeight, Math.min(screenHeight, bottomSheetShiftY.value));
  })


  const sheetRstyle = useAnimatedStyle(() => {
    return {
      transform: [{ translateY: bottomSheetClampedY.value }]
    }
  })

  const backdropRstyle = useAnimatedStyle(() => {
    return {
      opacity: - bottomSheetClampedY.value / screenHeight
    }
  })

  const panHandler = useAnimatedGestureHandler<PanGestureHandlerGestureEvent, { translationY: number }>({
    onStart: (event, context) => {
      context.translationY = bottomSheetShiftY.value;
    },
    onActive: (event, context) => {

      bottomSheetShiftY.value = context.translationY + event.translationY;
    },
    onEnd: (event) => {
      console.log(event.absoluteY, maxHeight);

      if (event.absoluteY > maxHeight * 1.5) {
        bottomSheetShiftY.value = withTiming(0);
      } else {
        bottomSheetShiftY.value = withTiming(-maxHeight);
      }
    }
  });

  const bottomsheetRstyle = useAnimatedStyle(() => {
    if (bottomSheetShiftY.value < 0) {
      return { height: screenHeight };
    } else {
      return { height: 0 };
    }
  });

  return (
    <Animated.View style={[StyleSheet.absoluteFillObject, { zIndex: 2 }, bottomsheetRstyle]}>
      <PanGestureHandler onGestureEvent={panHandler}>
        <Animated.View style={StyleSheet.absoluteFillObject}>
          <Animated.View style={[
            StyleSheet.absoluteFillObject, { backgroundColor: "rgba(0,0,0,0.6)" },
            backdropRstyle,
          ]} />
          <TouchableOpacity
            onPress={() => { bottomSheetShiftY.value = withTiming(0) }}
            style={[StyleSheet.absoluteFillObject, { width: "100%", height: "100%" }]}
          />
          <Animated.View style={[
            {
              position: "absolute",
              width: "100%",
              height: "100%",
              backgroundColor: "white",
              top: screenHeight,
              borderRadius: 10,
              overflow: "hidden"
            },
            sheetRstyle
          ]}>
            <Animated.View style={[
              { width: "100%", flexDirection: "row", justifyContent: "center", paddingTop: 15 },
            ]}>
              <View style={[
                { width: 50, height: 4, backgroundColor: "rgba(0,0,0,0.3)", borderRadius: 2 },
              ]} />
            </Animated.View>

            <View style={{ marginTop: 20 }}>
              {props.component}
            </View>
          </Animated.View>

        </Animated.View>
      </PanGestureHandler>
    </Animated.View>
  )
})

export default BottomSheet;

Usage

const playground = () => {
  const ref = useRef < BottomSheetHandle > null;
  const showBottomSheet = () => {
    ref.current?.show();
  };
  return (
    <View
      style={{
        width: "100%",
        height: "100%",
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      <TouchableOpacity onLongPress={showBottomSheet} delayLongPress={1000}>
        <Text style={{ padding: 20, borderWidth: 1 }}>Test</Text>
      </TouchableOpacity>
      <BottomSheet
        component={
          <>
            <Text>This is my nice Test</Text>
          </>
        }
        ref={ref}
        maxHeight={400}
      />
    </View>
  );
};