Swap#
checked 상태에 따라 두 가지 콘텐츠를 전환하는 토글 컴포넌트입니다. 회전(rotate), 뒤집기(flip) 애니메이션을 지원합니다.
Live Preview#
class SwapDefaultExample extends StatefulComponent {
const SwapDefaultExample({super.key});
@override
State<SwapDefaultExample> createState() => _SwapDefaultExampleState();
}
class _SwapDefaultExampleState extends State<SwapDefaultExample> {
bool _checked = false;
@override
Component build(BuildContext context) {
return Swap(
checked: _checked,
onContent: Text('ON'),
offContent: Text('OFF'),
onToggle: (v) => setState(() => _checked = v),
);
}
}
class SwapDefaultExample extends StatefulWidget {
const SwapDefaultExample({super.key});
@override
State<SwapDefaultExample> createState() => _SwapDefaultExampleState();
}
class _SwapDefaultExampleState extends State<SwapDefaultExample> {
bool _checked = false;
@override
Widget build(BuildContext context) {
return Swap(
checked: _checked,
onContent: const Text('ON'),
offContent: const Text('OFF'),
onToggle: (v) => setState(() => _checked = v),
);
}
}
class SwapRotateExample extends StatefulComponent {
const SwapRotateExample({super.key});
@override
State<SwapRotateExample> createState() => _SwapRotateExampleState();
}
class _SwapRotateExampleState extends State<SwapRotateExample> {
bool _checked = false;
@override
Component build(BuildContext context) {
return Swap(
checked: _checked,
animation: CoreSwapAnimation.rotate,
onContent: Text('ON'),
offContent: Text('OFF'),
onToggle: (v) => setState(() => _checked = v),
);
}
}
class SwapRotateExample extends StatefulWidget {
const SwapRotateExample({super.key});
@override
State<SwapRotateExample> createState() => _SwapRotateExampleState();
}
class _SwapRotateExampleState extends State<SwapRotateExample> {
bool _checked = false;
@override
Widget build(BuildContext context) {
return Swap(
checked: _checked,
animation: CoreSwapAnimation.rotate,
onContent: const Text('ON'),
offContent: const Text('OFF'),
onToggle: (v) => setState(() => _checked = v),
);
}
}
class SwapFlipExample extends StatefulComponent {
const SwapFlipExample({super.key});
@override
State<SwapFlipExample> createState() => _SwapFlipExampleState();
}
class _SwapFlipExampleState extends State<SwapFlipExample> {
bool _checked = false;
@override
Component build(BuildContext context) {
return Swap(
checked: _checked,
animation: CoreSwapAnimation.flip,
onContent: Text('ON'),
offContent: Text('OFF'),
onToggle: (v) => setState(() => _checked = v),
);
}
}
class SwapFlipExample extends StatefulWidget {
const SwapFlipExample({super.key});
@override
State<SwapFlipExample> createState() => _SwapFlipExampleState();
}
class _SwapFlipExampleState extends State<SwapFlipExample> {
bool _checked = false;
@override
Widget build(BuildContext context) {
return Swap(
checked: _checked,
animation: CoreSwapAnimation.flip,
onContent: const Text('ON'),
offContent: const Text('OFF'),
onToggle: (v) => setState(() => _checked = v),
);
}
}
class SwapChainExample extends StatefulComponent {
const SwapChainExample({super.key});
@override
State<SwapChainExample> createState() => _SwapChainExampleState();
}
class _SwapChainExampleState extends State<SwapChainExample> {
bool _checked = false;
@override
Component build(BuildContext context) {
return Swap(
checked: _checked,
onContent: Text('ON'),
offContent: Text('OFF'),
onToggle: (v) => setState(() => _checked = v),
).withStyle(
const CoreSwapStyle(
duration: Duration(milliseconds: CoreDuration.slow),
textStyle: CoreTextStyle.token(
CoreTextStyles.titleMedium,
color: CoreColor.token(CoreColors.primary),
),
clickableStyle: CoreClickableStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
),
),
);
}
}
class SwapChainExample extends StatefulWidget {
const SwapChainExample({super.key});
@override
State<SwapChainExample> createState() => _SwapChainExampleState();
}
class _SwapChainExampleState extends State<SwapChainExample> {
bool _checked = false;
@override
Widget build(BuildContext context) {
return Swap(
checked: _checked,
onContent: const Text('ON'),
offContent: const Text('OFF'),
onToggle: (v) => setState(() => _checked = v),
).withStyle(
const CoreSwapStyle(
duration: Duration(milliseconds: CoreDuration.slow),
textStyle: CoreTextStyle.token(
CoreTextStyles.titleMedium,
color: CoreColor.token(CoreColors.primary),
),
clickableStyle: CoreClickableStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
),
),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 다크/라이트 모드 아이콘 전환
- 볼륨 음소거/켜기 아이콘 토글
- 두 상태 간 시각적 전환 효과가 필요할 때
대신 다른 컴포넌트를 사용하세요:
Toggle: on/off 스위치 UICollapsible: 콘텐츠 영역 접기/펴기
기본 사용법 (Basic Usage)#
Swap(
checked: _checked,
animation: CoreSwapAnimation.rotate,
onContent: Icon(LucideIcons.sun),
offContent: Icon(LucideIcons.moon),
onToggle: (v) => setState(() => _checked = v),
)
Swap(
checked: _checked,
animation: CoreSwapAnimation.rotate,
onContent: Icon(LucideIcons.sun),
offContent: Icon(LucideIcons.moon),
onToggle: (v) => setState(() => _checked = v),
)
빠른 오버라이드 (Chain)#
이미 만든 Swap 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class SwapChainExample extends StatefulWidget {
const SwapChainExample({super.key});
@override
State<SwapChainExample> createState() => _SwapChainExampleState();
}
class _SwapChainExampleState extends State<SwapChainExample> {
bool _checked = false;
@override
Widget build(BuildContext context) {
return Swap(
checked: _checked,
onContent: const Text('ON'),
offContent: const Text('OFF'),
onToggle: (v) => setState(() => _checked = v),
).withStyle(
const CoreSwapStyle(
duration: Duration(milliseconds: CoreDuration.slow),
textStyle: CoreTextStyle.token(
CoreTextStyles.titleMedium,
color: CoreColor.token(CoreColors.primary),
),
clickableStyle: CoreClickableStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
),
),
);
}
}
class SwapChainExample extends StatefulComponent {
const SwapChainExample({super.key});
@override
State<SwapChainExample> createState() => _SwapChainExampleState();
}
class _SwapChainExampleState extends State<SwapChainExample> {
bool _checked = false;
@override
Component build(BuildContext context) {
return Swap(
checked: _checked,
onContent: Text('ON'),
offContent: Text('OFF'),
onToggle: (v) => setState(() => _checked = v),
).withStyle(
const CoreSwapStyle(
duration: Duration(milliseconds: CoreDuration.slow),
textStyle: CoreTextStyle.token(
CoreTextStyles.titleMedium,
color: CoreColor.token(CoreColors.primary),
),
clickableStyle: CoreClickableStyle(
borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
),
),
);
}
}
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
onContent |
Widget / Component |
필수 | "on" 상태 콘텐츠 |
offContent |
Widget / Component |
필수 | "off" 상태 콘텐츠 |
checked | bool | false | 현재 상태 |
animation |
CoreSwapAnimation |
none |
전환 애니메이션 (none, rotate, flip) |
onToggle |
ValueChanged<bool>? |
null |
토글 콜백 |
swapStyle |
CoreSwapStyle? |
null |
인스턴스별 chrome / animation 단일 진입점 |
CoreSwapStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
duration | Duration? | Animation duration override. |
textStyle |
CoreTextStyle? |
Body typography role override applied to the swap slots' children. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw
textColor
field removed).
|
clickableStyle |
CoreClickableStyle? |
Nested [CoreClickableStyle] slot for the composed root
Clickable
(press scale / durations / focus ring / disabled opacity) when the swap is interactive (
onToggle != null
). Merged on top of [defaultClickableStyle] and raw-forwarded — the Clickable's own resolver fills the rest.
|
동작 스펙 (Behavior)#
- 클릭/탭 시
onToggle콜백 호출 checked상태에 따라onContent/offContent전환rotate: 180도 회전 + 교차 페이드flip: Y축 180도 뒤집기 + 교차 페이드none: 교차 페이드만
사용 가이드라인 (Usage Guidelines)#
✅ Do#
인터랙티브 토글로 쓸 때는 onToggle 을 반드시 제공
Swap(
checked: _muted,
onContent: Icon(LucideIcons.volumeX),
offContent: Icon(LucideIcons.volume2),
onToggle: (v) => setState(() => _muted = v),
)
onToggle 유무가 접근성 표면 전체(역할 · 포커스 · 키보드)를 가릅니다 — 콜백이 없으면 역할도 tabindex/FocusNode 도 없는 순수 표시 표면이 되어 키보드·스크린 리더 사용자가 조작할 수 없습니다.
❌ Don't#
아이콘만 넣고 접근 가능한 이름 없이 배포하지 않기
// ❌ label 없음 — 스크린 리더 사용자는 이 버튼이 무엇을 하는지 알 수 없음
Swap(
checked: _dark,
onContent: Icon(LucideIcons.moon),
offContent: Icon(LucideIcons.sun),
onToggle: (v) => setState(() => _dark = v),
)
Swap 에는 label 파라미터가 없어 아이콘만으로는 접근 가능한 이름이 없습니다 — Web 은 attributes: {'aria-label': '...'} 로, Flutter 는 감싼 Semantics 로 이름을 직접 공급해야 합니다.
접근성 (Accessibility)#
접근성 표면은 onToggle 유무로 완전히 갈립니다. onToggle 이 있으면 토글
버튼이 되고, 없으면 역할도 포커스도 키 처리도 없는 순수 표시 표면입니다.
역할 / 시맨틱#
onToggle != null 일 때만 역할이 생기며, 역할은 합성된 Clickable 이 제공합니다.
-
Web:
Clickable이role="button"을(비활성 시aria-disabled="true"도) 내보내고,Swap이 그 위에aria-pressed="true|false"를 얹습니다. 역할은 의도적으로switch가 아니라button으로 남습니다. -
Flutter:
Clickable이Semantics(enabled: ..., button: true)를 내보내고,Swap이 그 안에Semantics(toggled: checked, button: true)를 한 겹 더 둡니다.
onToggle == null 이면 양쪽 모두 이 배선 이전에 빠져나갑니다 — Flutter 는
Semantics 없는 콘텐츠를, Web 은 role 도 tabindex
도 없는 평범한 <div> 를
반환합니다.
키보드#
onToggle != null 일 때만, 그리고 전적으로 Clickable 을 통해 동작합니다.
| 키 | 동작 |
|---|---|
Enter | 토글 활성화 (onToggle 호출) |
Space | 토글 활성화 (onToggle 호출) |
Web 은 처리한 키에 preventDefault() 를 호출하며, 이벤트 대상이 swap 자신일 때만
반응합니다(내부에 중첩된 포커스 가능 요소의 키 입력은 삼키지 않습니다). 방향키와
Escape 는 처리하지 않고, onToggle == null 이면 어떤 키도 처리하지 않습니다.
포커스#
onToggle != null 일 때만 포커스를 받습니다.
-
Flutter:
Clickable이FocusableActionDetector안에서FocusNode를 소유하고,enabled && focused일 때FocusOutline링을 그립니다. -
Web:
Clickable이 활성일 때tabindex="0"을 붙이고 비활성이면 생략합니다. 누름 · 포커스 표시는 resolved className 위의 브라우저 네이티브:active/:focus-visible이 담당합니다.
포커스 트랩과 복원은 없습니다. onToggle == null 이면 양쪽 모두 포커스 대상이
아닙니다 — tabindex 도 FocusNode 도 없습니다.
스크린 리더#
-
Web (인터랙티브): 눌림 상태를 가진 버튼으로 안내되고, 이름은 현재 보이는 면의
콘텐츠에서 옵니다. 두 면은 항상 마운트돼 있지만 보이지 않는 쪽에
inert가 실려 있어 두 면이 동시에 읽히지 않습니다. - Flutter (인터랙티브): 토글 상태를 가진 버튼으로 안내되고, 이름은 보이는 콘텐츠에서 옵니다.
-
비인터랙티브(
onToggle == null): 양쪽 모두 역할도 상태도 없는 평범한 콘텐츠로 읽힙니다.
알려진 제약#
-
Flutter 에는 Web 의
inert에 해당하는 처리가 없습니다.animation: none과rotate는 두 면을Opacity로 동시에 그리면서ExcludeSemantics를 걸지 않기 때문에, 숨겨진 면의 콘텐츠도 함께 읽힙니다. 한 면만 그리는 것은flip뿐입니다. Flutter 에서 두 면이 같이 읽히는 게 곤란하다면 호출자가 감싸서 제외해야 합니다. - Flutter 는 버튼 시맨틱 노드가 두 겹입니다(
Clickable것 +Swap자신의 것). -
역할이
switch/checkbox가 아니라button+ 눌림 상태입니다. 스위치가 아니라 토글 버튼으로 안내됩니다. -
레이블 파라미터가 없습니다. 아이콘만 넣은 swap 은 접근 가능한 이름이 없습니다 —
Web 은 통과되는
attributes(예:aria-label), Flutter 는 호출자가 감싼Semantics로 이름을 직접 공급해야 합니다. -
비인터랙티브 variant 는 의도적으로 접근성 어포던스가 0인 표시 표면입니다. 상태
변화를 사용자에게 알려야 한다면
onToggle을 주거나 바깥에서 알려야 합니다.
애니메이션 감쇄 · 대비 같은 축은 전역 접근성 축 을 참고하세요.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | Swap | Swap |
| 애니메이션 | AnimationController + Transform |
CSS transform + transition |
| 이벤트 | GestureDetector.onTap | click event |
관련 컴포넌트 (Related Components)#
- Toggle: on/off 스위치
- Collapsible: 콘텐츠 접기/펴기