Swiper
스와이프 제스처로 가장자리에서 오버레이 패널을 여는 컴포넌트
목차
자식 위젯을 감싸 스와이프 제스처에 반응하고, 화면 가장자리에서 오버레이 패널을 슬라이드 인하는 컴포넌트입니다. 모바일 환경의 자연스러운 스와이프 인터랙션으로 네비게이션 메뉴나 시트를 여는 데 활용됩니다.
기본 사용법 (Basic Usage)#
// 좌측에서 열리는 드로어 스타일 오버레이
Swiper(
position: CoreSwiperPosition.left,
open: isMenuOpen,
onOpenChanged: (isOpen) => setState(() => isMenuOpen = isOpen),
overlayContent: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Dashboard'),
Text('Settings'),
],
),
child: const AppHeader(),
)
// 아래에서 올라오는 시트 스타일
Swiper(
position: CoreSwiperPosition.bottom,
variant: CoreSwiperVariant.sheet,
open: isSheetOpen,
onOpenChanged: (isOpen) => setState(() => isSheetOpen = isOpen),
overlayContent: const ActionSheet(),
child: const PageContent(),
)
// 좌측에서 열리는 드로어 스타일 오버레이
Swiper(
position: CoreSwiperPosition.left,
open: isMenuOpen,
onOpenChanged: (isOpen) => setState(() => isMenuOpen = isOpen),
overlayContent: div([
const Text('Dashboard'),
const Text('Settings'),
], classes: 'flex flex-col gap-${CoreSpace.scale.space8}'),
child: const AppHeader(),
)
// 아래에서 올라오는 시트 스타일
Swiper(
position: CoreSwiperPosition.bottom,
variant: CoreSwiperVariant.sheet,
open: isSheetOpen,
onOpenChanged: (isOpen) => setState(() => isSheetOpen = isOpen),
overlayContent: const ActionSheet(),
child: const PageContent(),
)
Live Preview#
코드 보기
/// Default Swiper example for docs_site Web preview.
///
/// Mirrors the shadcn_flutter demo: pick the edge with the position
/// buttons, then swipe the card toward the opposite direction (or tap
/// Open) to reveal the drawer-style overlay. The open panel inherits
/// the composed Drawer's slide animation and can be swiped shut.
class SwiperDefaultExample extends StatefulComponent {
const SwiperDefaultExample({super.key});
@override
State<SwiperDefaultExample> createState() => _SwiperDefaultExampleState();
}
class _SwiperDefaultExampleState extends State<SwiperDefaultExample> {
CoreSwiperPosition _position = CoreSwiperPosition.left;
bool _open = false;
Component _positionButton(CoreSwiperPosition position, String label) {
return Button(
variant: _position == position ? .primary : .outline,
size: .sm,
onPressed: () => setState(() => _position = position),
child: Text(label),
);
}
@override
Component build(BuildContext context) {
return Swiper(
position: _position,
open: _open,
onOpenChanged: (isOpen) => setState(() => _open = isOpen),
// Centered like the shadcn demo via `justify-center` —
// alignment is caller-owned (the drawer never centers content
// itself). On the full-height left / right sides `h-full` gives
// the column room so the content centers; on content-sized
// top / bottom sides it resolves to auto and is a no-op.
overlayContent: div(
classes:
'flex h-full flex-col items-start justify-center '
'gap-${CoreSpace.scale.space8} p-${CoreSpace.scale.space16}',
[
const Text('Dashboard'),
const Text('Settings'),
const Text('Profile'),
Button(
variant: .outline,
size: .sm,
onPressed: () => setState(() => _open = false),
child: const Text('Close'),
),
],
),
// The whole preview area is the swipe surface (the gesture layer
// covers the Swiper child) — the card just labels it.
child: div(
classes: 'flex w-full items-center justify-center',
styles: Styles(
raw: {'min-height': 'var(--coui-space-${CoreSpace.space256.toInt()})'},
),
[
Card(
child: div(
classes:
'flex flex-col items-center gap-${CoreSpace.scale.space12} '
'p-${CoreSpace.scale.space24}',
[
const Text('Swipe me!').titleMedium.onSurface,
Join(
children: [
_positionButton(CoreSwiperPosition.left, 'Left'),
_positionButton(CoreSwiperPosition.right, 'Right'),
_positionButton(CoreSwiperPosition.top, 'Top'),
_positionButton(CoreSwiperPosition.bottom, 'Bottom'),
],
),
Button(
onPressed: () => setState(() => _open = true),
child: const Text('Open swiper'),
),
],
),
),
],
),
);
}
}
/// Default Swiper example for docs_site Flutter preview.
///
/// Mirrors the shadcn_flutter demo: pick the edge with the position
/// buttons, then swipe the card toward the opposite direction (or tap
/// Open) to reveal the drawer-style overlay. The open panel inherits
/// the composed Drawer's slide animation and can be swiped shut.
class SwiperDefaultExample extends StatefulWidget {
const SwiperDefaultExample({super.key});
@override
State<SwiperDefaultExample> createState() => _SwiperDefaultExampleState();
}
class _SwiperDefaultExampleState extends State<SwiperDefaultExample> {
CoreSwiperPosition _position = CoreSwiperPosition.left;
bool _open = false;
Widget _positionButton(CoreSwiperPosition position, String label) {
return Button(
variant: _position == position ? .primary : .outline,
size: .sm,
onPressed: () => setState(() => _position = position),
child: Text(label),
);
}
@override
Widget build(BuildContext context) {
return Swiper(
position: _position,
open: _open,
onOpenChanged: (isOpen) => setState(() => _open = isOpen),
// Centered like the shadcn demo via `mainAxisAlignment` —
// alignment is caller-owned (the drawer never centers content
// itself). On the full-height left / right sides the column is
// forced tall so the alignment centers; on content-sized
// top / bottom sides the column wraps and it is a no-op.
overlayContent: Padding(
padding: const CoreEdgeInsets.all(CoreSpace.space16).toValue(geometry: Theme.of(context).geometry),
child: Column(
crossAxisAlignment: .start,
mainAxisAlignment: .center,
mainAxisSize: .min,
spacing: Theme.of(context).geometry.space(CoreSpace.space8),
children: [
const Text('Dashboard'),
const Text('Settings'),
const Text('Profile'),
Button(
variant: .outline,
size: .sm,
onPressed: () => setState(() => _open = false),
child: const Text('Close'),
),
],
),
),
// The whole preview area is the swipe surface (the gesture layer
// covers the Swiper child) — the card just labels it.
child: SizedBox(
width: double.infinity,
height: CoreSpace.space256,
child: Center(
child: Card(
child: Padding(
padding: const CoreEdgeInsets.all(CoreSpace.space24).toValue(geometry: Theme.of(context).geometry),
child: Column(
mainAxisSize: .min,
children: [
const Text('Swipe me!').titleMedium.onSurface,
const Gap(gapStyle: CoreGapStyle(size: CoreSpace.space12)),
Join(
children: [
_positionButton(CoreSwiperPosition.left, 'Left'),
_positionButton(CoreSwiperPosition.right, 'Right'),
_positionButton(CoreSwiperPosition.top, 'Top'),
_positionButton(CoreSwiperPosition.bottom, 'Bottom'),
],
),
const Gap(gapStyle: CoreGapStyle(size: CoreSpace.space12)),
Button(
onPressed: () => setState(() => _open = true),
child: const Text('Open swiper'),
),
],
),
),
),
),
),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 화면 가장자리 스와이프로 네비게이션 메뉴를 열 때
- 모바일 환경에서 제스처 기반으로 시트/패널을 열 때
- 버튼 탭과 스와이프 두 가지 방식 모두로 오버레이를 열고 싶을 때
대신 다른 컴포넌트를 사용하세요:
Drawer: 버튼/명령으로만 여는 가장자리 패널에 (제스처 불필요)Carousel: 스와이프로 슬라이드를 순환 탐색할 때
Props / Parameters#
W는 Flutter의 Widget, Web의 Component를 뜻하는 합성 콘텐츠 타입입니다.
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child | W | 필수 | 스와이프 제스처에 반응하는 자식 |
overlayContent | W | 필수 | 오버레이 패널 콘텐츠 |
position |
CoreSwiperPosition |
left |
오버레이가 슬라이드 인하는 가장자리 |
variant |
CoreSwiperVariant |
drawer |
오버레이 표현 스타일 — drawer 는 열릴 때 앱 콘텐츠를 뒤로 축소(backdrop zoom-out)하고 그림자·보더·둥근 모서리를 가진 떠 있는 패널, sheet 는 backdrop 변형 없이 엣지에 붙는 최소 표면 (shadcn: "sheet 는 backdrop 변형이 없는 drawer") |
open |
bool |
false |
오버레이 열림 상태 (제어 값) |
onOpenChanged |
void Function(bool)? |
null |
열림 상태 변경 콜백 |
enabled |
bool |
true |
스와이프 제스처 활성화 여부 |
showDragHandle |
bool |
true |
드래그 핸들 표시 여부 — 핸들은 화면 중앙을 향한 엣지(잡아서 닫는 방향)에 붙음: bottom→위, top→아래, left/right→안쪽 엣지 세로 pill |
barrierDismissible |
bool |
true |
배경 탭으로 닫기 허용 여부 |
swiperStyle |
CoreSwiperStyle? |
null |
nested
drawerStyle: CoreDrawerStyle?
— 합성된 Drawer 패널의 크롬·치수·애니메이션·배리어와
손잡이
(
dragHandle*
) — chrome 단일 진입점. 평면
dragHandle*
필드는 폐기 예정(since 0.161)이며, 지울 때까지
drawerStyle
아래로 접혀 읽힌다(
drawerStyle
쪽이 이긴다)
|
오버레이 패널은 통일
Drawer가 그립니다 — 슬라이드 애니메이션·배리어·드래그로 닫기를 그대로 상속하며, variant(drawer/sheet)는 그 패널에 얹히는 partialCoreDrawerStyle프리셋 + backdrop 변형 여부(drawer 만 앱 콘텐츠를 0.95 로 축소)입니다. 열린 패널을 가장자리 방향으로 스와이프하면 닫힙니다.스와이프-투-오픈은 드래그 1:1 추종입니다(shadcn 파리티): 축-우세 슬롭(18px)을 넘는 순간 패널이 마운트되어 손가락을 그대로 따라 나오고, 놓으면 절반(0.5) 임계 기준으로 스냅 — 이상이면 완전히 열리고(open 이벤트 발화), 미만이면 슬라이드-백되며 열림 이벤트 없이 사라집니다. fling 속도 게이트가 없어 느린 풀도 동일하게 동작합니다. 모든 닫힘(배리어 탭·Escape·드래그·× 버튼·프로그래매틱)은 닫힘 슬라이드 애니메이션을 재생합니다.
빠른 오버라이드 (Chain)#
이미 만든 Swiper 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class SwiperChainExample extends StatefulWidget {
const SwiperChainExample({super.key});
@override
State<SwiperChainExample> createState() => _SwiperChainExampleState();
}
class _SwiperChainExampleState extends State<SwiperChainExample> {
CoreSwiperPosition _position = CoreSwiperPosition.left;
bool _open = false;
Widget _positionButton(CoreSwiperPosition position, String label) {
return Button(
variant: _position == position ? .primary : .outline,
size: .sm,
onPressed: () => setState(() => _position = position),
child: Text(label),
);
}
@override
Widget build(BuildContext context) {
return Swiper(
position: _position,
open: _open,
onOpenChanged: (isOpen) => setState(() => _open = isOpen),
// Centered like the shadcn demo via `mainAxisAlignment` —
// alignment is caller-owned (the drawer never centers content
// itself). On the full-height left / right sides the column is
// forced tall so the alignment centers; on content-sized
// top / bottom sides the column wraps and it is a no-op.
overlayContent: Padding(
padding: const CoreEdgeInsets.all(CoreSpace.space16).toValue(geometry: Theme.of(context).geometry),
child: Column(
crossAxisAlignment: .start,
mainAxisAlignment: .center,
mainAxisSize: .min,
spacing: Theme.of(context).geometry.space(CoreSpace.space8),
children: [
const Text('Dashboard'),
const Text('Settings'),
const Text('Profile'),
Button(
variant: .outline,
size: .sm,
onPressed: () => setState(() => _open = false),
child: const Text('Close'),
),
],
),
),
// The whole preview area is the swipe surface (the gesture layer
// covers the Swiper child) — the card just labels it.
child: SizedBox(
width: double.infinity,
height: CoreSpace.space256,
child: Center(
child: Card(
child: Padding(
padding: const CoreEdgeInsets.all(CoreSpace.space24).toValue(geometry: Theme.of(context).geometry),
child: Column(
mainAxisSize: .min,
children: [
const Text('Swipe me!').titleMedium.onSurface,
const Gap(gapStyle: CoreGapStyle(size: CoreSpace.space12)),
Join(
children: [
_positionButton(CoreSwiperPosition.left, 'Left'),
_positionButton(CoreSwiperPosition.right, 'Right'),
_positionButton(CoreSwiperPosition.top, 'Top'),
_positionButton(CoreSwiperPosition.bottom, 'Bottom'),
],
),
const Gap(gapStyle: CoreGapStyle(size: CoreSpace.space12)),
Button(
onPressed: () => setState(() => _open = true),
child: const Text('Open swiper'),
),
],
),
),
),
),
),
).withStyle(
const CoreSwiperStyle(
drawerStyle: CoreDrawerStyle(
dragHandleColor: CoreColor.token(CoreColors.primary),
dragHandleWidth: CoreSize.size64,
dragHandleThickness: CoreStrokeWidth.stroke6,
dragHandleBorderRadius: CoreBorderRadius.all(CoreRadius.radius4),
),
),
);
}
}
class SwiperChainExample extends StatefulComponent {
const SwiperChainExample({super.key});
@override
State<SwiperChainExample> createState() => _SwiperChainExampleState();
}
class _SwiperChainExampleState extends State<SwiperChainExample> {
CoreSwiperPosition _position = CoreSwiperPosition.left;
bool _open = false;
Component _positionButton(CoreSwiperPosition position, String label) {
return Button(
variant: _position == position ? .primary : .outline,
size: .sm,
onPressed: () => setState(() => _position = position),
child: Text(label),
);
}
@override
Component build(BuildContext context) {
return Swiper(
position: _position,
open: _open,
onOpenChanged: (isOpen) => setState(() => _open = isOpen),
// Centered like the shadcn demo via `justify-center` —
// alignment is caller-owned (the drawer never centers content
// itself). On the full-height left / right sides `h-full` gives
// the column room so the content centers; on content-sized
// top / bottom sides it resolves to auto and is a no-op.
overlayContent: div(
classes:
'flex h-full flex-col items-start justify-center '
'gap-${CoreSpace.scale.space8} p-${CoreSpace.scale.space16}',
[
const Text('Dashboard'),
const Text('Settings'),
const Text('Profile'),
Button(
variant: .outline,
size: .sm,
onPressed: () => setState(() => _open = false),
child: const Text('Close'),
),
],
),
// The whole preview area is the swipe surface (the gesture layer
// covers the Swiper child) — the card just labels it.
child: div(
classes: 'flex w-full items-center justify-center',
styles: Styles(
raw: {'min-height': 'var(--coui-space-${CoreSpace.space256.toInt()})'},
),
[
Card(
child: div(
classes:
'flex flex-col items-center gap-${CoreSpace.scale.space12} '
'p-${CoreSpace.scale.space24}',
[
const Text('Swipe me!').titleMedium.onSurface,
Join(
children: [
_positionButton(CoreSwiperPosition.left, 'Left'),
_positionButton(CoreSwiperPosition.right, 'Right'),
_positionButton(CoreSwiperPosition.top, 'Top'),
_positionButton(CoreSwiperPosition.bottom, 'Bottom'),
],
),
Button(
onPressed: () => setState(() => _open = true),
child: const Text('Open swiper'),
),
],
),
),
],
),
).withStyle(
const CoreSwiperStyle(
drawerStyle: CoreDrawerStyle(
dragHandleColor: CoreColor.token(CoreColors.primary),
dragHandleWidth: CoreSize.size64,
dragHandleThickness: CoreStrokeWidth.stroke6,
dragHandleBorderRadius: CoreBorderRadius.all(CoreRadius.radius4),
),
),
);
}
}
스타일 시스템 (Style System)#
CoreSwiperStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
drawerStyle |
CoreDrawerStyle? |
Nested chrome for the composed
Drawer
(panel surface / barrier / slide durations / drag-dismiss threshold …). Merges on top of the variant partial from [defaultsByVariant].
Deliberately has no flat
default*
— its baseline is [defaultsByVariant], which is this field's per-axis table.
Both resolvers read
defaultsByVariant[variant]!.merge(merged.drawerStyle)
, so the default is decided; it just varies along
variant
, which is where
core/layer-mapping.md
says a variant-dependent default belongs. The table is easy to miss with a name-matching search because its
value type
is this field's type, so no
drawerStyle:
key appears inside the literal. A flat constant could not represent it and would make things worse either way: empty, it is a no-op that adds a level to the merge chain for nothing; non-empty, it states panel chrome that [CoreSwiperVariant.sheet]'s partial (
panelBorderRadius:.zero
,
panelBorderWidth: 0
,
panelBoxShadow: []
) exists precisely to contradict — and any field it named that the sheet partial does not would silently reach the sheet too.
|
dragHandleColor |
CoreColor? |
Deprecated (since 0.161)
— Use drawerStyle: CoreDrawerStyle(dragHandleColor: …) — the handle is drawn by the composed Drawer. Still read until removed. Drag-handle colour — folds in under [drawerStyle]'s
dragHandleColor
, which wins when both are stated.
|
dragHandleWidth |
double? |
Deprecated (since 0.161)
— Use drawerStyle: CoreDrawerStyle(dragHandleWidth: …) — the handle is drawn by the composed Drawer. Still read until removed. Drag-handle length — folds in under [drawerStyle]'s
dragHandleWidth
.
|
dragHandleThickness |
double? |
Deprecated (since 0.161)
— Use drawerStyle: CoreDrawerStyle(dragHandleThickness: …) — the handle is drawn by the composed Drawer. Still read until removed. Drag-handle thickness — folds in under [drawerStyle]'s
dragHandleThickness
.
|
dragHandleInset |
double? |
Deprecated (since 0.161)
— Use drawerStyle: CoreDrawerStyle(dragHandleInset: …) — the handle is drawn by the composed Drawer. Still read until removed. Space around the handle — folds in under [drawerStyle]'s
dragHandleInset
.
|
dragHandleBorderRadius |
CoreBorderRadius? |
Deprecated (since 0.161)
— Use drawerStyle: CoreDrawerStyle(dragHandleBorderRadius: …) — the handle is drawn by the composed Drawer. Still read until removed. Drag-handle corner — folds in under [drawerStyle]'s
dragHandleBorderRadius
.
|
CoreSwiperStyle 변형별 기본값 (CoreDrawerStyle)#
| 필드 | drawer | sheet |
|---|---|---|
panelBorderRadius | — | zero |
panelBorderWidth | — | 0 |
panelBoxShadow | — | [] |
변형 (Variants)#
드로어 (Drawer)#
그림자가 있는 입체 패널입니다. 네비게이션 메뉴에 적합합니다.
Swiper(
position: CoreSwiperPosition.left,
variant: CoreSwiperVariant.drawer,
open: isOpen,
onOpenChanged: (v) => setState(() => isOpen = v),
overlayContent: const NavigationMenu(),
child: const AppHeader(),
)
시트 (Sheet)#
그림자 없이 최소 장식의 패널입니다. 바텀 시트에 적합합니다.
Swiper(
position: CoreSwiperPosition.bottom,
variant: CoreSwiperVariant.sheet,
open: isOpen,
onOpenChanged: (v) => setState(() => isOpen = v),
overlayContent: const ActionSheet(),
child: const PageContent(),
)
동작 스펙 (Behavior)#
인터랙션#
- 스와이프 열기:
position방향을 드러내는 방향으로 스와이프하면 오버레이가 열림 (좌측 오버레이 → 오른쪽 스와이프) - 배경 탭 닫기:
barrierDismissible이true이면 배경 탭으로 닫힘 - 제어 값:
open과onOpenChanged로 버튼 등 외부 트리거로도 제어 가능
애니메이션#
- 슬라이드 전환: 300ms ease-in-out
- 배경 페이드: 200ms ease-in-out
사용 가이드라인 (Usage Guidelines)#
가장자리에서 당겨 패널을 열고 드래그 추종을 제공하려면 Swiper를 사용합니다. 코드나 버튼으로 가장자리 패널을 여닫기만 하면 Drawer, 콘텐츠 뷰 사이의 인덱스 전환은 Switcher, 트리거 앵커 패널은 Popover를 선택합니다.
✅ Do#
open / onOpenChanged 로 controlled 상태를 유지하고, 스와이프 외 명시적 트리거도 항상 함께 제공
Swiper(
position: CoreSwiperPosition.left,
open: isMenuOpen,
onOpenChanged: (v) => setState(() => isMenuOpen = v),
overlayContent: const NavigationMenu(),
child: const AppHeader(),
)
// 스와이프 외에 버튼으로도 같은 상태를 열 수 있게
Button(
onPressed: () => setState(() => isMenuOpen = true),
child: const Icon(LucideIcons.menu),
)
스와이프 제스처는 키보드/스크린 리더 사용자가 실행할 수 없습니다 — open/onOpenChanged 로 상태를 노출해야 버튼 같은 명시적 트리거로도 같은 오버레이를 열고 닫을 수 있습니다.
❌ Don't#
콘텐츠 성격과 반대로 variant 를 고르지 않기
// ❌ 네비게이션 메뉴에 sheet — backdrop 변형·그림자 없이 밋밋하게 붙어
// "떠 있는 패널" 위계가 사라짐
Swiper(
position: CoreSwiperPosition.left,
variant: CoreSwiperVariant.sheet,
overlayContent: const NavigationMenu(),
child: const AppHeader(),
)
drawer 는 backdrop zoom-out + 그림자로 "떠 있는 패널"을 표현하고, sheet 는 backdrop 변형 없이 엣지에 붙는 최소 표면입니다 — 콘텐츠 성격과 반대로 고르면 사용자가 기대하는 시각적 위계가 어긋납니다.
접근성 (Accessibility)#
역할#
트리거(감싼 child)에는 역할도 이름도 없고 확장 상태만 붙습니다.
-
Flutter —
Semantics(expanded: _isOpen)가child전체를 감쌉니다. 버튼 role 도 라벨도 없습니다. -
Web — 루트
<div>에data-swiper-*와, 열려 있을 때만aria-expanded="true"가 붙습니다. 닫혔을 때는 속성 자체가 없어 "접힘"을 말하지 않습니다. 역할·이름은 없습니다.
오버레이 패널의 역할은 합성된 Drawer 가 소유합니다.
-
Web — 패널
<div>에role="dialog"+aria-modal="true".aria-label은Drawer.title이 있을 때만 붙는데Swiper는 title 을 넘기지 않으므로 이름 없는 dialog 가 됩니다. -
Flutter — 패널은
Semantics(container: true, label: title)로, title 이 없어 이름 없는 컨테이너 경계입니다. dialog role 에 해당하는 것은 emit 되지 않습니다. -
배리어 — Flutter 만 이름을 갖습니다(로케일의
dismissLabel). Web 배리어는 role/aria-label 없이 접근성 트리 밖에 두는 것이 의도된 설계입니다.
드래그 핸들 pill 은 순수 장식입니다 — 합성된 Drawer 가 그리며(showDragHandle 을 그대로 넘김), Flutter 는
ExcludeSemantics, Web 은 aria-hidden="true" 로 보조 기술에 아무것도 더하지 않고 조작 대상이 아닙니다.
키보드#
| 키 | Flutter | Web |
|---|---|---|
| 스와이프로 열기 | 없음 (포인터 전용) | 없음 (touch / mouse 전용) |
Escape 로 닫기 |
barrierDismissible: true 일 때만 |
항상 (Drawer.dismissOnEscape 기본 true) |
Tab |
열린 패널 안에 갇힘 (모달 라우트) | 갇히지 않음 — 뒤 페이지로 그대로 나감 |
Swiper 자신에는 키 핸들러가 없습니다 — Flutter 는 Listener 의 포인터 콜백만, Web 은 touch*
/ mouse* 이벤트만 붙입니다.
Escape 의 게이트가 플랫폼마다 다릅니다. Web 은 Drawer 가 dismissOnEscape 로 document keydown 리스너를 직접 설치하고,
Swiper 는 그 값을 노출하지 않으므로 항상 켜져 있습니다. Flutter 쪽 Drawer 에는 자체 Escape 처리가 없고(dismissOnEscape
는 선언·copyWith 외에 읽히는 곳이 없습니다) 프레임워크의 모달 라우트 dismiss 에 의존하는데, 그것은 barrierDismissible
로 게이트됩니다. 결과적으로 barrierDismissible: false 를 주면 Flutter 에서는 Escape 도 함께 막히고 Web 에서는 계속 닫힙니다.
스크린 리더#
트리거에도 패널에도 접근 가능한 이름이 없습니다. Swiper 에는 라벨 슬롯이 없고, 패널 이름을 만들 Drawer.title
도 전달하지 않습니다.
상태 변화를 알리는 live region 은 없습니다. 통보 수단은 트리거의 확장 상태(Flutter expanded, Web aria-expanded)뿐인데, 그 상태는 역할 없는 노드 위에 얹혀 있습니다.
포커스 관리#
Swiper 자신은 포커스 대상이 아닙니다 — 양쪽 모두 tabindex 도 FocusNode 도 없습니다. 패널의 포커스 거동은 합성된
Drawer 가 결정하고, 여기서 두 플랫폼이 크게 갈립니다.
| Flutter | Web | |
|---|---|---|
| 진입 | 패널이 PopupRoute(모달 라우트)라 프레임워크가 라우트의 FocusScope 로 포커스를 옮김 |
없음 — autofocus 도 포커스 이동도 하지 않아 포커스가 트리거(또는 <body>)에 남음 |
| 트랩 | 모달 라우트 스코프 안에 갇힘 | 없음 — 배경이 inert 도 아니고 트랩도 없어 Tab 이 패널 뒤 페이지로 빠져나감 |
| 이탈 | 라우트가 pop 될 때 이전 포커스로 복귀 | 옮긴 적이 없어 복귀할 것도 없음 |
알려진 제약#
-
열림 상태가 갱신되지 않습니다. 양 플랫폼 모두
_isOpen을 뒤집을 때 리빌드를 요청하지 않아(setState호출이 하나도 없습니다), Flutterexpanded와 Webaria-expanded가 이전 값에 머뭅니다. controlled 로 써서 부모가onOpenChanged를 받아 리빌드하더라도, 그 리빌드는_isOpen이 뒤집히기 전(Flutter post-frame / Web microtask 이전)에 끝나므로 그 프레임에도 따라오지 않고 이후 다른 이유로 한 번 더 리빌드될 때까지 남습니다. -
패널에 이름이 없고 닫기 버튼도 없습니다.
Swiper는 합성한Drawer에title을 넘기지 않는데,Drawer의 헤더와aria-label이 붙은 × 버튼은 title 이 있을 때만 렌더됩니다. 대안 없음 —Swiper에 title 슬롯이 없습니다. 닫는 경로는 배리어 탭 · 드래그 ·Escape·open제어값뿐입니다. -
여는 경로가 포인터 전용입니다. 키보드·스크린 리더만으로는 스와이프를 실행할 수 없습니다. 대안:
open/onOpenChanged로 상태를 노출하고 버튼 같은 명시적 트리거를 함께 둡니다. -
Flutter 에서 host-렌더 패널 안의
Navigator.of(context).pop()은 오버레이를 닫지 않습니다. 기본값인variant: drawer는 backdrop 변형을 켜므로, 앱 셸이 host 레이어를 설치한 환경에서 패널은 그 host 를 통해 nestedNavigator안에 그려집니다 —pop()은 그 nested Navigator 의 루트(패널)를 대상으로 삼습니다. 대안:closeDrawer(context)를 씁니다.
모션 감소 · 고대비 · 최소 터치 타깃처럼 모든 컴포넌트에 공통으로 적용되는 축은 전역 접근성 축 에 정리되어 있습니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | Swiper | Swiper |
| 제스처 감지 | GestureDetector 드래그 | touch / pointer 이벤트 |
| 오버레이 마운트 | PopupRoute | position: fixed 패널 |
| 애니메이션 | SlideTransition | CSS transition: transform |