radio.dart 17.7 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'package:flutter/widgets.dart';
6

7
import 'constants.dart';
8
import 'debug.dart';
9
import 'material_state.dart';
10
import 'radio_theme.dart';
11
import 'theme.dart';
12
import 'theme_data.dart';
13
import 'toggleable.dart';
14

15 16
const double _kOuterRadius = 8.0;
const double _kInnerRadius = 4.5;
17

18
/// A Material Design radio button.
19
///
20 21 22 23
/// 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.
24
///
25 26 27 28 29 30
/// 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].
///
31
/// {@tool dartpad}
32 33 34 35 36 37 38 39 40 41 42 43 44
/// 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.
///
45 46
/// Requires one of its ancestors to be a [Material] widget.
///
47
/// ** See code in examples/api/lib/material/radio/radio.0.dart **
48
/// {@end-tool}
49 50
///
/// See also:
51
///
52 53
///  * [RadioListTile], which combines this widget with a [ListTile] so that
///    you can give the radio button a label.
54 55
///  * [Slider], for selecting a value in a range.
///  * [Checkbox] and [Switch], for toggling a particular value on or off.
56
///  * <https://material.io/design/components/selection-controls.html#radio-buttons>
57
class Radio<T> extends StatefulWidget {
58
  /// Creates a Material Design radio button.
59
  ///
60 61 62 63 64
  /// 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.
65
  ///
66 67 68 69 70
  /// 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.
71
  const Radio({
72
    super.key,
73 74 75
    required this.value,
    required this.groupValue,
    required this.onChanged,
76
    this.mouseCursor,
77
    this.toggleable = false,
78
    this.activeColor,
79
    this.fillColor,
80 81
    this.focusColor,
    this.hoverColor,
82
    this.overlayColor,
83
    this.splashRadius,
84
    this.materialTapTargetSize,
85
    this.visualDensity,
86 87 88
    this.focusNode,
    this.autofocus = false,
  }) : assert(autofocus != null),
89
       assert(toggleable != null);
90

91
  /// The value represented by this radio button.
Hixie's avatar
Hixie committed
92
  final T value;
93

94
  /// The currently selected value for a group of radio buttons.
95 96 97
  ///
  /// This radio button is considered selected if its [value] matches the
  /// [groupValue].
98
  final T? groupValue;
99 100 101

