1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:ui' show SemanticsRole;
6
7import 'package:collection/collection.dart';
8import 'package:flutter/services.dart';
9
10import 'actions.dart';
11import 'basic.dart';
12import 'focus_manager.dart';
13import 'focus_traversal.dart';
14import 'framework.dart';
15import 'shortcuts.dart';
16
17/// A group for radios.
18///
19/// This widget treats all radios, such as [RawRadio], [Radio], [CupertinoRadio]
20/// in the sub tree with the same type T as a group. Radios with different types
21/// are not included in the group.
22///
23/// This widget handles the group value for the radios in the subtree with the
24/// same value type.
25///
26/// Using this widget also provides keyboard navigation and semantics for the
27/// radio buttons that matches [APG](https://www.w3.org/WAI/ARIA/apg/patterns/radio/).
28///
29/// The keyboard behaviors are:
30/// * Tab and Shift+Tab: moves focus into and out of radio group. When focus
31/// moves into a radio group and a radio button is select, focus is set on
32/// selected button. Otherwise, it focus the first radio button in reading
33/// order.
34/// * Space: toggle the selection on the focused radio button.
35/// * Right and down arrow key: move selection to next radio button in the group
36/// in reading order.
37/// * Left and up arrow key: move selection to previous radio button in the
38/// group in reading order.
39///
40/// Arrow keys will wrap around if it reach the first or last radio in the
41/// group.
42///
43/// {@tool dartpad}
44/// Here is an example of RadioGroup widget.
45///
46/// Try using tab, arrow keys, and space to see how the widget responds.
47///
48/// ** See code in examples/api/lib/widgets/radio_group/radio_group.0.dart **
49/// {@end-tool}
50class RadioGroup<T> extends StatefulWidget {
51 /// Creates a radio group.
52 ///
53 /// The `groupValue` set the selection on a subtree radio with the same
54 /// [RawRadio.value].
55 ///
56 /// The `onChanged` is called when the selection has changed in the subtree
57 /// radios.
58 const RadioGroup({super.key, this.groupValue, required this.onChanged, required this.child});
59
60 /// The selected value under this radio group.
61 ///
62 /// [RawRadio] under this radio group where its [RawRadio.value] equals to this
63 /// value will be selected.
64 final T? groupValue;
65
66 /// Called when selection has changed.
67 ///
68 /// The value can be null when unselect the [RawRadio] with
69 /// [RawRadio.toggleable] set to true.
70 final ValueChanged<T?> onChanged;
71
72 /// {@macro flutter.widgets.ProxyWidget.child}
73 final Widget child;
74
75 /// Gets the [RadioGroupRegistry] from the above the context.
76 ///
77 /// This registers a dependencies on the context that it causes rebuild
78 /// if [RadioGroupRegistry] has changed or its
79 /// [RadioGroupRegistry.groupValue] has changed.
80 static RadioGroupRegistry<T>? maybeOf<T>(BuildContext context) {
81 return context.dependOnInheritedWidgetOfExactType<_RadioGroupStateScope<T>>()?.state;
82 }
83
84 @override
85 State<StatefulWidget> createState() => _RadioGroupState<T>();
86}
87
88class _RadioGroupState<T> extends State<RadioGroup<T>> implements RadioGroupRegistry<T> {
89 late final Map<ShortcutActivator, Intent> _radioGroupShortcuts = <ShortcutActivator, Intent>{
90 const SingleActivator(LogicalKeyboardKey.arrowLeft): VoidCallbackIntent(_selectPreviousRadio),
91 const SingleActivator(LogicalKeyboardKey.arrowRight): VoidCallbackIntent(_selectNextRadio),
92 const SingleActivator(LogicalKeyboardKey.arrowDown): VoidCallbackIntent(_selectNextRadio),
93 const SingleActivator(LogicalKeyboardKey.arrowUp): VoidCallbackIntent(_selectPreviousRadio),
94 const SingleActivator(LogicalKeyboardKey.space): VoidCallbackIntent(_toggleFocusedRadio),
95 };
96
97 final Set<RadioClient<T>> _radios = <RadioClient<T>>{};
98
99 bool _debugCheckOnlySingleSelection() {
100 return _radios.where((RadioClient<T> radio) => radio.radioValue == groupValue).length < 2;
101 }
102
103 @override
104 T? get groupValue => widget.groupValue;
105
106 @override
107 void registerClient(RadioClient<T> radio) {
108 _radios.add(radio);
109 assert(
110 _debugCheckOnlySingleSelection(),
111 "RadioGroupPolicy can't be used for a radio group that allows multiple selection",
112 );
113 }
114
115 @override
116 void unregisterClient(RadioClient<T> radio) => _radios.remove(radio);
117
118 void _toggleFocusedRadio() {
119 final RadioClient<T>? radio = _radios.firstWhereOrNull(
120 (RadioClient<T> radio) => radio.focusNode.hasFocus,
121 );
122 if (radio == null) {
123 return;
124 }
125 if (radio.radioValue != widget.groupValue) {
126 onChanged(radio.radioValue);
127 return;
128 }
129
130 if (radio.tristate) {
131 onChanged(null);
132 }
133 }
134
135 @override
136 ValueChanged<T?> get onChanged => widget.onChanged;
137
138 void _selectNextRadio() => _selectRadioInDirection(true);
139
140 void _selectPreviousRadio() => _selectRadioInDirection(false);
141
142 void _selectRadioInDirection(bool forward) {
143 if (_radios.length < 2) {
144 return;
145 }
146 final FocusNode? currentFocus = _radios
147 .firstWhereOrNull((RadioClient<T> radio) => radio.focusNode.hasFocus)
148 ?.focusNode;
149 if (currentFocus == null) {
150 // The focused node is either a non interactive radio or other controls.
151 return;
152 }
153 final List<FocusNode> sorted = ReadingOrderTraversalPolicy.sort(
154 _radios.map<FocusNode>((RadioClient<T> radio) => radio.focusNode),
155 ).toList();
156 final Iterable<FocusNode> nodesInEffectiveOrder = forward ? sorted : sorted.reversed;
157
158 final Iterator<FocusNode> iterator = nodesInEffectiveOrder.iterator;
159 FocusNode? nextFocus;
160 while (iterator.moveNext()) {
161 if (iterator.current == currentFocus) {
162 if (iterator.moveNext()) {
163 nextFocus = iterator.current;
164 }
165 break;
166 }
167 }
168 // Current focus is at the end, the next focus should wrap around.
169 nextFocus ??= nodesInEffectiveOrder.first;
170 final RadioClient<T> radioToSelect = _radios.firstWhere(
171 (RadioClient<T> radio) => radio.focusNode == nextFocus,
172 );
173 onChanged(radioToSelect.radioValue);
174 nextFocus.requestFocus();
175 }
176
177 @override
178 Widget build(BuildContext context) {
179 return Semantics(
180 container: true,
181 role: SemanticsRole.radioGroup,
182 child: Shortcuts(
183 shortcuts: _radioGroupShortcuts,
184 child: FocusTraversalGroup(
185 policy: _SkipUnselectedRadioPolicy<T>(_radios, widget.groupValue),
186 child: _RadioGroupStateScope<T>(
187 state: this,
188 groupValue: widget.groupValue,
189 child: widget.child,
190 ),
191 ),
192 ),
193 );
194 }
195}
196
197class _RadioGroupStateScope<T> extends InheritedWidget {
198 const _RadioGroupStateScope({required this.state, required this.groupValue, required super.child})
199 : super();
200 final _RadioGroupState<T> state;
201 // Need to include group value to notify listener when group value changes.
202 final T? groupValue;
203
204 @override
205 bool updateShouldNotify(covariant _RadioGroupStateScope<T> oldWidget) {
206 return state != oldWidget.state || groupValue != oldWidget.groupValue;
207 }
208}
209
210/// An abstract interface for registering a group of radios.
211///
212/// Use [registerClient] or [unregisterClient] to handle registrations of radios.
213///
214/// The registry manages the group value for the radios. The radio needs to call
215/// [onChanged] to notify the group value needs to be changed.
216abstract class RadioGroupRegistry<T> {
217 /// The group value for the group.
218 T? get groupValue;
219
220 /// Registers a radio client.
221 ///
222 /// The subclass provides additional features, such as keyboard navigation
223 /// for the registered clients.
224 void registerClient(RadioClient<T> radio);
225
226 /// Unregisters a radio client.
227 void unregisterClient(RadioClient<T> radio);
228
229 /// Notifies the registry that the a radio is selected or unselected.
230 ValueChanged<T?> get onChanged;
231}
232
233/// A client for a [RadioGroupRegistry].
234///
235/// This is typically mixed with a [State].
236///
237/// To register to a [RadioGroupRegistry], assign the registry to [registry].
238///
239/// To unregister from previous [RadioGroupRegistry], either assign a different
240/// value to [registry] or set it to null.
241mixin RadioClient<T> {
242 /// Whether this radio support toggles.
243 ///
244 /// Used by registry to provide additional feature such as keyboard support.
245 bool get tristate;
246
247 /// This value this radio represents.
248 ///
249 /// Used by registry to provide additional feature such as keyboard support.
250 T get radioValue;
251
252 /// Focus node for this radio.
253 ///
254 /// Used by registry to provide additional feature such as keyboard support.
255 FocusNode get focusNode;
256
257 /// The [RadioGroupRegistry] this client register to.
258 ///
259 /// Setting this property automatically register to the new value and
260 /// unregister the old value.
261 ///
262 /// This should set to null when dispose.
263 RadioGroupRegistry<T>? get registry => _registry;
264 RadioGroupRegistry<T>? _registry;
265 set registry(RadioGroupRegistry<T>? newRegistry) {
266 if (_registry != newRegistry) {
267 _registry?.unregisterClient(this);
268 }
269 _registry = newRegistry;
270 _registry?.registerClient(this);
271 }
272}
273
274/// A traversal policy that is the same as [ReadingOrderTraversalPolicy] except
275/// it skips nodes of unselected radio button if there is one selected radio
276/// button.
277///
278/// If none of the radio is selected, this defaults to
279/// [ReadingOrderTraversalPolicy] for all nodes.
280///
281/// This policy is to ensure when tabbing into a radio group, it will only focus
282/// the current selected radio button and prevent focus from reaching unselected
283/// ones.
284class _SkipUnselectedRadioPolicy<T> extends ReadingOrderTraversalPolicy {
285 _SkipUnselectedRadioPolicy(this.radios, this.groupValue);
286 final Set<RadioClient<T>> radios;
287 final T? groupValue;
288
289 bool _radioSelected(RadioClient<T> radio) => radio.radioValue == groupValue;
290
291 @override
292 Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants, FocusNode currentNode) {
293 final Iterable<FocusNode> nodesInReadOrder = super.sortDescendants(descendants, currentNode);
294 RadioClient<T>? selected = radios.firstWhereOrNull(_radioSelected);
295
296 if (selected == null) {
297 // None of the radio are selected. Select the first radio in read order.
298 final Map<FocusNode, RadioClient<T>> radioFocusNodes = <FocusNode, RadioClient<T>>{};
299 for (final RadioClient<T> radio in radios) {
300 radioFocusNodes[radio.focusNode] = radio;
301 }
302
303 for (final FocusNode node in nodesInReadOrder) {
304 selected = radioFocusNodes[node];
305 if (selected != null) {
306 break;
307 }
308 }
309 }
310
311 if (selected == null) {
312 // None of the radio is selected or focusable, defaults to reading order
313 return nodesInReadOrder;
314 }
315
316 // Nodes that are not selected AND not currently focused, since we can't
317 // remove the focused node from the sorted result.
318 final Set<FocusNode> nodeToSkip = radios
319 .where((RadioClient<T> radio) => selected != radio && radio.focusNode != currentNode)
320 .map<FocusNode>((RadioClient<T> radio) => radio.focusNode)
321 .toSet();
322 final Iterable<FocusNode> skipsNonSelected = descendants.where(
323 (FocusNode node) => !nodeToSkip.contains(node),
324 );
325 return super.sortDescendants(skipsNonSelected, currentNode);
326 }
327}
328