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#
코드 보기
class MultipleChoiceDefaultExample extends StatefulComponent {
const MultipleChoiceDefaultExample({super.key});
@override
State<MultipleChoiceDefaultExample> createState() =>
_MultipleChoiceDefaultExampleState();
}
class _MultipleChoiceDefaultExampleState
extends State<MultipleChoiceDefaultExample> {
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),
);
}
}
class MultipleChoiceDefaultExample extends StatefulWidget {
const MultipleChoiceDefaultExample({super.key});
@override
State<MultipleChoiceDefaultExample> createState() =>
_MultipleChoiceDefaultExampleState();
}
class _MultipleChoiceDefaultExampleState
extends State<MultipleChoiceDefaultExample> {
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),
);
}
}
사용 시기 (When to Use)#
이 컴포넌트를 사용하세요:
- 크기 / 옵션처럼 적은 수의 후보 중 하나를 버튼 형태로 선택할 때
- 라디오 버튼보다 강조된 선택 UI가 필요할 때
대신 다른 컴포넌트를 사용하세요:
- RadioGroup: 라디오 그룹의 입력·키보드 계약이 필요할 때
- Select: 옵션을 펼쳐 두는 대신 드롭다운에서 선택할 때
Props / Parameters#
| 속성 | 타입 | 기본값 | 설명 |
|---|---|---|---|
options |
List<({T value, String label})> |
필수 | 선택 가능한 옵션 목록 |
value | T? | null | 현재 선택된 값 |
enabled | bool | true | 선택 가능 여부 |
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 이 이깁니다.
| 이전 필드 | 대체 |
|---|---|
optionPadding | chipStyle.padding |
optionBorderRadius | chipStyle.borderRadius |
optionBorderWidth | chipStyle.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: …), Webaria-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)#
| 항목 | Flutter | Web |
|---|---|---|
| 클래스명 | MultipleChoice<T> |
MultipleChoice<T> |
| 옵션 렌더 | Chip 합성 (Semantics 로 감쌈) |
Chip 합성 (role="radio" · aria-checked 전달) |
| 선택 의미 | button·selected 시맨틱 | radio 역할·aria-checked |
| 줄바꿈 | Wrap | CSS flex-wrap |
관련 컴포넌트 (Related Components)#
- RadioGroup: 라디오 버튼 단일 선택
- Select: 드롭다운 선택
- ChipGroup: 칩 형태 다중 선택