React Native용 Sheet: 모듈식 Sheet 프레임워크
Source: Dev.to
위에 제공된 Source 라인 외에 번역할 텍스트가 포함되어 있지 않습니다. 번역이 필요한 전체 내용(마크다운 형식 포함)을 알려주시면, 요청하신 대로 한국어로 번역해 드리겠습니다.
Source:
문제
모바일에서 사용자에게 콘텐츠를 표시하는 방법에는 팝업, 드롭다운, 바텀 시트, 센터 시트 등 다양한 방식이 있습니다. 대부분의 앱에서 이러한 요소들은 모달 형태로 나타나며, 인터페이스의 나머지 위에 겹쳐지는 집중된 레이어가 됩니다.
React Native에서는 기본 제공 Modal 컴포넌트가 이 역할을 수행합니다. 하지만 API가 제한적이고 네이티브 프레젠테이션 동작에 강하게 결합돼 있어, 맞춤 동작, 복잡한 제스처 시스템, 여러 모달 간의 정교한 상호작용이 필요할 때 프레임워크와 싸우게 되는 경우가 많습니다.
이러한 격차를 메우기 위해 여러 라이브러리가 등장했습니다:
gorhom/react-native-bottom-sheetammarahm-ed/react-native-actions-sheetlodev09/react-native-true-sheet
모든 라이브러리를 사용해 본 것은 아니지만, 저는 gorhom/react-native-bottom-sheet 를 회사에서 많이 사용했습니다. 그 과정에서 문서가 부족해 해결책이나 대안을 찾기 어려운 버그들을 자주 마주했습니다. 가장 두드러진 문제는 다음과 같습니다:
| Issue | Description |
|---|---|
| Z‑index 충돌 | 여러 바텀 시트를 동시에 열면 쌓이는 순서가 올바르게 적용되지 않는 경우가 많았습니다. |
| 동적 크기 조정 | 자동 크기 조정이 신뢰성 있게 동작하지 않았으며, 특히 스크롤 뷰와 함께 사용할 때 문제가 발생했습니다. |
| 떨리는 제스처 | 시트를 드래그할 때 가끔 “점프”하는 현상이 있었습니다. |
| 키보드 처리 | 다양한 키보드 상황에서 포커스와 레이아웃 관리가 일관되지 않았습니다. |
처음에는 라이브러리 컴포넌트를 감싸는 래퍼를 만들어 이러한 문제들을 완화하려 했지만, 근본 원인을 해결하지는 못한 임시방편에 불과했습니다.
최근 오픈소스에 더 깊이 관여하게 되고 라이브러리 유지보수와 릴리즈에 대해 배우면서, 이 문제를 새롭게 접근하기로 결심했습니다. 저는 유연한 API와 명시적인 엣지 케이스 처리를 목표로, 처음부터 새로운 라이브러리를 설계하고 있습니다.
자세한 내용은 여기에서 확인해 보세요.
이번 포스트에서는 라이브러리 설계 뒤에 있는 사고 모델과 해결하고자 하는 구체적인 문제들을 공유하고자 합니다.
시트 시스템
시트는 세 개의 별도 레이어로 구성된 시스템입니다:
-
Stack Item Layer – 현재 시트를 스택에 등록합니다. 스택은 등록 순서에 따라 고유한 z‑index를 생성해, 여러 시트를 동시에 열었을 때 올바른 순서대로 표시되도록 합니다.
-
Presenter Layer – 일반적인 프레젠테이션 로직을 관리합니다. 항상 0 % → 100 % 로 열고, 100 % → 0 % 로 닫습니다. 이 레이어는 아래에서 위로, 왼쪽에서 오른쪽으로, 혹은 대각선으로 이동할 수 있습니다.
이 레이어가 바로 “시크릿 소스”이며, 기본적으로 동적 크기 조정을 지원합니다. Presenter는 전환에만 집중하므로 콘텐츠 크기를 미리 알 필요가 없고, 콘텐츠는 필요에 따라 자유롭게 크기를 조정할 수 있습니다.
-
Content Layer – 실제로 표시하고자 하는 콘텐츠입니다. 텍스트가 들어간 간단한 뷰일 수도 있고, 제스처와 애니메이션이 포함된 복잡한 컴포넌트(예: 바텀 시트)일 수도 있습니다. 화면 중앙에 배치된 뷰도 문제없이 동작합니다.
시트는 다양한 사용 사례에 활용될 수 있지만, 여기서는 바텀 시트에 초점을 맞추겠습니다. 바텀 시트는 모든 엣지 케이스를 올바르게 처리하면서 구현하기 어려운 일반적인 요구사항입니다.
Example Setup
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import {
SheetStackProvider,
SheetKeyboardProvider,
BottomSheetRegistryProvider,
} from "react-native-the-sheet";
import { PortalHost, PortalProvider } from "react-native-universe-portal";
export default function App() {
return (
<GestureHandlerRootView>
<SafeAreaProvider>
<SheetKeyboardProvider>
<SheetStackProvider>
<PortalProvider>
{/* Your app content */}
</PortalProvider>
</SheetStackProvider>
</SheetKeyboardProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
| Provider | Purpose |
|---|---|
| SafeAreaProvider | 시트에 사용할 수 있는 실제 안전 영역 높이를 계산하는 데 필요합니다. |
| SheetKeyboardProvider | Android에서 키보드 상호작용을 올바르게 처리합니다(특히 엣지‑투‑엣지 레이아웃이 아닌 경우, adjustResize 및 adjustPan에 대해). |
| SheetStackProvider | 시트 스택과 그 상대적인 z‑인덱스를 관리합니다. |
| PortalProvider | 콘텐츠를 뷰 계층 구조의 올바른 위치로 텔레포트합니다. |
| BottomSheetRegistryProvider | 시트 ID만으로 내부 바텀‑시트 상태를 읽을 수 있게 해줍니다. |
2. 컴포넌트를 사용하여 바텀 시트 구성하기
이 예제에서는 다음과 같은 간단한 바텀 시트를 생성합니다:
- 동적 크기 조정
- 백드롭
- 핸들
- 팬 제스처를 인식하는 정적 콘텐츠 (
BottomSheetView사용)
핸들이나 콘텐츠를 드래그하면 시트가 손가락을 따라 움직입니다.
import { Fragment, useState } from "react";
import { Button, StyleSheet, Text, View } from "react-native";
import {
Backdrop,
BottomSheet,
BottomSheetHandle,
BottomSheetPresenter,
BottomSheetProvider,
BottomSheetView,
SheetStackItem,
} from "react-native-the-sheet";
import { Portal } from "react-native-universe-portal";
export default function ExampleBottomSheetView() {
const [isOpenA, setIsOpenA] = useState(false);
const renderContent = () => (
<Fragment>
{Array.from({ length: 20 }).map((_, index) => (
<Text key={index}>Item {index + 1}</Text>
))}
</Fragment>
);
return (
<View style={styles.root}>
<Text style={styles.header}>Example Bottom Sheet View</Text>
<Button title="Open Sheet" onPress={() => setIsOpenA(true)} />
<SheetStackItem
isOpen={isOpenA}
onClose={() => setIsOpenA(false)}
waitForFullyExit
testID="sheetA"
>
<BottomSheetProvider>
<Backdrop />
<BottomSheetPresenter>
<BottomSheet>
<BottomSheetHandle />
<BottomSheetView>{renderContent()}</BottomSheetView>
</BottomSheet>
</BottomSheetPresenter>
</BottomSheetProvider>
</SheetStackItem>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, justifyContent: "center", alignItems: "center" },
header: { fontSize: 18, marginBottom: 12 },
});
컴포넌트 설명
| 컴포넌트 | 역할 |
|---|---|
| SheetStackItem | 스택에 시트를 등록하고, 열림/닫힘 상태를 제어하며, z‑index 순서를 관리합니다. |
| BottomSheetProvider | 바텀 시트 내부(예: 제스처, 애니메이션 값)를 위한 컨텍스트를 제공합니다. |
| Backdrop | 시트 뒤에 나타나는 어두운 배경을 표시합니다. |
| BottomSheetPresenter | 전환 애니메이션(열림/닫힘)과 위치 지정 로직을 처리합니다. |
| BottomSheet | 시트의 시각적 스타일(배경, 테두리 반경 등)을 정의하는 컨테이너입니다. |
| BottomSheetHandle | 시트를 끌 수 있음을 나타내는 작은 드래그 바입니다. |
| BottomSheetView | 자식 요소가 팬 제스처에 반응하도록 감싸며, 동적 크기 조정을 가능하게 합니다. |
Source:
TL;DR
- 3계층 아키텍처 (Stack → Presenter → Content) 를 사용하면 신뢰할 수 있는 z‑index 처리, 유연한 애니메이션 방향, 그리고 진정한 동적 크기 조절을 제공받을 수 있습니다.
- SheetStackProvider + SheetStackItem 은 여러 시트를 순서대로 배치하는 일을 자동으로 처리합니다.
- Presenter 가 전환 로직을 추상화하므로, 콘텐츠는 자신의 크기를 미리 알 필요가 없습니다.
- Content 계층은 단순 텍스트 리스트부터 완전한 제스처 기반 뷰까지, 어떤 React 컴포넌트든 사용할 수 있습니다.
라이브러리를 직접 사용해보고 느낌을 알려 주세요!
<SheetStackItem
isOpen={isOpenA}
onClose={() => setIsOpenA(false)}
testID="sheetA"
>
<BottomSheetProvider>
<Backdrop />
<BottomSheetPresenter>
<BottomSheet>
<BottomSheetHandle />
<BottomSheetView>{renderContent()}</BottomSheetView>
</BottomSheet>
</BottomSheetPresenter>
</BottomSheetProvider>
</SheetStackItem>
// Styles
const styles = StyleSheet.create({
header: {
fontSize: 20,
fontWeight: "500",
},
root: {
flex: 1,
gap: 8,
padding: 16,
},
});
눈치채셨겠지만, 이 컴포넌트들에 전달해야 할 props는 거의 없습니다. 대부분의 기능이 컴포넌트 내부에 캡슐화되어 있어, 원하는 대로 자유롭게 조합하고 레이아웃할 수 있습니다.
물론 예외도 존재하지만, 창의적으로 접근해 보시길 권합니다. 아직 제가 생각하지 못한 유용한 패턴을 발견하실 수도 있습니다.
또한, 이 예시는 라이브러리 기능의 극히 일부에 불과합니다. 다양한 기능을 지원하는 컴포넌트가 많이 있으며, 전체 컴포넌트 목록과 문서는 아래에서 확인할 수 있습니다:
지금은 여기까지입니다! 이 글이 라이브러리와 그 정신 모델을 이해하는 데 도움이 되었길 바랍니다. 질문이 있으면 언제든 댓글을 남기거나 GitHub 저장소에 이슈를 열어 주세요:
저는 항상 피드백과 새로운 기능에 대한 제안을 환영합니다. 모두를 위해 API를 가능한 한 간단하고 유연하게 유지하면서 라이브러리를 개선하고자 합니다.