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#
코드 보기
class SwitcherDefaultExample extends StatefulComponent {
const SwitcherDefaultExample({super.key});
@override
State<SwitcherDefaultExample> createState() =>
_SwitcherDefaultExampleState();
}
class _SwitcherDefaultExampleState extends State<SwitcherDefaultExample> {
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',
),
],
),
],
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',
);
}
}
class SwitcherDefaultExample extends StatefulWidget {
const SwitcherDefaultExample({super.key});
@override
State<SwitcherDefaultExample> createState() =>
_SwitcherDefaultExampleState();
}
class _SwitcherDefaultExampleState extends State<SwitcherDefaultExample> {
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),
],
),
),
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]),
),
),
],
),
],
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 온보딩 단계처럼 순차적 뷰 사이를 슬라이드 전환할 때
- 목록/그리드 뷰 모드 전환에 부드러운 애니메이션이 필요할 때
- 드래그로 인접 뷰를 스크럽하는 인터랙션이 필요할 때
대신 다른 컴포넌트를 사용하세요:
Tabs: 라벨 기반의 콘텐츠 섹션 전환에Carousel: 자동 재생 / 다수 아이템 슬라이드쇼에Swiper: 카드 스택 형태의 스와이프 인터랙션에
Props / Parameters#
W는 Flutter의 Widget, Web의 Component를 뜻하는 뷰 타입입니다. index는 0-based이며
0 <= index < items.length 범위로 전달합니다.
Switcher#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
items | List<W> | 필수 | 전환 대상 자식 뷰 목록 |
index | int | 0 | 현재 보이는 자식 인덱스 |
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 하나만 붙입니다.
| 키 | Flutter | Web |
|---|---|---|
← / →, ↑ / ↓ (뷰 전환) |
없음 | 없음 |
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으로 변환합니다.