// 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 'package:flutter/widgets.dart'; import 'constants.dart'; import 'debug.dart'; import 'material_state.dart'; import 'theme.dart'; import 'theme_data.dart'; import 'toggleable.dart'; const double _kOuterRadius = 8.0; const double _kInnerRadius = 4.5; /// A material design radio button. /// /// Used to select between a number of mutually exclusive values. When one radio /// button in a group is selected, the other radio buttons in the group cease to /// be selected. The values are of type `T`, the type parameter of the [Radio] /// class. Enums are commonly used for this purpose. /// /// The radio button itself does not maintain any state. Instead, selecting the /// radio invokes the [onChanged] callback, passing [value] as a parameter. If /// [groupValue] and [value] match, this radio will be selected. Most widgets /// will respond to [onChanged] by calling [State.setState] to update the /// radio button's [groupValue]. /// /// {@tool dartpad --template=stateful_widget_scaffold_center} /// /// Here is an example of Radio widgets wrapped in ListTiles, which is similar /// to what you could get with the RadioListTile widget. /// /// The currently selected character is passed into `groupValue`, which is /// maintained by the example's `State`. In this case, the first `Radio` /// will start off selected because `_character` is initialized to /// `SingingCharacter.lafayette`. /// /// If the second radio button is pressed, the example's state is updated /// with `setState`, updating `_character` to `SingingCharacter.jefferson`. /// This causes the buttons to rebuild with the updated `groupValue`, and /// therefore the selection of the second button. /// /// Requires one of its ancestors to be a [Material] widget. /// /// ```dart preamble /// enum SingingCharacter { lafayette, jefferson } /// ``` /// /// ```dart /// SingingCharacter? _character = SingingCharacter.lafayette; /// /// @override /// Widget build(BuildContext context) { /// return Column( /// children: <Widget>[ /// ListTile( /// title: const Text('Lafayette'), /// leading: Radio<SingingCharacter>( /// value: SingingCharacter.lafayette, /// groupValue: _character, /// onChanged: (SingingCharacter? value) { /// setState(() { _character = value; }); /// }, /// ), /// ), /// ListTile( /// title: const Text('Thomas Jefferson'), /// leading: Radio<SingingCharacter>( /// value: SingingCharacter.jefferson, /// groupValue: _character, /// onChanged: (SingingCharacter? value) { /// setState(() { _character = value; }); /// }, /// ), /// ), /// ], /// ); /// } /// ``` /// {@end-tool} /// /// See also: /// /// * [RadioListTile], which combines this widget with a [ListTile] so that /// you can give the radio button a label. /// * [Slider], for selecting a value in a range. /// * [Checkbox] and [Switch], for toggling a particular value on or off. /// * <https://material.io/design/components/selection-controls.html#radio-buttons> class Radio<T> extends StatefulWidget { /// Creates a material design radio button. /// /// The radio button itself does not maintain any state. Instead, when the /// radio button is selected, the widget calls the [onChanged] callback. Most /// widgets that use a radio button will listen for the [onChanged] callback /// and rebuild the radio button with a new [groupValue] to update the visual /// appearance of the radio button. /// /// The following arguments are required: /// /// * [value] and [groupValue] together determine whether the radio button is /// selected. /// * [onChanged] is called when the user selects this radio button. const Radio({ Key? key, required this.value, required this.groupValue, required this.onChanged, this.mouseCursor, this.toggleable = false, this.activeColor, this.fillColor, this.focusColor, this.hoverColor, this.overlayColor, this.splashRadius, this.materialTapTargetSize, this.visualDensity, this.focusNode, this.autofocus = false, }) : assert(autofocus != null), assert(toggleable != null), super(key: key); /// The value represented by this radio button. final T value; /// The currently selected value for a group of radio buttons. /// /// This radio button is considered selected if its [value] matches the /// [groupValue]. final T? groupValue; /// Called when the user selects this radio button. /// /// The radio button passes [value] as a parameter to this callback. The radio /// button does not actually change state until the parent widget rebuilds the /// radio button with the new [groupValue]. /// /// If null, the radio button will be displayed as disabled. /// /// The provided callback will not be invoked if this radio button is already /// selected. /// /// The callback provided to [onChanged] should update the state of the parent /// [StatefulWidget] using the [State.setState] method, so that the parent /// gets rebuilt; for example: /// /// ```dart /// Radio<SingingCharacter>( /// value: SingingCharacter.lafayette, /// groupValue: _character, /// onChanged: (SingingCharacter newValue) { /// setState(() { /// _character = newValue; /// }); /// }, /// ) /// ``` final ValueChanged<T?>? onChanged; /// {@template flutter.material.radio.mouseCursor} /// The cursor for a mouse pointer when it enters or is hovering over the /// widget. /// /// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>], /// [MaterialStateProperty.resolve] is used for the following [MaterialState]s: /// /// * [MaterialState.selected]. /// * [MaterialState.hovered]. /// * [MaterialState.focused]. /// * [MaterialState.disabled]. /// {@endtemplate} /// /// If null, then the value of [RadioThemeData.mouseCursor] is used. /// If that is also null, then [MaterialStateMouseCursor.clickable] is used. /// /// See also: /// /// * [MaterialStateMouseCursor], a [MouseCursor] that implements /// `MaterialStateProperty` which is used in APIs that need to accept /// either a [MouseCursor] or a [MaterialStateProperty<MouseCursor>]. final MouseCursor? mouseCursor; /// Set to true if this radio button is allowed to be returned to an /// indeterminate state by selecting it again when selected. /// /// To indicate returning to an indeterminate state, [onChanged] will be /// called with null. /// /// If true, [onChanged] can be called with [value] when selected while /// [groupValue] != [value], or with null when selected again while /// [groupValue] == [value]. /// /// If false, [onChanged] will be called with [value] when it is selected /// while [groupValue] != [value], and only by selecting another radio button /// in the group (i.e. changing the value of [groupValue]) can this radio /// button be unselected. /// /// The default is false. /// /// {@tool dartpad --template=stateful_widget_scaffold} /// This example shows how to enable deselecting a radio button by setting the /// [toggleable] attribute. /// /// ```dart /// int? groupValue; /// static const List<String> selections = <String>[ /// 'Hercules Mulligan', /// 'Eliza Hamilton', /// 'Philip Schuyler', /// 'Maria Reynolds', /// 'Samuel Seabury', /// ]; /// /// @override /// Widget build(BuildContext context) { /// return Scaffold( /// body: ListView.builder( /// itemBuilder: (BuildContext context, int index) { /// return Row( /// mainAxisSize: MainAxisSize.min, /// crossAxisAlignment: CrossAxisAlignment.center, /// children: <Widget>[ /// Radio<int>( /// value: index, /// groupValue: groupValue, /// // TRY THIS: Try setting the toggleable value to false and /// // see how that changes the behavior of the widget. /// toggleable: true, /// onChanged: (int? value) { /// setState(() { /// groupValue = value; /// }); /// }), /// Text(selections[index]), /// ], /// ); /// }, /// itemCount: selections.length, /// ), /// ); /// } /// ``` /// {@end-tool} final bool toggleable; /// The color to use when this radio button is selected. /// /// Defaults to [ThemeData.toggleableActiveColor]. /// /// If [fillColor] returns a non-null color in the [MaterialState.selected] /// state, it will be used instead of this color. final Color? activeColor; /// {@template flutter.material.radio.fillColor} /// The color that fills the radio button, in all [MaterialState]s. /// /// Resolves in the following states: /// * [MaterialState.selected]. /// * [MaterialState.hovered]. /// * [MaterialState.focused]. /// * [MaterialState.disabled]. /// {@endtemplate} /// /// If null, then the value of [activeColor] is used in the selected state. If /// that is also null, then the value of [RadioThemeData.fillColor] is used. /// If that is also null, then [ThemeData.disabledColor] is used in /// the disabled state, [ThemeData.toggleableActiveColor] is used in the /// selected state, and [ThemeData.unselectedWidgetColor] is used in the /// default state. final MaterialStateProperty<Color?>? fillColor; /// {@template flutter.material.radio.materialTapTargetSize} /// Configures the minimum size of the tap target. /// {@endtemplate} /// /// If null, then the value of [RadioThemeData.materialTapTargetSize] is used. /// If that is also null, then the value of [ThemeData.materialTapTargetSize] /// is used. /// /// See also: /// /// * [MaterialTapTargetSize], for a description of how this affects tap targets. final MaterialTapTargetSize? materialTapTargetSize; /// {@template flutter.material.radio.visualDensity} /// Defines how compact the radio's layout will be. /// {@endtemplate} /// /// {@macro flutter.material.themedata.visualDensity} /// /// If null, then the value of [RadioThemeData.visualDensity] is used. If that /// is also null, then the value of [ThemeData.visualDensity] is used. /// /// See also: /// /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all /// widgets within a [Theme]. final VisualDensity? visualDensity; /// The color for the radio's [Material] when it has the input focus. /// /// If [overlayColor] returns a non-null color in the [MaterialState.focused] /// state, it will be used instead. /// /// If null, then the value of [RadioThemeData.overlayColor] is used in the /// focused state. If that is also null, then the value of /// [ThemeData.focusColor] is used. final Color? focusColor; /// The color for the radio's [Material] when a pointer is hovering over it. /// /// If [overlayColor] returns a non-null color in the [MaterialState.hovered] /// state, it will be used instead. /// /// If null, then the value of [RadioThemeData.overlayColor] is used in the /// hovered state. If that is also null, then the value of /// [ThemeData.hoverColor] is used. final Color? hoverColor; /// {@template flutter.material.radio.overlayColor} /// The color for the checkbox's [Material]. /// /// Resolves in the following states: /// * [MaterialState.pressed]. /// * [MaterialState.selected]. /// * [MaterialState.hovered]. /// * [MaterialState.focused]. /// {@endtemplate} /// /// If null, then the value of [activeColor] with alpha /// [kRadialReactionAlpha], [focusColor] and [hoverColor] is used in the /// pressed, focused and hovered state. If that is also null, /// the value of [RadioThemeData.overlayColor] is used. If that is also null, /// then the value of [ThemeData.toggleableActiveColor] with alpha /// [kRadialReactionAlpha], [ThemeData.focusColor] and [ThemeData.hoverColor] /// is used in the pressed, focused and hovered state. final MaterialStateProperty<Color?>? overlayColor; /// {@template flutter.material.radio.splashRadius} /// The splash radius of the circular [Material] ink response. /// {@endtemplate} /// /// If null, then the value of [RadioThemeData.splashRadius] is used. If that /// is also null, then [kRadialReactionRadius] is used. final double? splashRadius; /// {@macro flutter.widgets.Focus.focusNode} final FocusNode? focusNode; /// {@macro flutter.widgets.Focus.autofocus} final bool autofocus; bool get _selected => value == groupValue; @override State<Radio<T>> createState() => _RadioState<T>(); } class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin, ToggleableStateMixin { final _RadioPainter _painter = _RadioPainter(); void _handleChanged(bool? selected) { if (selected == null) { widget.onChanged!(null); return; } if (selected) { widget.onChanged!(widget.value); } } @override void didUpdateWidget(Radio<T> oldWidget) { super.didUpdateWidget(oldWidget); if (widget._selected != oldWidget._selected) { animateToValue(); } } @override void dispose() { _painter.dispose(); super.dispose(); } @override ValueChanged<bool?>? get onChanged => widget.onChanged != null ? _handleChanged : null; @override bool get tristate => widget.toggleable; @override bool? get value => widget._selected; MaterialStateProperty<Color?> get _widgetFillColor { return MaterialStateProperty.resolveWith((Set<MaterialState> states) { if (states.contains(MaterialState.disabled)) { return null; } if (states.contains(MaterialState.selected)) { return widget.activeColor; } return null; }); } MaterialStateProperty<Color> get _defaultFillColor { final ThemeData themeData = Theme.of(context); return MaterialStateProperty.resolveWith((Set<MaterialState> states) { if (states.contains(MaterialState.disabled)) { return themeData.disabledColor; } if (states.contains(MaterialState.selected)) { return themeData.toggleableActiveColor; } return themeData.unselectedWidgetColor; }); } @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); final ThemeData themeData = Theme.of(context); final MaterialTapTargetSize effectiveMaterialTapTargetSize = widget.materialTapTargetSize ?? themeData.radioTheme.materialTapTargetSize ?? themeData.materialTapTargetSize; final VisualDensity effectiveVisualDensity = widget.visualDensity ?? themeData.radioTheme.visualDensity ?? themeData.visualDensity; Size size; switch (effectiveMaterialTapTargetSize) { case MaterialTapTargetSize.padded: size = const Size(kMinInteractiveDimension, kMinInteractiveDimension); break; case MaterialTapTargetSize.shrinkWrap: size = const Size(kMinInteractiveDimension - 8.0, kMinInteractiveDimension - 8.0); break; } size += effectiveVisualDensity.baseSizeAdjustment; final MaterialStateProperty<MouseCursor> effectiveMouseCursor = MaterialStateProperty.resolveWith<MouseCursor>((Set<MaterialState> states) { return MaterialStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states) ?? themeData.radioTheme.mouseCursor?.resolve(states) ?? MaterialStateProperty.resolveAs<MouseCursor>(MaterialStateMouseCursor.clickable, states); }); // Colors need to be resolved in selected and non selected states separately // so that they can be lerped between. final Set<MaterialState> activeStates = states..add(MaterialState.selected); final Set<MaterialState> inactiveStates = states..remove(MaterialState.selected); final Color effectiveActiveColor = widget.fillColor?.resolve(activeStates) ?? _widgetFillColor.resolve(activeStates) ?? themeData.radioTheme.fillColor?.resolve(activeStates) ?? _defaultFillColor.resolve(activeStates); final Color effectiveInactiveColor = widget.fillColor?.resolve(inactiveStates) ?? _widgetFillColor.resolve(inactiveStates) ?? themeData.radioTheme.fillColor?.resolve(inactiveStates) ?? _defaultFillColor.resolve(inactiveStates); final Set<MaterialState> focusedStates = states..add(MaterialState.focused); final Color effectiveFocusOverlayColor = widget.overlayColor?.resolve(focusedStates) ?? widget.focusColor ?? themeData.radioTheme.overlayColor?.resolve(focusedStates) ?? themeData.focusColor; final Set<MaterialState> hoveredStates = states..add(MaterialState.hovered); final Color effectiveHoverOverlayColor = widget.overlayColor?.resolve(hoveredStates) ?? widget.hoverColor ?? themeData.radioTheme.overlayColor?.resolve(hoveredStates) ?? themeData.hoverColor; final Set<MaterialState> activePressedStates = activeStates..add(MaterialState.pressed); final Color effectiveActivePressedOverlayColor = widget.overlayColor?.resolve(activePressedStates) ?? themeData.radioTheme.overlayColor?.resolve(activePressedStates) ?? effectiveActiveColor.withAlpha(kRadialReactionAlpha); final Set<MaterialState> inactivePressedStates = inactiveStates..add(MaterialState.pressed); final Color effectiveInactivePressedOverlayColor = widget.overlayColor?.resolve(inactivePressedStates) ?? themeData.radioTheme.overlayColor?.resolve(inactivePressedStates) ?? effectiveActiveColor.withAlpha(kRadialReactionAlpha); return Semantics( inMutuallyExclusiveGroup: true, checked: widget._selected, child: buildToggleable( focusNode: widget.focusNode, autofocus: widget.autofocus, mouseCursor: effectiveMouseCursor, size: size, painter: _painter ..position = position ..reaction = reaction ..reactionFocusFade = reactionFocusFade ..reactionHoverFade = reactionHoverFade ..inactiveReactionColor = effectiveInactivePressedOverlayColor ..reactionColor = effectiveActivePressedOverlayColor ..hoverColor = effectiveHoverOverlayColor ..focusColor = effectiveFocusOverlayColor ..splashRadius = widget.splashRadius ?? themeData.radioTheme.splashRadius ?? kRadialReactionRadius ..downPosition = downPosition ..isFocused = states.contains(MaterialState.focused) ..isHovered = states.contains(MaterialState.hovered) ..activeColor = effectiveActiveColor ..inactiveColor = effectiveInactiveColor, ), ); } } class _RadioPainter extends ToggleablePainter { @override void paint(Canvas canvas, Size size) { paintRadialReaction(canvas: canvas, origin: size.center(Offset.zero)); final Offset center = (Offset.zero & size).center; // Outer circle final Paint paint = Paint() ..color = Color.lerp(inactiveColor, activeColor, position.value)! ..style = PaintingStyle.stroke ..strokeWidth = 2.0; canvas.drawCircle(center, _kOuterRadius, paint); // Inner circle if (!position.isDismissed) { paint.style = PaintingStyle.fill; canvas.drawCircle(center, _kInnerRadius * position.value, paint); } } }