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

MultipleChoice

여러 옵션 중 하나를 선택하는 단일 선택 그룹 컴포넌트

목차

여러 옵션을 나열하고 그중 하나만 선택할 수 있는 단일 선택 그룹 컴포넌트입니다. allowUnselect가 true이면 선택된 옵션을 다시 눌러 선택을 해제할 수 있습니다.

각 옵션은 Chip 을 합성해 그립니다. 선택되지 않은 옵션은 중립색 윤곽(neutral · outlined) 칩이고, 선택된 옵션은 primary 칩입니다. 높이는 32, 옵션 사이 간격은 8 입니다. 옵션의 모양을 바꾸려면 chipStyle 슬롯으로 칩의 스타일을 넘깁니다.

기본 사용법 (Basic Usage)#

MultipleChoice<String>(
  options: const [
    (value: 'sm', label: 'Small'),
    (value: 'md', label: 'Medium'),
    (value: 'lg', label: 'Large'),
  ],
  value: size,
  onChanged: (v) => setState(() => size = v),
)
MultipleChoice<String>(
  options: const [
    (value: 'sm', label: 'Small'),
    (value: 'md', label: 'Medium'),
    (value: 'lg', label: 'Large'),
  ],
  value: size,
  onChanged: (v) => setState(() => size = v),
)

빠른 오버라이드 (Chain)#

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

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

  @override
  State<MultipleChoiceChainExample> createState() => _MultipleChoiceChainExampleState();
}

class _MultipleChoiceChainExampleState extends State<MultipleChoiceChainExample> {
  String? _value = 'md';

  @override
  Widget build(BuildContext context) {
    return MultipleChoice<String>(
      options: const [
        (value: 'sm', label: 'Small'),
        (value: 'md', label: 'Medium'),
        (value: 'lg', label: 'Large'),
      ],
      value: _value,
      onChanged: (v) => setState(() => _value = v),
    ).withStyle(
      const CoreMultipleChoiceStyle(
        optionSpacing: CoreSpace.space12,
        chipStyle: CoreChipStyle(
          padding: CoreEdgeInsets.symmetric(
            horizontal: CoreSpace.space20,
            vertical: CoreSpace.space12,
          ),
          borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
        ),
      ),
    );
  }
}
class MultipleChoiceChainExample extends StatefulComponent {
  const MultipleChoiceChainExample({super.key});

  @override
  State<MultipleChoiceChainExample> createState() => _MultipleChoiceChainExampleState();
}

class _MultipleChoiceChainExampleState extends State<MultipleChoiceChainExample> {
  String? _value = 'md';

  @override
  Component build(BuildContext context) {
    return MultipleChoice<String>(
      options: const [
        (value: 'sm', label: 'Small'),
        (value: 'md', label: 'Medium'),
        (value: 'lg', label: 'Large'),
      ],
      value: _value,
      onChanged: (v) => setState(() => _value = v),
    ).withStyle(
      const CoreMultipleChoiceStyle(
        optionSpacing: CoreSpace.space12,
        chipStyle: CoreChipStyle(
          padding: CoreEdgeInsets.symmetric(
            horizontal: CoreSpace.space20,
            vertical: CoreSpace.space12,
          ),
          borderRadius: CoreBorderRadius.all(CoreRadius.radius16),
        ),
      ),
    );
  }
}

Live Preview#

사용 시기 (When to Use)#

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

  • 크기 / 옵션처럼 적은 수의 후보 중 하나를 버튼 형태로 선택할 때
  • 라디오 버튼보다 강조된 선택 UI가 필요할 때

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

  • RadioGroup: 라디오 그룹의 입력·키보드 계약이 필요할 때
  • Select: 옵션을 펼쳐 두는 대신 드롭다운에서 선택할 때

Props / Parameters#

속성타입기본값설명
options List<({T value, String label})> 필수 선택 가능한 옵션 목록
valueT?null현재 선택된 값
enabledbooltrue선택 가능 여부
allowUnselect bool false 선택 항목 재탭으로 해제 허용
onChanged ValueChanged<T?>? null 선택 변경 콜백
multipleChoiceStyle CoreMultipleChoiceStyle? null 옵션 간격과 합성된 Chip 의 스타일(chipStyle) 오버라이드

스타일 시스템 (Style System)#

CoreMultipleChoiceStyle 필드#

