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

Switcher

슬라이드 전환과 드래그 제스처로 여러 뷰 사이를 오가는 스위처 컴포넌트

목차

여러 자식 뷰 중 하나를 한 번에 보여주고, 활성 인덱스가 바뀌면 슬라이드 / 페이드 전환을 애니메이션하는 컴포넌트입니다. 사용자는 드래그하여 인접한 뷰 사이를 스크럽할 수 있습니다.

Switcher는 레거시 Flutter Switcher와 Web Switcher를 통일한 컴포넌트입니다. Flutter와 Web이 동일한 파라미터 이름·동작·시각을 갖습니다 — 네 방향 슬라이드, 드래그 제스처, 동일한 전환 시간.

기본 사용법 (Basic Usage)#

Switcher(
  index: _index,
  direction: CoreSwitcherDirection.left,
  onIndexChanged: (next) => setState(() => _index = next),
  items: const [
    Text('Page 1'),
    Text('Page 2'),
    Text('Page 3'),
  ],
)
Switcher(
  index: _index,
  direction: CoreSwitcherDirection.left,
  onIndexChanged: (next) => setState(() => _index = next),
  items: [
    div([text('Page 1')]),
    div([text('Page 2')]),
    div([text('Page 3')]),
  ],
)

Live Preview#

사용 시기 (When to Use)#

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

  • 온보딩 단계처럼 순차적 뷰 사이를 슬라이드 전환할 때
  • 목록/그리드 뷰 모드 전환에 부드러운 애니메이션이 필요할 때
  • 드래그로 인접 뷰를 스크럽하는 인터랙션이 필요할 때

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

  • Tabs: 라벨 기반의 콘텐츠 섹션 전환에
  • Carousel: 자동 재생 / 다수 아이템 슬라이드쇼에
  • Swiper: 카드 스택 형태의 스와이프 인터랙션에

Props / Parameters#

W는 Flutter의 Widget, Web의 Component를 뜻하는 뷰 타입입니다. index는 0-based이며 0 <= index < items.length 범위로 전달합니다.

Switcher#

속성타입기본값설명
itemsList<W>필수전환 대상 자식 뷰 목록
indexint0현재 보이는 자식 인덱스
direction CoreSwitcherDirection left 새 뷰가 슬라이드해 들어오는 방향
gestureEnabled bool true 드래그 / 스와이프 내비게이션 활성화 여부
onIndexChanged void Function(int)? null 드래그가 다른 뷰에 안착했을 때 새 인덱스로 호출
switcherStyle CoreSwitcherStyle? null 인스턴스별 전환 스타일

CoreSwitcherDirection은 up / down / left / right 네 방향을 지원합니다.

스타일 시스템 (Style System)#

Switcher의 전환 chrome은 단일 CoreSwitcherStyle 슬롯으로 흐릅니다 .

시맨틱 vs 스타일#

  • 시맨틱 / behaviour: 위젯 파라미터로 직접 (index, direction, gestureEnabled, items)
  • chrome / dimensional: switcherStyle: CoreSwitcherStyle? 한 곳으로

Resolve chain#

CoreSwitcherStyle.defaultDuration / defaultCurve  // 디자인 시스템 기본값
  → CoreSwitcherTheme.style                       // 프로젝트 공통
  → widget.switcherStyle                          // 인스턴스별

CoreSwitcherStyle 필드#

필드타입설명
duration Duration? Slide / fade transition duration.
curve CoreCubicBezier? Slide / fade transition easing, as cubic-bezier control points. null → [defaultCurve].
Switcher(
  switcherStyle: const CoreSwitcherStyle(
    curve: CoreEasing.easeOutBack,
    duration: Duration(milliseconds: 200),
  ),
  items: const [Text('A'), Text('B')],
)

curve 는 플랫폼 중립인 cubic-bezier 제어점 4개(CoreEasing.*)입니다 — Flutter 는 이것을 Curve 로, Web 은 같은 제어점을 트랙 slide 와 슬롯 fade 양쪽의 cubic-bezier(...) timing function 으로 변환합니다.

전환 chrome 은 양 플랫폼 모두 switcherStyle 슬롯 하나로만 들어갑니다 — Flutter Curve 는 Web 이 미러할 수 없는 타입이라 단일 진입점이 될 수 없고, Core easing 토큰은 양쪽이 같이 읽습니다.

빠른 오버라이드 (Chain)#

이미 만든 Switcher 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.

class SwitcherChainExample extends StatefulWidget {
  const SwitcherChainExample({super.key});

  @override
  State<SwitcherChainExample> createState() => _SwitcherChainExampleState();
}

class _SwitcherChainExampleState extends State<SwitcherChainExample> {
  int _index = 0;

