Swap | CoUI
LogoCoUI

Swap

두 가지 상태 사이를 전환하는 토글 위젯 컴포넌트

Swap#

checked 상태에 따라 두 가지 콘텐츠를 전환하는 토글 컴포넌트입니다. 회전(rotate), 뒤집기(flip) 애니메이션을 지원합니다.

Live Preview#

사용 시기 (When to Use)#

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

  • 다크/라이트 모드 아이콘 전환
  • 볼륨 음소거/켜기 아이콘 토글
  • 두 상태 간 시각적 전환 효과가 필요할 때

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

  • Toggle: on/off 스위치 UI
  • Collapsible: 콘텐츠 영역 접기/펴기

기본 사용법 (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" 상태 콘텐츠
checkedboolfalse현재 상태
animation CoreSwapAnimation none 전환 애니메이션 (none, rotate, flip)
onToggle ValueChanged<bool>? null 토글 콜백
swapStyle CoreSwapStyle? null 인스턴스별 chrome / animation 단일 진입점

CoreSwapStyle 필드#

필드타입설명
durationDuration?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: Clickablerole="button" 을(비활성 시 aria-disabled="true" 도) 내보내고, Swap 이 그 위에 aria-pressed="true|false" 를 얹습니다. 역할은 의도적으로 switch 가 아니라 button 으로 남습니다.
  • Flutter: ClickableSemantics(enabled: ..., button: true) 를 내보내고, Swap 이 그 안에 Semantics(toggled: checked, button: true) 를 한 겹 더 둡니다.

onToggle == null 이면 양쪽 모두 이 배선 이전에 빠져나갑니다 — Flutter 는 Semantics 없는 콘텐츠를, Web 은 roletabindex 도 없는 평범한 <div> 를 반환합니다.

키보드#

onToggle != null 일 때만, 그리고 전적으로 Clickable 을 통해 동작합니다.

동작
Enter토글 활성화 (onToggle 호출)
Space토글 활성화 (onToggle 호출)

Web 은 처리한 키에 preventDefault() 를 호출하며, 이벤트 대상이 swap 자신일 때만 반응합니다(내부에 중첩된 포커스 가능 요소의 키 입력은 삼키지 않습니다). 방향키와 Escape 는 처리하지 않고, onToggle == null 이면 어떤 키도 처리하지 않습니다.

포커스#

onToggle != null 일 때만 포커스를 받습니다.

  • Flutter: ClickableFocusableActionDetector 안에서 FocusNode 를 소유하고, enabled && focused 일 때 FocusOutline 링을 그립니다.
  • Web: Clickable 이 활성일 때 tabindex="0" 을 붙이고 비활성이면 생략합니다. 누름 · 포커스 표시는 resolved className 위의 브라우저 네이티브 :active / :focus-visible 이 담당합니다.

포커스 트랩과 복원은 없습니다. onToggle == null 이면 양쪽 모두 포커스 대상이 아닙니다 — tabindexFocusNode 도 없습니다.

스크린 리더#

  • Web (인터랙티브): 눌림 상태를 가진 버튼으로 안내되고, 이름은 현재 보이는 면의 콘텐츠에서 옵니다. 두 면은 항상 마운트돼 있지만 보이지 않는 쪽에 inert 가 실려 있어 두 면이 동시에 읽히지 않습니다.
  • Flutter (인터랙티브): 토글 상태를 가진 버튼으로 안내되고, 이름은 보이는 콘텐츠에서 옵니다.
  • 비인터랙티브(onToggle == null): 양쪽 모두 역할도 상태도 없는 평범한 콘텐츠로 읽힙니다.

알려진 제약#

  • Flutter 에는 Web 의 inert 에 해당하는 처리가 없습니다. animation: nonerotate 는 두 면을 Opacity 로 동시에 그리면서 ExcludeSemantics 를 걸지 않기 때문에, 숨겨진 면의 콘텐츠도 함께 읽힙니다. 한 면만 그리는 것은 flip 뿐입니다. Flutter 에서 두 면이 같이 읽히는 게 곤란하다면 호출자가 감싸서 제외해야 합니다.
  • Flutter 는 버튼 시맨틱 노드가 두 겹입니다(Clickable 것 + Swap 자신의 것).
  • 역할이 switch / checkbox 가 아니라 button + 눌림 상태입니다. 스위치가 아니라 토글 버튼으로 안내됩니다.
  • 레이블 파라미터가 없습니다. 아이콘만 넣은 swap 은 접근 가능한 이름이 없습니다 — Web 은 통과되는 attributes(예: aria-label), Flutter 는 호출자가 감싼 Semantics 로 이름을 직접 공급해야 합니다.
  • 비인터랙티브 variant 는 의도적으로 접근성 어포던스가 0인 표시 표면입니다. 상태 변화를 사용자에게 알려야 한다면 onToggle 을 주거나 바깥에서 알려야 합니다.

애니메이션 감쇄 · 대비 같은 축은 전역 접근성 축 을 참고하세요.

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

항목FlutterWeb
클래스명SwapSwap
애니메이션 AnimationController + Transform CSS transform + transition
이벤트GestureDetector.onTapclick event