Swiper | CoUI
LogoCoUI

Swiper

스와이프 제스처로 가장자리에서 오버레이 패널을 여는 컴포넌트

Swiper#

자식 위젯을 감싸 스와이프 제스처에 반응하고, 화면 가장자리에서 오버레이 패널을 슬라이드 인하는 컴포넌트입니다. 모바일 환경의 자연스러운 스와이프 인터랙션으로 네비게이션 메뉴나 시트를 여는 데 활용됩니다.

Live Preview#

사용 시기 (When to Use)#

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

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

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

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

기본 사용법 (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(),
)

Props / Parameters#

속성타입기본값설명
child Widget / Component 필수 스와이프 제스처에 반응하는 자식
overlayContent Widget / Component 필수 오버레이 패널에 표시할 콘텐츠
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 drag-handle 크롬 + nested drawerStyle: CoreDrawerStyle? (합성된 Drawer 패널의 크롬·치수·애니메이션·배리어) — chrome 단일 진입점

오버레이 패널은 통일 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 EdgeInsets.all(CoreSpace.space16),
        child: Column(
          crossAxisAlignment: .start,
          mainAxisAlignment: .center,
          mainAxisSize: .min,
          spacing: 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 EdgeInsets.all(CoreSpace.space24),
              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(
        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(
        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? Drag-handle indicator colour override.
dragHandleWidth double? Drag-handle indicator width override.
dragHandleThickness double? Drag-handle indicator thickness override.
dragHandleInset double? Drag-handle inset padding override.
dragHandleBorderRadius CoreBorderRadius? Drag-handle indicator corner radius override.

CoreSwiperStyle 변형별 기본값 (CoreDrawerStyle)#

필드drawersheet
panelBorderRadiuszero
panelBorderWidth0
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 방향을 드러내는 방향으로 스와이프하면 오버레이가 열림 (좌측 오버레이 → 오른쪽 스와이프)
  • 배경 탭 닫기: barrierDismissibletrue이면 배경 탭으로 닫힘
  • 제어 값: openonOpenChanged로 버튼 등 외부 트리거로도 제어 가능

애니메이션#

  • 슬라이드 전환: 300ms ease-in-out
  • 배경 페이드: 200ms ease-in-out

사용 가이드라인 (Usage Guidelines)#

✅ 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)#

  • 오버레이 패널에 role="dialog" + aria-modal="true" 부여
  • 배경 닫기 버튼에 aria-label 제공
  • 스와이프 외에 버튼 등 명시적 트리거를 함께 제공하여 키보드/스크린 리더 사용자도 오버레이를 열 수 있게

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

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