  /// Called when the user selects this radio button.
  ///
102 103 104 105 106
  /// 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.
107
  ///
108 109 110
  /// The provided callback will not be invoked if this radio button is already
  /// selected.
  ///
111
  /// The callback provided to [onChanged] should update the state of the parent
112 113 114 115
  /// [StatefulWidget] using the [State.setState] method, so that the parent
  /// gets rebuilt; for example:
  ///
  /// ```dart
116
  /// Radio<SingingCharacter>(
117 118 119 120 121 122 123
  ///   value: SingingCharacter.lafayette,
  ///   groupValue: _character,
  ///   onChanged: (SingingCharacter newValue) {
  ///     setState(() {
  ///       _character = newValue;
  ///     });
  ///   },
124
  /// )
125
  /// ```
126
  final ValueChanged<T?>? onChanged;
127

128
  /// {@template flutter.material.radio.mouseCursor}
129 130 131 132 133 134 135 136 137 138
  /// 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].
139
  /// {@endtemplate}
140
  ///
141 142 143 144 145 146 147 148
  /// 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>].
149
  final MouseCursor? mouseCursor;
150

151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167
  /// 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.
  ///
168
  /// {@tool dartpad}
169 170 171
  /// This example shows how to enable deselecting a radio button by setting the
  /// [toggleable] attribute.
  ///
172
  /// ** See code in examples/api/lib/material/radio/radio.toggleable.0.dart **
173 174 175
  /// {@end-tool}
  final bool toggleable;

176 177
  /// The color to use when this radio button is selected.
  ///
178
  /// Defaults to [ThemeData.toggleableActiveColor].
179 180 181
  ///
  /// If [fillColor] returns a non-null color in the [MaterialState.selected]
  /// state, it will be used instead of this color.
182
  final Color? activeColor;
183

184 185
  /// {@template flutter.material.radio.fillColor}
  /// The color that fills the radio button, in all [MaterialState]s.
186 187 188 189 190 191
  ///
  /// Resolves in the following states:
  ///  * [MaterialState.selected].
  ///  * [MaterialState.hovered].
  ///  * [MaterialState.focused].
  ///  * [MaterialState.disabled].
192 193 194 195 196 197 198 199 200 201 202
  ///
  /// {@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: (_){},
203
  ///   fillColor: MaterialStateProperty.resolveWith<Color>((Set<MaterialState> states) {
204 205 206 207 208 209 210 211
  ///     if (states.contains(MaterialState.disabled)) {
  ///       return Colors.orange.withOpacity(.32);
  ///     }
  ///     return Colors.orange;
  ///   })
  /// )
  /// ```
  /// {@end-tool}
212 213 214 215 216 217 218 219
  /// {@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.
220 221
  final MaterialStateProperty<Color?>? fillColor;

222
  /// {@template flutter.material.radio.materialTapTargetSize}
223
  /// Configures the minimum size of the tap target.
224
  /// {@endtemplate}
225
  ///
226 227 228
  /// If null, then the value of [RadioThemeData.materialTapTargetSize] is used.
  /// If that is also null, then the value of [ThemeData.materialTapTargetSize]
  /// is used.
229 230 231
  ///
  /// See also:
  ///
232
  ///  * [MaterialTapTargetSize], for a description of how this affects tap targets.
233
  final MaterialTapTargetSize? materialTapTargetSize;
234

235
  /// {@template flutter.material.radio.visualDensity}
236
  /// Defines how compact the radio's layout will be.
237
  /// {@endtemplate}
238 239 240
  ///
  /// {@macro flutter.material.themedata.visualDensity}
  ///
241 242 243
  /// If null, then the value of [RadioThemeData.visualDensity] is used. If that
  /// is also null, then the value of [ThemeData.visualDensity] is used.
  ///
244 245
  /// See also:
  ///
246 247
  ///  * [ThemeData.visualDensity], which specifies the [visualDensity] for all
  ///    widgets within a [Theme].
248
  final VisualDensity? visualDensity;
249

250
  /// The color for the radio's [Material] when it has the input focus.
251
  ///
252 253 254
  /// If [overlayColor] returns a non-null color in the [MaterialState.focused]
  /// state, it will be used instead.
  ///
255 256 257
  /// 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.
258
  final Color? focusColor;
259 260

  /// The color for the radio's [Material] when a pointer is hovering over it.
261
  ///
262 263 264
  /// If [overlayColor] returns a non-null color in the [MaterialState.hovered]
  /// state, it will be used instead.
  ///
265 266 267
  /// 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.
268
  final Color? hoverColor;
269

270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288
  /// {@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;

289
  /// {@template flutter.material.radio.splashRadius}
290
  /// The splash radius of the circular [Material] ink response.
291
  /// {@endtemplate}
292
  ///
293 294
  /// If null, then the value of [RadioThemeData.splashRadius] is used. If that
  /// is also null, then [kRadialReactionRadius] is used.
295 296
  final double? splashRadius;

297
  /// {@macro flutter.widgets.Focus.focusNode}
298
  final FocusNode? focusNode;
299 300 301 302

  /// {@macro flutter.widgets.Focus.autofocus}
  final bool autofocus;

303 304
  bool get _selected => value == groupValue;

305
  @override
306
  State<Radio<T>> createState() => _RadioState<T>();
307 308
}

309 310
class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin, ToggleableStateMixin {
  final _RadioPainter _painter = _RadioPainter();
311

312 313 314 315 316 317 318 319 320
  void _handleChanged(bool? selected) {
    if (selected == null) {
      widget.onChanged!(null);
      return;
    }
    if (selected) {
      widget.onChanged!(widget.value);
    }
  }
321

322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
  @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;
341

342 343
  @override
  bool? get value => widget._selected;
344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369

  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;
    });
  }

370
  @override
