BottomSheet | CoUI
본문으로 건너뛰기
LogoCoUI

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#

구조 (Anatomy)#

 [            handle             ]
 [ title                  close ]
 [ description                  ]
 ───────────── divider ──────────
 [ content                      ]
 [ footer                       ]
부분역할슬롯
손잡이위로 끌어 올릴 수 있다는 표시 (기본 켬)showHandle
제목시트의 이름title
닫기제목 줄 끝의 닫기 버튼 (기본 끔)showCloseButton · onClose
설명제목 아래 짧은 한 줄description
구분선머리와 내용 사이 가는 선 (기본 끔)showDivider
내용시트의 본문content
하단 바내용 아래에 고정되는 영역 — 보통 BottomActionBarfooter

Figma: CoUI 라이브러리 파일의 BottomSheet 페이지

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 휴대폰에서 화면 아래에서 올라오는 패널 안에 제목, 선택 목록, 확인 버튼을 담을 때
  • 정렬 · 필터처럼 짧은 작업을 현재 화면 위에서 끝낼 때

대신 다른 컴포넌트를 사용하세요:

  • Drawer: 화면 가장자리에서 밀려 나오는 패널을 열고 닫는 일(라우트 · 오버레이)까지 맡길 때. 데스크톱의 옆 패널은 Drawer 입니다
  • Dialog: 화면 가운데에서 흐름을 멈추고 확인을 받을 때

모바일의 하단 패널에는 Drawer(side: bottom) 보다 BottomSheet 를 권장합니다. Drawer 의 Side=bottom 은 그대로 쓸 수 있습니다.

Props / Parameters#

W 는 Flutter의 Widget / Web의 Jaspr Component입니다.

속성타입기본값설명
titleW필수시트의 이름
description W? null 제목 아래 짧은 한 줄
contentW?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
구분선dividerStyleDivider 의 기본값
높이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': '…'}.

키보드#

키FlutterWeb
Tab 닫기 버튼 · 내용 · 하단 바의 활성 요소를 화면 순서로 이동 닫기 버튼 · 내용 · 하단 바의 활성 요소를 화면 순서로 이동
Enter / Space포커스를 가진 요소가 처리포커스를 가진 요소가 처리

스크린 리더#

  • 닫기 버튼은 아이콘뿐이라 라벨이 그 이름이 됩니다
  • 제목이 위젯이라 Flutter 는 제목 글자로 컨테이너의 이름을 붙이지 않습니다. 이름이 필요하면 놓는 쪽이 Semantics(label:) 로 감쌉니다

포커스 관리#

  • 진입 · 이탈 · 트랩: 없음 — 시트는 오버레이가 아닙니다. 오버레이로 놓는 쪽이 포커스를 관리합니다

알려진 제약#

  • Flutter 는 컨테이너에 문자열 라벨을 붙이지 못합니다(제목이 위젯이기 때문)

전역 축(동작 줄이기 · 고대비 · 색 강제 모드 · 시맨틱 역할 · 최소 터치 타겟)은 접근성 페이지가 한 곳에서 다룹니다.

크로스 플랫폼 차이점 (Platform Differences)#

항목FlutterWeb
클래스명BottomSheetBottomSheet
루트 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 통과

위 차이 밖에는 없습니다 — 이름 · 순서 · 기본값을 두 위젯 생성자에서 대조했습니다.

  • Drawer: 가장자리에서 밀려 나오는 패널을 열고 닫는 컴포넌트
  • BottomActionBar: 시트 맨 아래에 놓는 하단 바
  • OptionList: 시트 안 선택 목록
  • Dialog: 화면 가운데에서 확인을 받는 대화상자