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

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#

사용 시기 (When to Use)#

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

  • 화면 가장자리 스와이프로 네비게이션 메뉴를 열 때
  • 모바일 환경에서 제스처 기반으로 시트/패널을 열 때
  • 버튼 탭과 스와이프 두 가지 방식 모두로 오버레이를 열고 싶을 때

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

  • Drawer: 버튼/명령으로만 여는 가장자리 패널에 (제스처 불필요)
  • Carousel: 스와이프로 슬라이드를 순환 탐색할 때

Props / Parameters#

W는 Flutter의 Widget, Web의 Component를 뜻하는 합성 콘텐츠 타입입니다.

속성타입기본값설명
childW필수스와이프 제스처에 반응하는 자식
overlayContentW필수오버레이 패널 콘텐츠
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)는 그 패널에 얹히는 partial CoreDrawerStyle 프리셋 + 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)#

필드drawersheet
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" 로 보조 기술에 아무것도 더하지 않고 조작 대상이 아닙니다.

키보드#

키FlutterWeb
스와이프로 열기 없음 (포인터 전용) 없음 (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 가 결정하고, 여기서 두 플랫폼이 크게 갈립니다.

FlutterWeb
진입 패널이 PopupRoute(모달 라우트)라 프레임워크가 라우트의 FocusScope 로 포커스를 옮김 없음 — autofocus 도 포커스 이동도 하지 않아 포커스가 트리거(또는 <body>)에 남음
트랩 모달 라우트 스코프 안에 갇힘 없음 — 배경이 inert 도 아니고 트랩도 없어 Tab 이 패널 뒤 페이지로 빠져나감
이탈라우트가 pop 될 때 이전 포커스로 복귀옮긴 적이 없어 복귀할 것도 없음

알려진 제약#

  • 열림 상태가 갱신되지 않습니다. 양 플랫폼 모두 _isOpen 을 뒤집을 때 리빌드를 요청하지 않아(setState 호출이 하나도 없습니다), Flutter expanded 와 Web aria-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 를 통해 nested Navigator 안에 그려집니다 — pop() 은 그 nested Navigator 의 루트(패널)를 대상으로 삼습니다. 대안: closeDrawer(context) 를 씁니다.

모션 감소 · 고대비 · 최소 터치 타깃처럼 모든 컴포넌트에 공통으로 적용되는 축은 전역 접근성 축 에 정리되어 있습니다.

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

항목FlutterWeb
클래스명SwiperSwiper
제스처 감지GestureDetector 드래그touch / pointer 이벤트
오버레이 마운트PopupRouteposition: fixed 패널
애니메이션SlideTransitionCSS transition: transform
  • Drawer: 버튼/명령으로 여는 가장자리 패널
  • Carousel: 스와이프로 슬라이드를 순환 탐색