  static const List<String> _labels = ['View A', 'View B', 'View C'];

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        SizedBox(
          width: CoreSpace.space256,
          height: CoreSize.size96,
          child:
              Switcher(
                index: _index,
                onIndexChanged: (next) => setState(() => _index = next),
                items: [
                  for (final label in _labels) Center(child: Text(label).titleMedium),
                ],
              ).withStyle(
                const CoreSwitcherStyle(
                  duration: Duration(milliseconds: CoreDuration.slow),
                  curve: CoreEasing.enter,
                ),
              ),
        ),
        const Gap.space16(),
        Wrap(
          alignment: WrapAlignment.center,
          children: [
            for (var idx = 0; idx < _labels.length; idx++)
              Padding(
                padding: const CoreEdgeInsets.symmetric(
                  horizontal: CoreSpace.space4,
                ).toValue(geometry: Theme.of(context).geometry),
                child: Button(
                  variant: idx == _index ? CoreButtonVariant.primary : CoreButtonVariant.outline,
                  onPressed: () => setState(() => _index = idx),
                  child: Text(_labels[idx]),
                ),
              ),
          ],
        ),
      ],
    );
  }
}
class SwitcherChainExample extends StatefulComponent {
  const SwitcherChainExample({super.key});

  @override
  State<SwitcherChainExample> createState() => _SwitcherChainExampleState();
}

class _SwitcherChainExampleState extends State<SwitcherChainExample> {
  int _index = 0;

  static const List<String> _labels = ['View A', 'View B', 'View C'];

  @override
  Component build(BuildContext context) {
    return div(
      [
        div(
          [
            Switcher(
              index: _index,
              onIndexChanged: (next) => setState(() => _index = next),
              items: [
                for (final label in _labels)
                  div(
                    [Text(label).titleMedium],
                    classes: 'flex items-center justify-center h-full',
                  ),
              ],
            ).withStyle(
              const CoreSwitcherStyle(
                duration: Duration(milliseconds: CoreDuration.slow),
                curve: CoreEasing.enter,
              ),
            ),
          ],
          styles: const Styles(
            raw: {'width': '16rem', 'height': '6rem'},
          ),
        ),
        Gap.space16(),
        div(
          [
            for (var idx = 0; idx < _labels.length; idx++)
              Button(
                variant: idx == _index ? CoreButtonVariant.primary : CoreButtonVariant.outline,
                onPressed: () => setState(() => _index = idx),
                child: Text(_labels[idx]),
              ),
          ],
          classes: 'flex flex-row flex-wrap justify-center gap-${CoreSpace.scale.space8}',
        ),
      ],
      classes: 'inline-flex flex-col items-center',
    );
  }
}

변형 (Variants)#

슬라이드 방향#

direction으로 새 뷰가 들어오는 방향을 지정합니다.

Switcher(direction: CoreSwitcherDirection.up, items: [...])
Switcher(direction: CoreSwitcherDirection.right, items: [...])

드래그 비활성화#

gestureEnabled: false로 드래그를 막고 프로그래매틱 전환만 허용합니다.

Switcher(gestureEnabled: false, items: [...])

동작 스펙 (Behavior)#

인터랙션#

  • 프로그래매틱 전환: index 변경 시 새 뷰가 direction 방향으로 슬라이드
  • 드래그: gestureEnabled가 true면 마우스 / 터치 드래그로 인접 뷰 스크럽
  • 스냅: 드래그 해제 시 가장 가까운 뷰로 스냅하고 onIndexChanged 호출

애니메이션#

  • 슬라이드 + 페이드 전환: 기본 300ms (CoreDuration.moderate), easing 은 CoreEasing.move
  • 드래그 중에는 전환이 비활성화되어 포인터를 1:1로 따라감

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

여러 자식 뷰를 인덱스와 방향으로 전환하고 스와이프 이동하려면 Switcher를 사용합니다. 두 상태만 교체하면 Swap, 탭 바와 패널 구조는 TabPane, 가장자리 오버레이는 Swiper를 선택합니다. Switcher 자체는 탭 라벨이나 내비게이션 바를 만들지 않습니다.

라벨 선택 바만 필요하면 Tabs를 함께 검토합니다.

✅ Do#

onIndexChanged로 드래그 결과를 상위 상태에 반영

Switcher(
  index: _index,
  onIndexChanged: (next) => setState(() => _index = next),
  items: const [Text('A'), Text('B')],
)

드래그가 안착한 인덱스를 상태에 반영해야 다음 빌드에서 일관된 뷰가 보인다.


❌ Don't#

라벨 기반 콘텐츠 섹션 전환에 Switcher 사용

// ❌ 라벨 탭 UI 가 필요한 경우
Switcher(
  items: const [Text('Account'), Text('Password')],
)

라벨 탭이 필요하면 Tabs가 적합하다. Switcher 는 슬라이드 전환이 핵심이다.

접근성 (Accessibility)#

역할#

  • Web — 루트 <div> 에 role="group". 각 슬롯 <div> 에는 aria-hidden 이 붙어 활성 뷰는 "false", 나머지는 "true" 가 됩니다.
  • Flutter — Semantics(container: true) 하나뿐입니다. group 에 대응하는 role 이 Flutter 에 없어 시맨틱 경계로만 표현되고, aria-hidden 에 해당하는 것도 없습니다(아래 스크린 리더 참조).