필드타입설명
optionSpacing double? Gap between consecutive options (logical px) — Wrap.spacing on Flutter, the root row gap on Web. Defaults to [defaultOptionSpacing].
optionPadding CoreEdgeInsets? Deprecated (since 0.173) — Use chipStyle.padding — the option is a composed Chip now; the value still reaches the chip until removed. Option padding override.
optionBorderRadius CoreBorderRadius? Deprecated (since 0.173) — Use chipStyle.borderRadius — the option is a composed Chip now; the value still reaches the chip until removed. Option corner radius override.
optionBorderWidth double? Deprecated (since 0.173) — Use chipStyle.borderWidth — the option is a composed Chip now; the value still reaches the chip until removed. Option border width override (logical px).
selectedBackgroundColor CoreColor? Deprecated (since 0.173) — Use chipStyle.backgroundColor on a selected Chip — the option is a composed Chip now; the value still reaches the chip until removed. Selected option background colour override.
selectedTextStyle CoreTextStyle? Deprecated (since 0.173) — Use chipStyle.labelStyle — the option is a composed Chip now; the value still reaches the chip until removed. Selected option text style override. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw selectedTextColor field removed). Defaults to [defaultSelectedTextStyle].
selectedBorderColor CoreColor? Deprecated (since 0.173) — Use chipStyle.borderColor — the option is a composed Chip now; the value still reaches the chip until removed. Selected option border colour override.
unselectedBackgroundColor CoreColor? Deprecated (since 0.173) — Use chipStyle.backgroundColor — the option is a composed Chip now; the value still reaches the chip until removed. Unselected option background colour override.
unselectedTextStyle CoreTextStyle? Deprecated (since 0.173) — Use chipStyle.labelStyle — the option is a composed Chip now; the value still reaches the chip until removed. Unselected option text style override. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw unselectedTextColor field removed). Defaults to [defaultUnselectedTextStyle].
unselectedBorderColor CoreColor? Deprecated (since 0.173) — Use chipStyle.borderColor — the option is a composed Chip now; the value still reaches the chip until removed. Unselected option border colour override.
disabledTextStyle CoreTextStyle? Deprecated (since 0.173) — Use chipStyle.labelStyle — the option is a composed Chip now; the value still reaches the chip until removed. Disabled option text style override. Text colour is carried via [CoreTextStyle.color] inside this slot (sb8 — raw disabledTextColor field removed). Defaults to [defaultDisabledTextStyle].
transitionDuration Duration? Transition duration override for selection colour changes. Folded into the composed Chip 's transitionDuration by [chipStyleFor].
clickableStyle CoreClickableStyle? Nested [CoreClickableStyle] slot (press scale / durations / focus ring / disabled opacity). Folded into the composed Chip 's own clickableStyle by [chipStyleFor] — the chip's resolver fills the rest.
chipStyle CoreChipStyle? Nested [CoreChipStyle] slot for the composed per-option Chip . Merged on top of [defaultChipStyle] (and the deprecated flat option fields, see [chipStyleFor]) and raw-forwarded to Chip(chipStyle:) — the chip's own resolver fills the rest.

이전 필드 (Deprecated)#

옵션 박스를 직접 그리던 아래 필드는 deprecated(since 0.173) 입니다. 값은 합성된 칩의 스타일로 그대로 전달되므로 기존 오버라이드는 한 버전 동안 계속 적용됩니다. 새 코드는 chipStyle 을 쓰세요. 둘이 같은 값을 건드리면 chipStyle 이 이깁니다.

이전 필드대체
optionPaddingchipStyle.padding
optionBorderRadiuschipStyle.borderRadius
optionBorderWidthchipStyle.borderWidth
selectedBackgroundColor · unselectedBackgroundColor chipStyle.backgroundColor
selectedBorderColor · unselectedBorderColor chipStyle.borderColor
selectedTextStyle · unselectedTextStyle · disabledTextStyle chipStyle.labelStyle

동작 스펙 (Behavior)#

  • 선택: 옵션 클릭 시 해당 값으로 onChanged 호출
  • 해제: allowUnselect가 true일 때 선택된 옵션 재클릭 → onChanged(null)
  • 단일 선택: 항상 최대 한 개만 선택됨
  • value는 호출부의 현재 값이며 콜백 후 부모가 갱신해야 합니다. 선택한 값을 재탭해도 allowUnselect가 거짓이면 콜백을 내지 않습니다. enabled=false 또는 onChanged=null이면 변경 요청을 내지 않습니다.
  • 좁은 부모에서는 옵션을 줄바꿈합니다. 그룹 전용 화살표 선택·폼 검증·목적지 이동은 제공하지 않습니다. 전통적인 라디오 그룹의 탐색 계약이 필요하면 RadioGroup 입니다.

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

✅ Do#

선택 해제가 의미 있을 때만 allowUnselect 사용

MultipleChoice<String>(
  options: const [
    (value: 'all', label: 'All'),
    (value: 'active', label: 'Active'),
  ],
  value: filter,
  allowUnselect: true,
  onChanged: (v) => setState(() => filter = v),
)

allowUnselect 기본값은 false 라 선택된 옵션을 다시 눌러도 해제되지 않습니다. "필터 초기화"처럼 선택 해제 자체가 유효한 상태일 때만 켜세요.


❌ Don't#

옵션별 개별 비활성화를 흉내내지 않기

// ❌ enabled 는 그룹 전체에만 적용 — 특정 옵션만 막을 수 없음
MultipleChoice<String>(
  options: const [
    (value: 'sm', label: 'Small'),
    (value: 'md', label: 'Medium (품절)'),
  ],
  value: size,
  enabled: size != 'md',
)

