| 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 | |
| 5 | import 'dart:ui' show SemanticsRole; |
| 6 | |
| 7 | import 'package:collection/collection.dart' ; |
| 8 | import 'package:flutter/services.dart'; |
| 9 | |
| 10 | import 'actions.dart'; |
| 11 | import 'basic.dart'; |
| 12 | import 'focus_manager.dart'; |
| 13 | import 'focus_traversal.dart'; |
| 14 | import 'framework.dart'; |
| 15 | import '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} |
| 50 | class 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 | |
| 88 | class _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 | |
| 197 | class _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. |
| 216 | abstract 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. |
| 241 | mixin 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. |
| 284 | class _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 | |