blob: 4440878b02d79725494e8e6d1eecced07ba83db1 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' show SemanticsRole;
import 'package:collection/collection.dart';
import 'package:flutter/services.dart';
import 'actions.dart';
import 'basic.dart';
import 'binding.dart';
import 'focus_manager.dart';
import 'focus_traversal.dart';
import 'framework.dart';
import 'shortcuts.dart';
/// A group for radios.
///
/// This widget treats all radios, such as [RawRadio], [Radio], [CupertinoRadio]
/// in the sub tree with the same type T as a group. Radios with different types
/// are not included in the group.
///
/// This widget handles the group value for the radios in the subtree with the
/// same value type.
///
/// Using this widget also provides keyboard navigation and semantics for the
/// radio buttons that matches [APG](https://www.w3.org/WAI/ARIA/apg/patterns/radio/).
///
/// The keyboard behaviors are:
/// * Tab and Shift+Tab: moves focus into and out of radio group. When focus
/// moves into a radio group and a radio button is select, focus is set on
/// selected button. Otherwise, it focus the first radio button in reading
/// order.
/// * Space: toggle the selection on the focused radio button.
/// * Right and down arrow key: move selection to next radio button in the group
/// in reading order.
/// * Left and up arrow key: move selection to previous radio button in the
/// group in reading order.
///
/// Arrow keys will wrap around if it reach the first or last radio in the
/// group.
///
/// {@tool dartpad}
/// Here is an example of RadioGroup widget.
///
/// Try using tab, arrow keys, and space to see how the widget responds.
///
/// ** See code in examples/api/lib/widgets/radio_group/radio_group.0.dart **
/// {@end-tool}
class RadioGroup<T> extends StatefulWidget {
/// Creates a radio group.
///
/// The `groupValue` set the selection on a subtree radio with the same
/// [RawRadio.value].
///
/// The `onChanged` is called when the selection has changed in the subtree
/// radios.
const RadioGroup({super.key, this.groupValue, required this.onChanged, required this.child});
/// The selected value under this radio group.
///
/// [RawRadio] under this radio group where its [RawRadio.value] equals to this
/// value will be selected.
final T? groupValue;
/// Called when selection has changed.
///
/// The value can be null when unselect the [RawRadio] with
/// [RawRadio.toggleable] set to true.
final ValueChanged<T?> onChanged;
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
/// Gets the [RadioGroupRegistry] from the above the context.
///
/// This registers a dependencies on the context that it causes rebuild
/// if [RadioGroupRegistry] has changed or its
/// [RadioGroupRegistry.groupValue] has changed.
static RadioGroupRegistry<T>? maybeOf<T>(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<_RadioGroupStateScope<T>>()?.state;
}
@override
State<StatefulWidget> createState() => _RadioGroupState<T>();
}
class _RadioGroupState<T> extends State<RadioGroup<T>> implements RadioGroupRegistry<T> {
late final Map<ShortcutActivator, Intent> _radioGroupShortcuts = <ShortcutActivator, Intent>{
const SingleActivator(LogicalKeyboardKey.arrowLeft): VoidCallbackIntent(_selectPreviousRadio),
const SingleActivator(LogicalKeyboardKey.arrowRight): VoidCallbackIntent(_selectNextRadio),
const SingleActivator(LogicalKeyboardKey.arrowDown): VoidCallbackIntent(_selectNextRadio),
const SingleActivator(LogicalKeyboardKey.arrowUp): VoidCallbackIntent(_selectPreviousRadio),
const SingleActivator(LogicalKeyboardKey.space): VoidCallbackIntent(_toggleFocusedRadio),
};
late final _RadioGroupShortcutManager<T> _radioGroupShortcutManager =
_RadioGroupShortcutManager<T>(shortcuts: _radioGroupShortcuts, state: this);
final Set<RadioClient<T>> _radios = <RadioClient<T>>{};
bool _debugHasScheduledSingleSelectionCheck = false;
/// Schedules a check for the next frame to verify that there is only one
/// radio with the group value.
bool _debugScheduleSingleSelectionCheck() {
if (_debugHasScheduledSingleSelectionCheck) {
return true;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_debugHasScheduledSingleSelectionCheck = false;
if (!mounted || _debugCheckOnlySingleSelection()) {
return;
}
throw FlutterError(
"RadioGroupPolicy can't be used for a radio group that allows multiple selection.",
);
}, debugLabel: 'RadioGroup.singleSelectionCheck');
_debugHasScheduledSingleSelectionCheck = true;
return true;
}
bool _debugCheckOnlySingleSelection() {
return _radios.where((RadioClient<T> radio) => radio.radioValue == groupValue).length < 2;
}
@override
T? get groupValue => widget.groupValue;
@override
void dispose() {
_radioGroupShortcutManager.dispose();
super.dispose();
}
@override
void registerClient(RadioClient<T> radio) {
_radios.add(radio);
assert(_debugScheduleSingleSelectionCheck());
}
@override
void unregisterClient(RadioClient<T> radio) => _radios.remove(radio);
void _toggleFocusedRadio() {
final RadioClient<T>? radio = _radios.firstWhereOrNull(
(RadioClient<T> radio) => radio.focusNode.hasFocus,
);
if (radio == null) {
return;
}
if (radio.radioValue != widget.groupValue) {
onChanged(radio.radioValue);
return;
}
if (radio.tristate) {
onChanged(null);
}
}
@override
ValueChanged<T?> get onChanged => widget.onChanged;
void _selectNextRadio() => _selectRadioInDirection(true);
void _selectPreviousRadio() => _selectRadioInDirection(false);
void _selectRadioInDirection(bool forward) {
if (_radios.length < 2) {
return;
}
final FocusNode? currentFocus = _radios
.firstWhereOrNull((RadioClient<T> radio) => radio.focusNode.hasFocus)
?.focusNode;
if (currentFocus == null) {
// The focused node is either a non interactive radio or other controls.
return;
}
final List<FocusNode> sorted = ReadingOrderTraversalPolicy.sort(
_radios
.where((RadioClient<T> radio) => radio.enabled)
.map<FocusNode>((RadioClient<T> radio) => radio.focusNode),
).toList();
assert(sorted.isNotEmpty);
final Iterable<FocusNode> nodesInEffectiveOrder = forward ? sorted : sorted.reversed;
final Iterator<FocusNode> iterator = nodesInEffectiveOrder.iterator;
FocusNode? nextFocus;
while (iterator.moveNext()) {
if (iterator.current == currentFocus) {
if (iterator.moveNext()) {
nextFocus = iterator.current;
}
break;
}
}
// Current focus is at the end, the next focus should wrap around.
nextFocus ??= nodesInEffectiveOrder.first;
final RadioClient<T> radioToSelect = _radios.firstWhere(
(RadioClient<T> radio) => radio.focusNode == nextFocus,
);
onChanged(radioToSelect.radioValue);
nextFocus.requestFocus();
}
@override
Widget build(BuildContext context) {
assert(_debugScheduleSingleSelectionCheck());
return Semantics(
container: true,
role: SemanticsRole.radioGroup,
child: Shortcuts.manager(
manager: _radioGroupShortcutManager,
child: FocusTraversalGroup(
policy: _SkipUnselectedRadioPolicy<T>(_radios, widget.groupValue),
child: _RadioGroupStateScope<T>(
state: this,
groupValue: widget.groupValue,
child: widget.child,
),
),
),
);
}
}
class _RadioGroupShortcutManager<T> extends ShortcutManager {
_RadioGroupShortcutManager({required super.shortcuts, required this.state});
final _RadioGroupState<T> state;
@override
KeyEventResult handleKeypress(BuildContext context, KeyEvent event) {
final bool radioHasFocus = state._radios.any(
(RadioClient<T> radio) => radio.focusNode.hasFocus,
);
if (!radioHasFocus) {
// Ignore the event if no radio is focused. This prevents this handler
// from unintentionally consuming an event meant for a non-radio widget
// that currently has focus.
return KeyEventResult.ignored;
}
return super.handleKeypress(context, event);
}
}
class _RadioGroupStateScope<T> extends InheritedWidget {
const _RadioGroupStateScope({required this.state, required this.groupValue, required super.child})
: super();
final _RadioGroupState<T> state;
// Need to include group value to notify listener when group value changes.
final T? groupValue;
@override
bool updateShouldNotify(covariant _RadioGroupStateScope<T> oldWidget) {
return state != oldWidget.state || groupValue != oldWidget.groupValue;
}
}
/// An abstract interface for registering a group of radios.
///
/// Use [registerClient] or [unregisterClient] to handle registrations of radios.
///
/// The registry manages the group value for the radios. The radio needs to call
/// [onChanged] to notify the group value needs to be changed.
abstract class RadioGroupRegistry<T> {
/// The group value for the group.
T? get groupValue;
/// Registers a radio client.
///
/// The subclass provides additional features, such as keyboard navigation
/// for the registered clients.
void registerClient(RadioClient<T> radio);
/// Unregisters a radio client.
void unregisterClient(RadioClient<T> radio);
/// Notifies the registry that the a radio is selected or unselected.
ValueChanged<T?> get onChanged;
}
/// A client for a [RadioGroupRegistry].
///
/// This is typically mixed with a [State].
///
/// To register to a [RadioGroupRegistry], assign the registry to [registry].
///
/// To unregister from previous [RadioGroupRegistry], either assign a different
/// value to [registry] or set it to null.
mixin RadioClient<T> {
/// Whether this radio support toggles.
///
/// Used by registry to provide additional feature such as keyboard support.
bool get tristate;
/// This value this radio represents.
///
/// Used by registry to provide additional feature such as keyboard support.
T get radioValue;
/// Whether this radio is enabled.
///
/// If false, the registry skips this client when handling keyboard
/// navigation.
bool get enabled;
/// Focus node for this radio.
///
/// Used by registry to provide additional feature such as keyboard support.
FocusNode get focusNode;
/// The [RadioGroupRegistry] this client register to.
///
/// Setting this property automatically register to the new value and
/// unregister the old value.
///
/// This should set to null when dispose.
RadioGroupRegistry<T>? get registry => _registry;
RadioGroupRegistry<T>? _registry;
set registry(RadioGroupRegistry<T>? newRegistry) {
if (_registry != newRegistry) {
_registry?.unregisterClient(this);
}
_registry = newRegistry;
_registry?.registerClient(this);
}
}
/// A traversal policy that is the same as [ReadingOrderTraversalPolicy] except
/// it skips nodes of unselected radio button if there is one selected radio
/// button.
///
/// If none of the radio is selected, this defaults to
/// [ReadingOrderTraversalPolicy] for all nodes.
///
/// This policy is to ensure when tabbing into a radio group, it will only focus
/// the current selected radio button and prevent focus from reaching unselected
/// ones.
class _SkipUnselectedRadioPolicy<T> extends ReadingOrderTraversalPolicy {
_SkipUnselectedRadioPolicy(this.radios, this.groupValue);
final Set<RadioClient<T>> radios;
final T? groupValue;
bool _radioSelected(RadioClient<T> radio) => radio.radioValue == groupValue;
@override
Iterable<FocusNode> sortDescendants(Iterable<FocusNode> descendants, FocusNode currentNode) {
final Iterable<FocusNode> nodesInReadOrder = super.sortDescendants(descendants, currentNode);
RadioClient<T>? selected = radios.firstWhereOrNull(_radioSelected);
if (selected == null) {
// None of the radio are selected. Select the first radio in read order.
final radioFocusNodes = <FocusNode, RadioClient<T>>{};
for (final RadioClient<T> radio in radios) {
radioFocusNodes[radio.focusNode] = radio;
}
for (final node in nodesInReadOrder) {
selected = radioFocusNodes[node];
if (selected != null) {
break;
}
}
}
if (selected == null) {
// None of the radio is selected or focusable, defaults to reading order
return nodesInReadOrder;
}
// Nodes that are not selected AND not currently focused, since we can't
// remove the focused node from the sorted result.
final Set<FocusNode> nodeToSkip = radios
.where((RadioClient<T> radio) => selected != radio && radio.focusNode != currentNode)
.map<FocusNode>((RadioClient<T> radio) => radio.focusNode)
.toSet();
final Iterable<FocusNode> skipsNonSelected = descendants.where(
(FocusNode node) => !nodeToSkip.contains(node),
);
return super.sortDescendants(skipsNonSelected, currentNode);
}
}