React Native

React Native - Bottom Sheet

크런키스틱 2025. 2. 12. 23:03
728x90

어플을 이용하다보면 하단에서 올라오는 UI를 본 경험이 있을 것이다. 이는 일종의 보조 페이지 역할로 Bottom Sheet 라고 한다. 이를 구현하기 위해 사용한 라이브러리는 아래와 같다.

 

https://www.npmjs.com/package/@gorhom/bottom-sheet

 

@gorhom/bottom-sheet

A performant interactive bottom sheet with fully configurable options 🚀. Latest version: 5.1.1, last published: 3 days ago. Start using @gorhom/bottom-sheet in your project by running `npm i @gorhom/bottom-sheet`. There are 197 other projects in the npm

www.npmjs.com

 

 

1. dependency 설치

npm install react-native-reanimated react-native-gesture-handler
#or
yarn add react-native-reanimated react-native-gesture-handler

 

 

2. babel.config.js

react-native-reanimated 설정을 위해 babel.config.js의 plugins의 맨 아래에 추가해준다.

// babel.config.js

  module.exports = {
    presets: [
      ... // don't add it here :)
    ],
    plugins: [
      ...
      // 가장 아래에 위치해야 한다.
      'react-native-reanimated/plugin',
    ],
  };

 

 

3. GestureHandlerRootView

App.tsx의 컴포넌트들을 해당 컴포넌트로 감싸주어야 한다.

RecoilRoot와 같이 더 최상단 컴포넌트가 있다면 그 아래에 위치해주면 된다.

// App.tsx

import { GestureHandlerRootView } from 'react-native-gesture-handler';

export default function App() {
  ...
  return (
    <GestureHandlerRootView>
      ...
    </GestureHandlerRootView>
  );
}

 

 

4. 설치

npm install @gorhom/bottom-sheet@^5
#or
yarn add @gorhom/bottom-sheet@^5

 

 

5. Bottom Sheet Modal 코드

요구사항에 따라 다르겠지만 내가 필요했던 Bottom Sheet UI는 버튼 클릭과 같은 이벤트 발생 시 화면에 나타나야 하기 때문에 Bottom Sheet Modal을 사용했다.

// test.tsx

