React Native - Bottom Sheet
어플을 이용하다보면 하단에서 올라오는 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;