BottomSheet
모바일 화면 아래에서 올라오는 패널 — 손잡이 · 제목 · 내용 · 하단 바를 한 면에 담습니다
목차
BottomSheet 는 휴대폰 화면 아래에서 올라오는 패널입니다. 위에서부터 손잡이, 제목 줄(제목과 닫기), 설명, 구분선,
내용, 하단 바 순서로 쌓고, 위 두 모서리가 둥근 채워진 면 위에 놓습니다. 컴포넌트가 맡는 것은 면과 배치뿐입니다 —
열고, 뒤 화면을 어둡게 하고, 스스로 닫는 일은 하지 않습니다. onClose 는 닫기 버튼이 눌렸다는 것만 알립니다.
기본 사용법 (Basic Usage)#
BottomSheet(
title: const Text('정렬'),
description: const Text('목록을 어떻게 볼지 골라요'),
content: OptionList(
children: [
OptionItem(
label: const Text('최신순'),
selected: sort == Sort.latest,
onPressed: () => setState(() => sort = Sort.latest),
),
OptionItem(
label: const Text('인기순'),
selected: sort == Sort.popular,
onPressed: () => setState(() => sort = Sort.popular),
),
],
),
footer: BottomActionBar(
primaryAction: Button(
variant: .primary,
size: .xl,
onPressed: apply,
child: const Text('적용하기'),
),
),
)
BottomSheet(
title: const Text('정렬'),
description: const Text('목록을 어떻게 볼지 골라요'),
content: OptionList(
children: [
OptionItem(
label: const Text('최신순'),
selected: sort == Sort.latest,
onPressed: () => setState(() => sort = Sort.latest),
),
OptionItem(
label: const Text('인기순'),
selected: sort == Sort.popular,
onPressed: () => setState(() => sort = Sort.popular),
),
],
),
footer: BottomActionBar(
primaryAction: Button(
variant: .primary,
size: .xl,
onPressed: apply,
child: const Text('적용하기'),
),
),
)
footer 는 자기 여백을 가진 영역을 놓는 자리입니다. BottomActionBar 를 넣으면 하단 안전 영역은 그 바가
처리하고, 바탕과 위 구분선은 시트가 정합니다 — 시트 안의 바는 바탕을 칠하지 않고 자기 구분선도 그리지 않습니다.
시트는 열리지 않습니다. 뒤 화면을 어둡게 하고 아래에서 올리는 일은 시트를 놓는 쪽의 몫입니다. 시트는 받은 폭을
채우고, 높이는 내용이 정하되 화면 높이의 90%(maxHeightFactor)를 넘지 않습니다. 내용이 더 길면 내용 영역만
스크롤됩니다.
Live Preview#
코드 보기
class BottomSheetDefaultExample extends StatelessComponent {
const BottomSheetDefaultExample({super.key});
@override
Component build(BuildContext context) {
return BottomSheet(
title: const Text('정렬'),
description: const Text('목록을 어떻게 볼지 골라요'),
content: OptionList(
children: [
OptionItem(
label: const Text('최신순'),
selected: true,
onPressed: () {},
),
OptionItem(
label: const Text('인기순'),
onPressed: () {},
),
OptionItem(
label: const Text('낮은 가격순'),
onPressed: () {},
),
],
),
footer: BottomActionBar(
primaryAction: Button(
variant: .primary,
size: .xl,
onPressed: () {},
child: const Text('적용하기'),
),
),
);
}
}
class BottomSheetDefaultExample extends StatelessWidget {
const BottomSheetDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return BottomSheet(
title: const Text('정렬'),
description: const Text('목록을 어떻게 볼지 골라요'),
content: OptionList(
children: [
OptionItem(
label: const Text('최신순'),
selected: true,
onPressed: () {},
),
OptionItem(
label: const Text('인기순'),
onPressed: () {},
),
OptionItem(
label: const Text('낮은 가격순'),
onPressed: () {},
),
],
),
footer: BottomActionBar(
primaryAction: Button(
variant: .primary,
size: .xl,
onPressed: () {},
child: const Text('적용하기'),
),
),
);
}
}
구조 (Anatomy)#
[ handle ]
[ title close ]
[ description ]
───────────── divider ──────────
[ content ]
[ footer ]
| 부분 | 역할 | 슬롯 |
|---|---|---|
| 손잡이 | 위로 끌어 올릴 수 있다는 표시 (기본 켬) | showHandle |
| 제목 | 시트의 이름 | title |
| 닫기 | 제목 줄 끝의 닫기 버튼 (기본 끔) | showCloseButton · onClose |
| 설명 | 제목 아래 짧은 한 줄 | description |
| 구분선 | 머리와 내용 사이 가는 선 (기본 끔) | showDivider |
| 내용 | 시트의 본문 | content |
| 하단 바 | 내용 아래에 고정되는 영역 — 보통 BottomActionBar | footer |
Figma: CoUI 라이브러리 파일의
BottomSheet 페이지
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 휴대폰에서 화면 아래에서 올라오는 패널 안에 제목, 선택 목록, 확인 버튼을 담을 때
- 정렬 · 필터처럼 짧은 작업을 현재 화면 위에서 끝낼 때
대신 다른 컴포넌트를 사용하세요:
- Drawer: 화면 가장자리에서 밀려 나오는 패널을 열고 닫는 일(라우트 · 오버레이)까지 맡길 때. 데스크톱의 옆 패널은 Drawer 입니다
- Dialog: 화면 가운데에서 흐름을 멈추고 확인을 받을 때
모바일의 하단 패널에는 Drawer(side: bottom) 보다 BottomSheet 를 권장합니다. Drawer
의 Side=bottom 은 그대로
쓸 수 있습니다.
Props / Parameters#
W 는 Flutter의 Widget / Web의 Jaspr Component입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
title | W | 필수 | 시트의 이름 |
description |
W? |
null |
제목 아래 짧은 한 줄 |
content | W? | null | 시트의 본문 |
footer |
W? |
null |
내용 아래에 고정되는 영역 — 보통 BottomActionBar |
showHandle |
bool |
true |
맨 위 손잡이를 그릴지 |
showCloseButton |
bool |
false |
제목 줄 끝에 닫기 버튼을 그릴지 |
showDivider |
bool |
false |
머리와 내용 사이 가는 선을 그릴지 |
onClose |
void Function()? |
null |
닫기 버튼이 눌렸을 때 — 시트는 스스로 닫지 않습니다 |
bottomSheetStyle |
CoreBottomSheetStyle? |
null |
모든 chrome 이 지나는 단일 슬롯 (아래 표 참고) |
Web 은 UiComponent 이므로 id · classes · css · attributes
· eventHandlers 도 받습니다.
CoreBottomSheetStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
backgroundColor |
CoreColor? |
Surface fill colour. null → [defaultBackgroundColor]. |
borderRadius |
CoreBorderRadius? |
Corners of the panel. null → [defaultBorderRadius]. |
handleWidth |
double? |
Width of the handle bar (logical px, pre-scaling). null → [defaultHandleWidth]. |
handleHeight |
double? |
Height of the handle bar (logical px, pre-scaling). null → [defaultHandleHeight]. |
handleColor |
CoreColor? |
Colour of the handle bar. null → [defaultHandleColor]. |
handleBorderRadius |
CoreBorderRadius? |
Corners of the handle bar. null → [defaultHandleBorderRadius]. |
handlePadding |
CoreEdgeInsets? |
Inset around the handle bar — its top edge is the space above it.
null
→ [defaultHandlePadding].
|
headerPadding |
CoreEdgeInsets? |
Inset around the title row and the description. null → [defaultHeaderPadding]. |
headerSpacing |
double? |
Space between the title and the close button (logical px, pre-scaling).
null
→ [defaultHeaderSpacing].
|
titleGapStyle |
CoreGapStyle? |
Gap between the title row and the description. null → [defaultTitleGapStyle]. |
titleTextStyle |
CoreTextStyle? |
Ambient text style of the title slot, merged on top of [defaultTitleTextStyle]. |
descriptionTextStyle |
CoreTextStyle? |
Ambient text style of the description slot, merged on top of [defaultDescriptionTextStyle]. |
contentPadding |
CoreEdgeInsets? |
Inset around the content. null → [defaultContentPadding]. |
closeButtonStyle |
CoreButtonStyle? |
Chrome of the composed close
Button
(raw-forwarded to
Button(variant:.plain, size:.sm, shape:.square)
), merged on top of [defaultCloseButtonStyle].
|
dividerStyle |
CoreDividerStyle? |
Chrome of the composed
Divider
between the header and the content, raw-forwarded.
null
leaves the divider to its own defaults. This field deliberately has no
default*
: the sheet's divider does not diverge from
Divider
's own look, so a default here would only copy the child's defaults (composition forwards a partial style only for the values that differ).
|
maxHeightFactor |
double? |
Tallest the sheet grows, as a fraction of the viewport's height. Past it the content scrolls between the header and the footer.
null
→ [defaultMaxHeightFactor].
|
footerDividerColor |
CoreColor? |
Colour of the hairline the sheet draws above the footer while more content lies below.
null
→ [defaultFooterDividerColor].
|
footerDividerWidth |
double? |
Thickness of that hairline (logical px, pre-scaling).
null
→ [defaultFooterDividerWidth].
|
footerBarStyle |
CoreBottomActionBarStyle? |
Chrome handed to a
BottomActionBar
placed in the footer — installed as that bar's theme style around the footer, so the bar's own per-instance style still wins. Merged on top of [defaultFooterBarStyle].
|
스타일 시스템 (Style System)#
bottomSheetStyle#
-
시맨틱 / behaviour: 위젯 파라미터로 직접 (
showHandle·showCloseButton·showDivider·onClose와 슬롯) - chrome / dimensional:
CoreBottomSheetStyle한 곳으로
| 부분 | 필드 | 기본값 |
|---|---|---|
| 면 | backgroundColor · borderRadius |
surfaceBright · 위 두 모서리 24, 아래 0 |
| 손잡이 |
handleWidth
·
handleHeight
·
handleColor
·
handleBorderRadius
·
handlePadding
|
36 × 4 · outline · 알약 · 위 8 (영역 높이 12) |
| 머리 | headerPadding · headerSpacing · titleGapStyle |
위 20 · 좌우 20 · 아래 8 · 제목과 닫기 사이 12 · 제목과 설명 사이 8 |
| 글자 | titleTextStyle · descriptionTextStyle |
titleLarge
+
onSurface
·
bodyLarge
+
onSurfaceVariant
|
| 내용 | contentPadding | 위아래 8 · 좌우 20 |
| 닫기 버튼 | closeButtonStyle |
정사각 32 · 패딩 0 · 모서리 8 · onSurfaceVariant |
| 구분선 | dividerStyle | Divider 의 기본값 |
| 높이 | maxHeightFactor | 화면 높이의 0.9 |
| 하단 바 위 선 | footerDividerColor · footerDividerWidth |
BottomActionBar 의 선과 같은 값 (outlineVariant · 1) |
| 하단 바 | footerBarStyle | 바탕 투명 · 바 자신의 선 두께 0 |
면은 재질을 읽는 표면입니다 — 서페이스 스타일(유리 · 점토 등)이 채움과 블러, 안쪽 림을 입힙니다. 테두리와 그림자는
없습니다. 닫기 버튼과 구분선은 각각 Button 과 Divider 를 합성한 것이라 그 컴포넌트의 스타일 슬롯으로 흐릅니다.
footerBarStyle 은 footer 에 놓인 BottomActionBar 에 바 테마로 설치됩니다. 그래서 프로젝트 공통 바 테마
(CoreBottomActionBarTheme)보다는 앞서고, 그 바 인스턴스에 직접 준 bottomActionBarStyle
에는 집니다.
BottomActionBar 가 아닌 위젯을 footer 에 넣으면 시트는 그 위젯의 모양을 바꾸지 않습니다 — 위 선만 그대로
시트가 그립니다.
Resolve chain
CoreBottomSheetStyle.defaultX // 기본값
→ CoreBottomSheetTheme.style // 프로젝트 공통
→ widget.bottomSheetStyle // 인스턴스별
사용 예
CoreComponentTheme(
bottomSheet: CoreBottomSheetTheme(
style: CoreBottomSheetStyle(
contentPadding: CoreEdgeInsets.symmetric(
horizontal: CoreSpace.space16,
vertical: CoreSpace.space8,
),
),
),
)
빠른 오버라이드 (Chain)
BottomSheet(title: const Text('정렬')).withStyle(
const CoreBottomSheetStyle(
borderRadius: CoreBorderRadius.only(
topLeft: CoreRadius.radius16,
topRight: CoreRadius.radius16,
),
),
)
동작 스펙 (Behavior)#
배치#
- 위에서 아래로 손잡이, 머리(제목 줄 · 설명), 구분선, 내용, 하단 바 순서입니다. 없는 부분은 자리도 차지하지 않습니다
- 제목 줄은 제목이 남는 폭을 채우고 닫기 버튼이 끝에 놓입니다. 제목과 닫기는 세로 가운데에 맞춥니다
-
시트는 받은 폭을 채우고 높이는 내용이 정합니다. 다만 화면 높이 ×
maxHeightFactor(기본 0.9)를 넘지 않습니다. 위 두 모서리만 둥글게 그립니다 - 내용이 그 높이를 넘으면 내용 영역만 스크롤됩니다. 손잡이 · 머리는 위에, 하단 바는 아래에 고정되고, 내용은 하단 바 위에서 잘려 버튼 뒤로 비치지 않습니다
-
높이 제약이 없는 곳(스크롤 뷰 안 등)에 놓여도 같은 상한으로 그립니다. Flutter 에서 화면 크기를 알 수 없으면(
MediaQuery가 없거나 높이 0) 상한 없이 내용 높이로 그리고 스크롤하지 않습니다 - 하단 바 영역은 바탕을 칠하지 않습니다 — 시트의 면이 그대로 보입니다
- 머리와 내용의 좌우 여백은 같은 값이라 읽기 방향에 따라 뒤집히지 않습니다
인터랙션#
- 시트 자체는 입력을 받지 않습니다. 닫기 버튼만
onClose를 부릅니다 - 열기 · 뒤 화면 어둡게 하기 · 끌어 내려 닫기는 시트를 놓는 쪽의 몫입니다
상태 전환#
| 상태 | 하단 바 위 선 |
|---|---|
| 내용이 다 보이거나, 끝까지 스크롤함 | 없음 |
| 아래에 내용이 더 있음(내용이 하단 바 밑으로 이어짐) | footerDividerColor · footerDividerWidth |
- 선은 하단 바 윗변 위에 겹쳐 그려 높이를 차지하지 않습니다. 선이 나타나고 사라져도 하단 바와 내용이 움직이지 않습니다
- 판정 여유는 1px 입니다. 끝에서 1px 이내면 끝까지 스크롤한 것으로 봅니다
- 하단 바가 없으면 선도 없습니다
애니메이션#
- 없습니다. 아래에서 올라오는 움직임은 시트를 놓는 쪽이 정합니다
사용 가이드라인 (Usage Guidelines)#
Do#
하단 바는 BottomActionBar 를 footer 로 넘긴다
BottomSheet(
title: const Text('필터'),
content: filters,
footer: BottomActionBar(primaryAction: applyButton),
)
이유: 하단 안전 영역은 바가 처리하고, 바탕 · 위 구분선은 시트가 바에 알맞게 맞춥니다. 같은 일을 두 번 하지 않습니다.
Don't#
content 안에 시트 자체의 패딩을 다시 두르지 않는다
// ❌ 내용 여백은 시트가 이미 위아래 8 · 좌우 20 으로 줍니다
BottomSheet(title: title, content: Padding(padding: const EdgeInsets.all(20), child: list))
이유: 여백이 겹쳐 내용이 안으로 밀립니다. 여백을 바꾸려면 contentPadding 을 씁니다.
접근성 (Accessibility)#
역할#
-
Flutter:
CoUISemantics(container: true)로 시트 전체를 하나의 컨테이너로 묶고, 닫기 버튼에는 로컬라이즈된 닫기 라벨을 붙입니다 -
Web: 루트
<div>에 역할을 내보내지 않습니다. 닫기 버튼에는 로컬라이즈된aria-label을 붙입니다
시트는 면일 뿐 대화상자가 아닙니다. 화면을 가리고 포커스를 가두는 오버레이로 놓는다면 놓는 쪽이 대화상자 역할을
붙입니다 — Web 은 attributes: {'role': 'dialog', 'aria-label': '…'}.
키보드#
| 키 | Flutter | Web |
|---|---|---|
Tab |
닫기 버튼 · 내용 · 하단 바의 활성 요소를 화면 순서로 이동 | 닫기 버튼 · 내용 · 하단 바의 활성 요소를 화면 순서로 이동 |
Enter / Space | 포커스를 가진 요소가 처리 | 포커스를 가진 요소가 처리 |
스크린 리더#
- 닫기 버튼은 아이콘뿐이라 라벨이 그 이름이 됩니다
- 제목이 위젯이라 Flutter 는 제목 글자로 컨테이너의 이름을 붙이지 않습니다. 이름이 필요하면 놓는 쪽이
Semantics(label:)로 감쌉니다
포커스 관리#
- 진입 · 이탈 · 트랩: 없음 — 시트는 오버레이가 아닙니다. 오버레이로 놓는 쪽이 포커스를 관리합니다
알려진 제약#
- Flutter 는 컨테이너에 문자열 라벨을 붙이지 못합니다(제목이 위젯이기 때문)
전역 축(동작 줄이기 · 고대비 · 색 강제 모드 · 시맨틱 역할 · 최소 터치 타겟)은 접근성 페이지가 한 곳에서 다룹니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | BottomSheet | BottomSheet |
| 루트 |
CoUISemantics
→
ConstrainedBox
(최대 높이) →
DecoratedBox
→
Column
(stretch, 최소 높이)
|
<div>(flex 열, max-height: 90dvh, 역할 없음) |
| 높이 상한의 기준 | MediaQuery 의 화면 높이 | dvh(보이는 뷰포트 높이) |
| 내용 스크롤 · 선 판정 | SingleChildScrollView 의 extentAfter — 스크롤 · 크기 알림마다 |
overflow-y: auto
영역의
scroll
이벤트와
ResizeObserver
— 측정기가 CSS 변수만 바꿔 다시 렌더하지 않음
|
| 서버 렌더 | — | 첫 그림은 선 없음. 내용이 넘치는 시트는 브라우저에서 측정한 뒤 선이 나타남 |
| 닫기 라벨 | Semantics(button: true, label:) |
버튼 attributes 의 aria-label |
| Web 전용 HTML 표면 | — |
attributes
·
classes
·
css
·
eventHandlers
·
id
통과
|
위 차이 밖에는 없습니다 — 이름 · 순서 · 기본값을 두 위젯 생성자에서 대조했습니다.
관련 컴포넌트 (Related Components)#
- Drawer: 가장자리에서 밀려 나오는 패널을 열고 닫는 컴포넌트
- BottomActionBar: 시트 맨 아래에 놓는 하단 바
- OptionList: 시트 안 선택 목록
- Dialog: 화면 가운데에서 확인을 받는 대화상자