어느 쪽에도 이름이 없습니다 — 라벨 슬롯 자체가 없습니다. Web 은 컴포넌트가 role 만 마지막에 덮어쓰므로 호출자 attributes: {'aria-label': ...} 는 그대로 통과하고, Flutter 는 호출부가 Semantics(label: ...) 로 감싸야 합니다.

키보드#

처리하는 키가 없습니다. 뷰 전환은 포인터 드래그와 index 변경만으로 일어납니다 — Flutter 는 GestureDetector 의 pan 콜백만, Web 은 pointerdown 하나만 붙입니다.

키FlutterWeb
← / →, ↑ / ↓ (뷰 전환) 없음 없음
Home / End, PageUp / PageDown 없음 없음
Tab컨테이너 자신은 정지점이 아님컨테이너 자신은 정지점이 아님

Web 은 onKeyDown / onKeyUp 을 루트에 통과시키지만 루트가 tabindex 를 갖지 않아, 포커스가 슬롯 콘텐츠 안에 있을 때만 발화합니다. Flutter 쪽에는 키 파라미터 자체가 없습니다.

스크린 리더#

인덱스가 바뀌었다고 알리는 live region 이 없습니다 — role="status" 도 aria-live 도 Flutter liveRegion 도 없습니다. 현재 몇 번째 뷰인지 알려주는 수단도 없습니다.

비활성 뷰를 리더에서 감추는 방식이 플랫폼마다 다릅니다. Web 은 모든 뷰가 항상 DOM 에 있고 aria-hidden 으로 활성 하나만 노출합니다. Flutter 는 정지 상태에서 비활성 항목을 아예 빌드하지 않습니다(전환이 끝난 쪽 가지가 SizedBox.shrink() 로 접힙니다). 전환이 진행되는 동안에는 Flutter 만 두 뷰가 동시에 트리에 있습니다.

포커스 관리#

  • 진입 / 이탈 / 트랩 — 없음. 오버레이가 아니고, 어느 쪽도 포커스를 옮기지 않습니다.
  • 컨테이너 자신은 포커스 대상이 아닙니다 — Web 루트에 tabindex 가 없고 Flutter 에 FocusNode / Focus 가 없습니다.
  • index 가 바뀌어도 새로 보이는 뷰로 포커스를 옮기지 않습니다.

알려진 제약#

  • Web 의 비활성 뷰가 여전히 탭 순서에 있습니다. 슬롯이 받는 것은 aria-hidden="true" 와 opacity: 0 뿐이고 inert / display: none / visibility: hidden / tabindex="-1" 은 붙지 않습니다. 비활성 뷰 안에 버튼·링크가 있으면 클리핑되어 보이지 않는 채로 Tab 이 도달하고, 그 요소는 aria-hidden 아래에 있어 리더가 읽지 못합니다 — 포커스는 갔는데 아무것도 안내되지 않는 상태입니다. 대안: 호출부가 각 item 안에서 자기 인터랙티브 요소를 현재 index 에 따라 비활성화합니다. Flutter 는 정지 상태에서 비활성 항목을 빌드하지 않으므로 같은 문제가 없습니다.
  • aria-hidden 이 index 만 따릅니다. 드래그로 다른 뷰에 안착해도 노출 여부는 component.index 로 계산되므로, 호출부가 onIndexChanged 를 받아 index 를 갱신하지 않으면 화면에 보이는 뷰가 aria-hidden="true" 로, 안 보이는 뷰가 노출된 채로 남습니다. 대안: index 를 controlled 로 유지합니다.
  • 전환이 "동작 줄이기"에 반응하지 않습니다. Web resolver 는 duration 을 durationMsCss(토큰 → var(--coui-duration-*)) 대신 리터럴 ${durationMs}ms 로 emit 하고, Flutter resolver 는 resolveMotionDuration 을 거치지 않습니다. 기본 300ms(CoreDuration.moderate) 슬라이드·페이드가 OS 설정과 무관하게 재생됩니다. 대안: switcherStyle: CoreSwitcherStyle(duration: Duration.zero) 로 호출부가 직접 끕니다.

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

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

Switcher는 Flutter와 Web이 동일한 items / index / direction / gestureEnabled / onIndexChanged / switcherStyle API와 동작을 갖습니다. Flutter는 커스텀 RenderObject로 자식 크기를 보간하고, Web은 CSS transform 트랙 슬라이드로 동일한 시각 결과를 냅니다. 전환 곡선은 양쪽 모두 CoreSwitcherStyle.curve (기본 CoreEasing.move) 를 읽습니다 — Flutter는 Curve로, Web은 cubic-bezier(...) timing function으로 변환합니다.

  • Tabs: 라벨 기반 콘텐츠 섹션 전환
  • Carousel: 자동 재생 슬라이드쇼
  • Swiper: 카드 스택 스와이프