import CustomText from '@/components/text';
import {TestProps} from '@/types/stack';
import {
  BottomSheetBackdrop,
  BottomSheetModal,
  BottomSheetModalProvider,
  BottomSheetView,
} from '@gorhom/bottom-sheet';
import {useCallback, useMemo, useRef, useState} from 'react';
import {Button, Text, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';

const TestScreen = ({navigation}: TestProps) => {
  // ref
  const bottomSheetModalRef = useRef<BottomSheetModal>(null);

  // index
  const [bottomSheetIndex, setBottomSheetIndex] = useState<number>(-1);

  // snap point
  const snapPoints = useMemo(() => ['40%', '50%'], []);

  // callbacks
  const handlePresentModalPress = useCallback(() => {
    setBottomSheetIndex(1);
    bottomSheetModalRef.current?.present();
  }, []);

  const handleCloseModalPress = useCallback(() => {
    bottomSheetModalRef.current?.close();
  }, []);

  // Bottom Sheet close event when background touch
  const renderBackdrop = useCallback(
    (props: any) => <BottomSheetBackdrop {...props} pressBehavior="close" />,
    [],
  );

  return (
    <BottomSheetModalProvider>
      <SafeAreaView className="flex-1">
        <View className="w-full h-full flex justify-center items-center bg-white">
          <CustomText className="text-success text-xl" children="테스트 화면" />
          <Button title="열기" onPress={() => handlePresentModalPress()} />
        </View>

        <Button
          onPress={handlePresentModalPress}
          title="Present Modal"
          color="black"
        />
        <BottomSheetModal
          ref={bottomSheetModalRef}
          index={bottomSheetIndex}
          snapPoints={snapPoints}
          backdropComponent={renderBackdrop}>
          <BottomSheetView className="flex-1 items-center justify-center">
            <Text>바텀시트 오픈!</Text>
            <Button title="닫기" onPress={() => handleCloseModalPress()} />
          </BottomSheetView>
        </BottomSheetModal>
      </SafeAreaView>
    </BottomSheetModalProvider>
  );
};

export default TestScreen;

 

위는 간단하게 구현한 예시 코드로 본인은 몇가지 형태가 비슷한 바텀시트의 경우, forwardRef를 활용하여 컴포넌트로 분리하여 사용했다. 이해한 내용을 간단하게 살펴보자면 아래와 같다.

 

  • ref : BottomSheet에 연결해준다.
  • snapPoints
    • BottomSheet가 스냅할 지점을 정한다.
    • 지금은 하드코딩되어 있지만 컴포넌트화를 한다면 해당 useMemo에 snap 값을 받아 선언하면 된다.
  • index
    • snapPoints의 배열 인자값으로 기본값은 0으로 제공된다고 설명되어 있다.
    • -1로 지정해야 기본 Bottom Sheet가 닫혀있는 상태로 시작된다.
  • handlePresentModalPress : Bottom Sheet 모달 열기. 기본 index를 -1로 지정했기 때문에 여기서 index 변경도 작업한다.
  • handleCloseModalPress : Bottom Sheet 모달 닫기
  • renderBackdrop
    • Bottom Sheet 모달이 열렸을 때, 뒤의 백그라운드에 대한 설정 (색상, 백그라운드 터치 시 이벤트 등)
    • 본인은 여기서 백그라운드 터치가 되면 창이 닫히기를 원해서 pressBehavior에 close 값을 주었다.
    • 이를 backdropComponent에 넣어준다.

 

이 외에도 정말 많은 props, methods, hook 등을 통해 다양한 기능을 설정할 수 있다. 이는 본인이 원하는 기능에 맞게 아래 공식 사이트를 참고하면서 개발하면 된다.

https://gorhom.dev/react-native-bottom-sheet/

 

Bottom Sheet | React Native Bottom Sheet

A performant interactive bottom sheet with fully configurable options 🚀

gorhom.dev

 

 

6. 하드웨어 뒤로가기 버튼 클릭 시 닫히기 설정

본인이 어플 개발을 한창 하면서 테스트하던 도중에 android에서 Bottom Sheet 창이 열렸을 때 기기의 뒤로가기 버튼을 클릭하면 당연히 창이 닫힐 거라고 예상을 했다. 하지만 의도와는 다르게 이전 페이지로 이동하는 상황이 발생했다. 이를 해결하기 위해 onChange, onDismiss 등 다양한 시도를 해봤지만 원하는 기능이 깔끔하게 작동하지 않고 페이지에서의 뒤로가기 버튼에도 영향을 준다던지 하는 side effect가 발생했다. 한창 구글링을 하면서 찾던 중 custom hook을 만들어 적용하면 된다는 예시 코드를 찾게 되었고 적용하니 잘 작동하여 코드를 첨부한다.

// useBottomSheetBakcHandler.ts

import {BottomSheetModal, BottomSheetModalProps} from '@gorhom/bottom-sheet';
import {useCallback, useRef} from 'react';
import {BackHandler, NativeEventSubscription} from 'react-native';

/**
 * hook that dismisses the bottom sheet on the hardware back button press if it is visible
 * @param bottomSheetRef ref to the bottom sheet which is going to be closed/dismissed on the back press
 */
const useBottomSheetBackHandler = (
  bottomSheetRef:
    | React.RefObject<BottomSheetModal | null>
    | React.ForwardedRef<BottomSheetModal>,
) => {
  const backHandlerSubscriptionRef = useRef<NativeEventSubscription | null>(
    null,
  );
  const handleSheetPositionChange = useCallback<
    NonNullable<BottomSheetModalProps['onChange']>
  >(
    index => {
      const isBottomSheetVisible = index >= 0;
      if (isBottomSheetVisible && !backHandlerSubscriptionRef.current) {
        // setup the back handler if the bottom sheet is right in front of the user
        backHandlerSubscriptionRef.current = BackHandler.addEventListener(
          'hardwareBackPress',
          () => {
            if (bottomSheetRef && typeof bottomSheetRef !== 'function')
              bottomSheetRef.current?.dismiss();

            return true;
          },
        );
      } else if (!isBottomSheetVisible) {
        backHandlerSubscriptionRef.current?.remove();
        backHandlerSubscriptionRef.current = null;
      }
    },
    [bottomSheetRef, backHandlerSubscriptionRef],
  );
  return {handleSheetPositionChange};
};

export default useBottomSheetBackHandler;

 

해당 Bottom Sheet의 index가 0 이상일 경우(즉, 열려있을 경우) 뒤로가기 버튼이 눌리면 ref를 통해 연결된 Bottom Sheet를 닫아주는 원리이다.

 

이 custom hook을 선언하고 onChange에 넣어주면 된다.

// test.tsx

import CustomText from '@/components/text';
import useBottomSheetBackHandler from '@/hook/useBottomSheetBackHandler';
import {TestProps} from '@/types/stack';
import {
  BottomSheetBackdrop,
  BottomSheetModal,
  BottomSheetModalProvider,
  BottomSheetView,
} from '@gorhom/bottom-sheet';
import {useCallback, useMemo, useRef, useState} from 'react';
import {Button, Text, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';

const TestScreen = ({navigation}: TestProps) => {
  // ref
  const bottomSheetModalRef = useRef<BottomSheetModal>(null);

  // index
  const [bottomSheetIndex, setBottomSheetIndex] = useState<number>(-1);

  // snap point
  const snapPoints = useMemo(() => ['40%', '50%'], []);

  // callbacks
  const handlePresentModalPress = useCallback(() => {
    setBottomSheetIndex(1);
    bottomSheetModalRef.current?.present();
  }, []);

  const handleCloseModalPress = useCallback(() => {
    bottomSheetModalRef.current?.close();
  }, []);

  // Bottom Sheet close event when background touch
  const renderBackdrop = useCallback(
    (props: any) => <BottomSheetBackdrop {...props} pressBehavior="close" />,
    [],
  );

  const {handleSheetPositionChange} =
    useBottomSheetBackHandler(bottomSheetModalRef);

  return (
    <BottomSheetModalProvider>
      <SafeAreaView className="flex-1">
        <View className="w-full h-full flex justify-center items-center bg-white">
          <CustomText className="text-success text-xl" children="테스트 화면" />
          <Button title="열기" onPress={() => handlePresentModalPress()} />
        </View>

        <Button
          onPress={handlePresentModalPress}
          title="Present Modal"
          color="black"
        />
        <BottomSheetModal
          ref={bottomSheetModalRef}
          index={bottomSheetIndex}
          snapPoints={snapPoints}
          onChange={handleSheetPositionChange}
          backdropComponent={renderBackdrop}>
          <BottomSheetView className="flex-1 items-center justify-center">
            <Text>바텀시트 오픈!</Text>
            <Button title="닫기" onPress={() => handleCloseModalPress()} />
          </BottomSheetView>
        </BottomSheetModal>
      </SafeAreaView>
    </BottomSheetModalProvider>
  );
};

export default TestScreen;

 

 

7. 실행

728x90