// 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/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'color_scheme.dart'; import 'colors.dart'; import 'constants.dart'; import 'debug.dart'; import 'material_state.dart'; import 'radio_theme.dart'; import 'theme.dart'; import 'theme_data.dart'; import 'toggleable.dart'; // Examples can assume: // late BuildContext context; // enum SingingCharacter { lafayette } // late SingingCharacter? _character; // late StateSetter setState; enum _RadioType { material, adaptive } 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} /// 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. /// /// ** See code in examples/api/lib/material/radio/radio.0.dart ** /// {@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({ super.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, }) : _radioType = _RadioType.material, useCupertinoCheckmarkStyle = false; /// Creates an adaptive [Radio] based on whether the target platform is iOS /// or macOS, following Material design's /// [Cross-platform guidelines](https://material.io/design/platform-guidance/cross-platform-adaptation.html). /// /// On iOS and macOS, this constructor creates a [CupertinoRadio], which has /// matching functionality and presentation as Material checkboxes, and are the /// graphics expected on iOS. On other platforms, this creates a Material /// design [Radio]. /// /// If a [CupertinoRadio] is created, the following parameters are ignored: /// [mouseCursor], [fillColor], [hoverColor], [overlayColor], [splashRadius], /// [materialTapTargetSize], [visualDensity]. /// /// [useCupertinoCheckmarkStyle] is used only if a [CupertinoRadio] is created. /// /// The target platform is based on the current [Theme]: [ThemeData.platform]. const Radio.adaptive({ super.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, this.useCupertinoCheckmarkStyle = false }) : _radioType = _RadioType.adaptive; /// 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} /// This example shows how to enable deselecting a radio button by setting the /// [toggleable] attribute. /// /// ** See code in examples/api/lib/material/radio/radio.toggleable.0.dart ** /// {@end-tool} final bool toggleable; /// The color to use when this radio button is selected. /// /// Defaults to [ColorScheme.secondary]. /// /// 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]. /// /// {@tool snippet} /// This example resolves the [fillColor] based on the current [MaterialState] /// of the [Radio], providing a different [Color] when it is /// [MaterialState.disabled]. /// /// ```dart /// Radio<int>( /// value: 1, /// groupValue: 1, /// onChanged: (_){}, /// fillColor: MaterialStateProperty.resolveWith<Color>((Set<MaterialState> states) { /// if (states.contains(MaterialState.disabled)) { /// return Colors.orange.withOpacity(.32); /// } /// return Colors.orange; /// }) /// ) /// ``` /// {@end-tool} /// {@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, [ColorScheme.secondary] 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; /// {@template flutter.material.radio.hoverColor} /// 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. /// {@endtemplate} /// /// 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 radio'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 [ColorScheme.secondary] 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; /// Controls whether the checkmark style is used in an iOS-style radio. /// /// Only usable under the [Radio.adaptive] constructor. If set to true, on /// Apple platforms the radio button will appear as an iOS styled checkmark. /// Controls the [CupertinoRadio] through [CupertinoRadio.useCheckmarkStyle]. /// /// Defaults to false. final bool useCupertinoCheckmarkStyle; final _RadioType _radioType; 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; }); } @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); switch (widget._radioType) { case _RadioType.material: break; case _RadioType.adaptive: final ThemeData theme = Theme.of(context); switch (theme.platform) { case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: break; case TargetPlatform.iOS: case TargetPlatform.macOS: return CupertinoRadio<T>( value: widget.value, groupValue: widget.groupValue, onChanged: widget.onChanged, toggleable: widget.toggleable, activeColor: widget.activeColor, focusColor: widget.focusColor, focusNode: widget.focusNode, autofocus: widget.autofocus, useCheckmarkStyle: widget.useCupertinoCheckmarkStyle, ); } } final RadioThemeData radioTheme = RadioTheme.of(context); final RadioThemeData defaults = Theme.of(context).useMaterial3 ? _RadioDefaultsM3(context) : _RadioDefaultsM2(context); final MaterialTapTargetSize effectiveMaterialTapTargetSize = widget.materialTapTargetSize ?? radioTheme.materialTapTargetSize ?? defaults.materialTapTargetSize!; final VisualDensity effectiveVisualDensity = widget.visualDensity ?? radioTheme.visualDensity ?? defaults.visualDensity!; Size size; switch (effectiveMaterialTapTargetSize) { case MaterialTapTargetSize.padded: size = const Size(kMinInteractiveDimension, kMinInteractiveDimension); case MaterialTapTargetSize.shrinkWrap: size = const Size(kMinInteractiveDimension - 8.0, kMinInteractiveDimension - 8.0); } size += effectiveVisualDensity.baseSizeAdjustment; final MaterialStateProperty<MouseCursor> effectiveMouseCursor = MaterialStateProperty.resolveWith<MouseCursor>((Set<MaterialState> states) { return MaterialStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states) ?? 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? activeColor = widget.fillColor?.resolve(activeStates) ?? _widgetFillColor.resolve(activeStates) ?? radioTheme.fillColor?.resolve(activeStates); final Color effectiveActiveColor = activeColor ?? defaults.fillColor!.resolve(activeStates)!; final Color? inactiveColor = widget.fillColor?.resolve(inactiveStates) ?? _widgetFillColor.resolve(inactiveStates) ?? radioTheme.fillColor?.resolve(inactiveStates); final Color effectiveInactiveColor = inactiveColor ?? defaults.fillColor!.resolve(inactiveStates)!; final Set<MaterialState> focusedStates = states..add(MaterialState.focused); Color effectiveFocusOverlayColor = widget.overlayColor?.resolve(focusedStates) ?? widget.focusColor ?? radioTheme.overlayColor?.resolve(focusedStates) ?? defaults.overlayColor!.resolve(focusedStates)!; final Set<MaterialState> hoveredStates = states..add(MaterialState.hovered); Color effectiveHoverOverlayColor = widget.overlayColor?.resolve(hoveredStates) ?? widget.hoverColor ?? radioTheme.overlayColor?.resolve(hoveredStates) ?? defaults.overlayColor!.resolve(hoveredStates)!; final Set<MaterialState> activePressedStates = activeStates..add(MaterialState.pressed); final Color effectiveActivePressedOverlayColor = widget.overlayColor?.resolve(activePressedStates) ?? radioTheme.overlayColor?.resolve(activePressedStates) ?? activeColor?.withAlpha(kRadialReactionAlpha) ?? defaults.overlayColor!.resolve(activePressedStates)!; final Set<MaterialState> inactivePressedStates = inactiveStates..add(MaterialState.pressed); final Color effectiveInactivePressedOverlayColor = widget.overlayColor?.resolve(inactivePressedStates) ?? radioTheme.overlayColor?.resolve(inactivePressedStates) ?? inactiveColor?.withAlpha(kRadialReactionAlpha) ?? defaults.overlayColor!.resolve(inactivePressedStates)!; if (downPosition != null) { effectiveHoverOverlayColor = states.contains(MaterialState.selected) ? effectiveActivePressedOverlayColor : effectiveInactivePressedOverlayColor; effectiveFocusOverlayColor = states.contains(MaterialState.selected) ? effectiveActivePressedOverlayColor : effectiveInactivePressedOverlayColor; } final bool? accessibilitySelected; // Apple devices also use `selected` to annotate radio button's semantics // state. switch (defaultTargetPlatform) { case TargetPlatform.android: case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: accessibilitySelected = null; case TargetPlatform.iOS: case TargetPlatform.macOS: accessibilitySelected = widget._selected; } return Semantics( inMutuallyExclusiveGroup: true, checked: widget._selected, selected: accessibilitySelected, 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 ?? 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); } } } // Hand coded defaults based on Material Design 2. class _RadioDefaultsM2 extends RadioThemeData { _RadioDefaultsM2(this.context); final BuildContext context; late final ThemeData _theme = Theme.of(context); late final ColorScheme _colors = _theme.colorScheme; @override MaterialStateProperty<Color> get fillColor { return MaterialStateProperty.resolveWith((Set<MaterialState> states) { if (states.contains(MaterialState.disabled)) { return _theme.disabledColor; } if (states.contains(MaterialState.selected)) { return _colors.secondary; } return _theme.unselectedWidgetColor; }); } @override MaterialStateProperty<Color> get overlayColor { return MaterialStateProperty.resolveWith((Set<MaterialState> states) { if (states.contains(MaterialState.pressed)) { return fillColor.resolve(states).withAlpha(kRadialReactionAlpha); } if (states.contains(MaterialState.hovered)) { return _theme.hoverColor; } if (states.contains(MaterialState.focused)) { return _theme.focusColor; } return Colors.transparent; }); } @override MaterialTapTargetSize get materialTapTargetSize => _theme.materialTapTargetSize; @override VisualDensity get visualDensity => _theme.visualDensity; } // BEGIN GENERATED TOKEN PROPERTIES - Radio<T> // Do not edit by hand. The code between the "BEGIN GENERATED" and // "END GENERATED" comments are generated from data in the Material // Design token database by the script: // dev/tools/gen_defaults/bin/gen_defaults.dart. class _RadioDefaultsM3 extends RadioThemeData { _RadioDefaultsM3(this.context); final BuildContext context; late final ThemeData _theme = Theme.of(context); late final ColorScheme _colors = _theme.colorScheme; @override MaterialStateProperty<Color> get fillColor { return MaterialStateProperty.resolveWith((Set<MaterialState> states) { if (states.contains(MaterialState.selected)) { if (states.contains(MaterialState.disabled)) { return _colors.onSurface.withOpacity(0.38); } if (states.contains(MaterialState.pressed)) { return _colors.primary; } if (states.contains(MaterialState.hovered)) { return _colors.primary; } if (states.contains(MaterialState.focused)) { return _colors.primary; } return _colors.primary; } if (states.contains(MaterialState.disabled)) { return _colors.onSurface.withOpacity(0.38); } if (states.contains(MaterialState.pressed)) { return _colors.onSurface; } if (states.contains(MaterialState.hovered)) { return _colors.onSurface; } if (states.contains(MaterialState.focused)) { return _colors.onSurface; } return _colors.onSurfaceVariant; }); } @override MaterialStateProperty<Color> get overlayColor { return MaterialStateProperty.resolveWith((Set<MaterialState> states) { if (states.contains(MaterialState.selected)) { if (states.contains(MaterialState.pressed)) { return _colors.onSurface.withOpacity(0.12); } if (states.contains(MaterialState.hovered)) { return _colors.primary.withOpacity(0.08); } if (states.contains(MaterialState.focused)) { return _colors.primary.withOpacity(0.12); } return Colors.transparent; } if (states.contains(MaterialState.pressed)) { return _colors.primary.withOpacity(0.12); } if (states.contains(MaterialState.hovered)) { return _colors.onSurface.withOpacity(0.08); } if (states.contains(MaterialState.focused)) { return _colors.onSurface.withOpacity(0.12); } return Colors.transparent; }); } @override MaterialTapTargetSize get materialTapTargetSize => _theme.materialTapTargetSize; @override VisualDensity get visualDensity => _theme.visualDensity; } // END GENERATED TOKEN PROPERTIES - Radio<T>