CoreMultipleChoiceOption 레코드에는 per-option disabled 필드가 없습니다 — enabled 는 그룹 전체를 한꺼번에 켜고 끌 뿐입니다. 특정 옵션만 막아야 한다면 options 목록에서 그 옵션을 아예 제외하세요.

접근성 (Accessibility)#

역할#

플랫폼그룹각 옵션
Flutter Wrap 하나뿐 — 시맨틱 노드를 만들지 않습니다 Semantics(selected: …, inMutuallyExclusiveGroup: true) 로 감싼 Chip . 이름·버튼·비활성 시맨틱은 Chip 의 Clickable 이 냅니다
Web 루트 <div> 에 role="radiogroup" (configureAttributes) Chip 의 Clickable 루트에 role="radio" + aria-checked="{selected}" (칩의 attributes 로 전달). 비활성이면 Clickable 이 aria-disabled="true" 를 얹고 tabindex 를 뺍니다

Web 의 호출자 attributes 는 configureAttributes 뒤에 병합되어 이깁니다 — 그룹 루트에 aria-label / aria-labelledby 를 얹거나 role 을 바꿀 수 있습니다.

키보드#

키동작
Tab / Shift+Tab옵션 사이 이동 (옵션마다 tab stop)
Enter · Space 그 옵션 선택. 이미 선택된 옵션이고 allowUnselect 가 true 면 해제( onChanged(null) )
화살표 (← → ↑ ↓)없음
Home / End없음

활성화는 옵션마다 합성된 Chip 안의 Clickable 이 담당합니다 — Flutter 는 SingleActivator(.enter) / SingleActivator(.space) → ActivateIntent, Web 은 keydown 에서 Enter / ' ' / Spacebar. 두 구현 파일 어디에도 화살표 키를 읽는 코드가 없습니다.

스크린 리더#

  • 각 옵션의 이름은 CoreMultipleChoiceOption.label 문자열입니다 — Chip 의 label 로 전달되어 칩 안의 텍스트로 읽힙니다.
  • 선택 상태는 양쪽 다 전달됩니다 — Flutter Semantics(selected: …), Web aria-checked.
  • 비활성 상태도 양쪽 다 전달됩니다 — Flutter 는 Chip 의 비활성 시맨틱, Web 은 aria-disabled="true".
  • 그룹 자체에는 이름이 없습니다. 컴포넌트가 label 파라미터를 갖지 않아 Web 은 이름 없는 radiogroup 으로 나갑니다.
  • 라이브 리전이 아닙니다 — 선택이 바뀌어도 즉시 통보되지 않습니다.

포커스 관리#

오버레이가 아니라 진입 · 이탈 · 트랩이 모두 없습니다. roving tabindex 가 적용되지 않아 옵션 하나가 tab stop 하나를 차지합니다 — Web Clickable 은 활성이면 항상 tabindex="0" 을 넣고, Flutter 는 옵션마다 독립된 FocusableActionDetector 를 만듭니다. 옵션이 N 개면 그룹을 빠져나가는 데 Tab 을 N 번 눌러야 합니다. 포커스 링은 Chip 이 자기 모서리 반경에 맞춰 그립니다.

알려진 제약#

  • radiogroup / radio 가 약속하는 화살표 이동이 없습니다 (Web). 스크린 리더는 라디오 그룹이라 안내하는데 화살표 키는 아무 일도 하지 않고 Tab 이 유일한 이동 수단이며, 그 결과 위 포커스 절의 "옵션 수만큼 tab stop" 이 그대로 사용자에게 노출됩니다. 대안이 사실상 없습니다 — 옵션의 attributes 와 선택 인덱스가 모두 컴포넌트 내부에서 만들어져, 호출부가 eventHandlers 로 keydown 을 붙여도 어느 옵션으로 포커스를 옮길지 지시할 표면이 없습니다.
  • Flutter 에는 라디오 그룹 구조가 없습니다. 루트가 Wrap 이라 그룹 노드가 생기지 않고, 옵션은 selected: + inMutuallyExclusiveGroup: true 로 알려지지만 "3개 중 1번" 같은 위치 안내를 낼 그룹 노드가 없습니다. 대안: 호출부가 Semantics(container: true, label: '…', child: MultipleChoice(…)) 로 감싸 그룹 이름 정도는 얹을 수 있습니다. 옵션 사이의 상호배타 관계까지 되살릴 경로는 없습니다.
  • 선택 · 비활성 구분은 Chip 의 규칙을 따릅니다. 선택은 primary 채움 ↔ 중립 윤곽, 비활성은 Chip 의 비활성 토큰으로 갈립니다. 색 강제(forced-colors) 모드에서 이 구분이 어떻게 접히는지는 Chip 문서가 정본입니다.

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

항목FlutterWeb
클래스명 MultipleChoice<T> MultipleChoice<T>
옵션 렌더 Chip 합성 (Semantics 로 감쌈) Chip 합성 (role="radio" · aria-checked 전달)
선택 의미button·selected 시맨틱radio 역할·aria-checked
줄바꿈WrapCSS flex-wrap