371
  Widget build(BuildContext context) {
372
    assert(debugCheckHasMaterial(context));
373
    final ThemeData themeData = Theme.of(context);
374
    final RadioThemeData radioTheme = RadioTheme.of(context);
375
    final MaterialTapTargetSize effectiveMaterialTapTargetSize = widget.materialTapTargetSize
376
      ?? radioTheme.materialTapTargetSize
377 378
      ?? themeData.materialTapTargetSize;
    final VisualDensity effectiveVisualDensity = widget.visualDensity
379
      ?? radioTheme.visualDensity
380
      ?? themeData.visualDensity;
381
    Size size;
382
    switch (effectiveMaterialTapTargetSize) {
383
      case MaterialTapTargetSize.padded:
384
        size = const Size(kMinInteractiveDimension, kMinInteractiveDimension);
385 386
        break;
      case MaterialTapTargetSize.shrinkWrap:
387
        size = const Size(kMinInteractiveDimension - 8.0, kMinInteractiveDimension - 8.0);
388 389
        break;
    }
390
    size += effectiveVisualDensity.baseSizeAdjustment;
391 392 393

    final MaterialStateProperty<MouseCursor> effectiveMouseCursor = MaterialStateProperty.resolveWith<MouseCursor>((Set<MaterialState> states) {
      return MaterialStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states)
394
        ?? radioTheme.mouseCursor?.resolve(states)
395 396
        ?? MaterialStateProperty.resolveAs<MouseCursor>(MaterialStateMouseCursor.clickable, states);
    });
397

398 399
    // Colors need to be resolved in selected and non selected states separately
    // so that they can be lerped between.
400 401
    final Set<MaterialState> activeStates = states..add(MaterialState.selected);
    final Set<MaterialState> inactiveStates = states..remove(MaterialState.selected);
402 403
    final Color effectiveActiveColor = widget.fillColor?.resolve(activeStates)
      ?? _widgetFillColor.resolve(activeStates)
404
      ?? radioTheme.fillColor?.resolve(activeStates)
405 406 407
      ?? _defaultFillColor.resolve(activeStates);
    final Color effectiveInactiveColor = widget.fillColor?.resolve(inactiveStates)
      ?? _widgetFillColor.resolve(inactiveStates)
408
      ?? radioTheme.fillColor?.resolve(inactiveStates)
409
      ?? _defaultFillColor.resolve(inactiveStates);
410

411
    final Set<MaterialState> focusedStates = states..add(MaterialState.focused);
412 413
    final Color effectiveFocusOverlayColor = widget.overlayColor?.resolve(focusedStates)
      ?? widget.focusColor
414
      ?? radioTheme.overlayColor?.resolve(focusedStates)
415 416
      ?? themeData.focusColor;

417
    final Set<MaterialState> hoveredStates = states..add(MaterialState.hovered);
418 419
    final Color effectiveHoverOverlayColor = widget.overlayColor?.resolve(hoveredStates)
        ?? widget.hoverColor
420
        ?? radioTheme.overlayColor?.resolve(hoveredStates)
421 422
        ?? themeData.hoverColor;

423 424
    final Set<MaterialState> activePressedStates = activeStates..add(MaterialState.pressed);
    final Color effectiveActivePressedOverlayColor = widget.overlayColor?.resolve(activePressedStates)
425
        ?? radioTheme.overlayColor?.resolve(activePressedStates)
426 427 428 429
        ?? effectiveActiveColor.withAlpha(kRadialReactionAlpha);

    final Set<MaterialState> inactivePressedStates = inactiveStates..add(MaterialState.pressed);
    final Color effectiveInactivePressedOverlayColor = widget.overlayColor?.resolve(inactivePressedStates)
430
        ?? radioTheme.overlayColor?.resolve(inactivePressedStates)
431 432
        ?? effectiveActiveColor.withAlpha(kRadialReactionAlpha);

433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449
    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
450
          ..splashRadius = widget.splashRadius ?? radioTheme.splashRadius ?? kRadialReactionRadius
451 452 453 454
          ..downPosition = downPosition
          ..isFocused = states.contains(MaterialState.focused)
          ..isHovered = states.contains(MaterialState.hovered)
          ..activeColor = effectiveActiveColor
455
          ..inactiveColor = effectiveInactiveColor,
456
      ),
457 458 459
    );
  }
}
460

461
class _RadioPainter extends ToggleablePainter {
462
  @override
463 464
  void paint(Canvas canvas, Size size) {
    paintRadialReaction(canvas: canvas, origin: size.center(Offset.zero));
465

466
    final Offset center = (Offset.zero & size).center;
467 468

    // Outer circle
469
    final Paint paint = Paint()
470
      ..color = Color.lerp(inactiveColor, activeColor, position.value)!
471
      ..style = PaintingStyle.stroke
472 473 474 475 476
      ..strokeWidth = 2.0;
    canvas.drawCircle(center, _kOuterRadius, paint);

    // Inner circle
    if (!position.isDismissed) {
477
      paint.style = PaintingStyle.fill;
478 479 480 481
      canvas.drawCircle(center, _kInnerRadius * position.value, paint);
    }
  }
}