radio.dart 16.6 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 6
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
7
import 'package:flutter/rendering.dart';
8
import 'package:flutter/widgets.dart';
9

10
import 'constants.dart';
11
import 'debug.dart';
12
import 'material_state.dart';
13
import 'theme.dart';
14
import 'theme_data.dart';
15
import 'toggleable.dart';
16

17 18
const double _kOuterRadius = 8.0;
const double _kInnerRadius = 4.5;
19

20 21
/// A material design radio button.
///
22 23 24 25
/// 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.
26
///
27 28 29 30 31 32
/// 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].
///
33
/// {@tool dartpad --template=stateful_widget_scaffold_center}
34 35 36 37 38 39 40 41 42 43 44 45 46 47
///
/// 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.
///
48 49
/// Requires one of its ancestors to be a [Material] widget.
///
50 51 52 53 54 55 56 57
/// ```dart preamble
/// enum SingingCharacter { lafayette, jefferson }
/// ```
///
/// ```dart
/// SingingCharacter _character = SingingCharacter.lafayette;
///
/// Widget build(BuildContext context) {
58 59 60 61 62 63 64 65 66 67
///   return Column(
///     children: <Widget>[
///       ListTile(
///         title: const Text('Lafayette'),
///         leading: Radio(
///           value: SingingCharacter.lafayette,
///           groupValue: _character,
///           onChanged: (SingingCharacter value) {
///             setState(() { _character = value; });
///           },
68
///         ),
69 70 71 72 73 74 75 76 77
///       ),
///       ListTile(
///         title: const Text('Thomas Jefferson'),
///         leading: Radio(
///           value: SingingCharacter.jefferson,
///           groupValue: _character,
///           onChanged: (SingingCharacter value) {
///             setState(() { _character = value; });
///           },
78
///         ),
79 80
///       ),
///     ],
81 82 83 84
///   );
/// }
/// ```
/// {@end-tool}
85 86
///
/// See also:
87
///
88 89
///  * [RadioListTile], which combines this widget with a [ListTile] so that
///    you can give the radio button a label.
90 91
///  * [Slider], for selecting a value in a range.
///  * [Checkbox] and [Switch], for toggling a particular value on or off.
92
///  * <https://material.io/design/components/selection-controls.html#radio-buttons>
93
class Radio<T> extends StatefulWidget {
94 95
  /// Creates a material design radio button.
  ///
96 97 98 99 100
  /// 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.
101
  ///
102 103 104 105 106
  /// 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.
107
  const Radio({
108 109 110 111
    Key? key,
    required this.value,
    required this.groupValue,
    required this.onChanged,
112
    this.mouseCursor,
113
    this.toggleable = false,
114
    this.activeColor,
115 116
    this.focusColor,
    this.hoverColor,
117
    this.splashRadius,
118
    this.materialTapTargetSize,
119
    this.visualDensity,
120 121 122
    this.focusNode,
    this.autofocus = false,
  }) : assert(autofocus != null),
123
       assert(toggleable != null),
124
       super(key: key);
125

126
  /// The value represented by this radio button.
Hixie's avatar
Hixie committed
127
  final T value;
128

129
  /// The currently selected value for a group of radio buttons.
130 131 132
  ///
  /// This radio button is considered selected if its [value] matches the
  /// [groupValue].
133
  final T? groupValue;
134 135 136

  /// Called when the user selects this radio button.
  ///
137 138 139 140 141
  /// 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.
142
  ///
143 144 145
  /// The provided callback will not be invoked if this radio button is already
  /// selected.
  ///
146
  /// The callback provided to [onChanged] should update the state of the parent
147 148 149 150
  /// [StatefulWidget] using the [State.setState] method, so that the parent
  /// gets rebuilt; for example:
  ///
  /// ```dart
151
  /// Radio<SingingCharacter>(
152 153 154 155 156 157 158
  ///   value: SingingCharacter.lafayette,
  ///   groupValue: _character,
  ///   onChanged: (SingingCharacter newValue) {
  ///     setState(() {
  ///       _character = newValue;
  ///     });
  ///   },
159
  /// )
160
  /// ```
161
  final ValueChanged<T?>? onChanged;
162

163 164 165 166 167 168 169 170 171 172 173 174
  /// 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].
  ///
  /// If this property is null, [MaterialStateMouseCursor.clickable] will be used.
175
  final MouseCursor? mouseCursor;
176

177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239
  /// 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: (context, 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;

240 241
  /// The color to use when this radio button is selected.
  ///
242
  /// Defaults to [ThemeData.toggleableActiveColor].
243
  final Color? activeColor;
244

245 246 247 248 249 250
  /// Configures the minimum size of the tap target.
  ///
  /// Defaults to [ThemeData.materialTapTargetSize].
  ///
  /// See also:
  ///
251
  ///  * [MaterialTapTargetSize], for a description of how this affects tap targets.
252
  final MaterialTapTargetSize? materialTapTargetSize;
253

254 255 256 257 258 259
  /// Defines how compact the radio's layout will be.
  ///
  /// {@macro flutter.material.themedata.visualDensity}
  ///
  /// See also:
  ///
260 261
  ///  * [ThemeData.visualDensity], which specifies the [visualDensity] for all
  ///    widgets within a [Theme].
262
  final VisualDensity? visualDensity;
263

264
  /// The color for the radio's [Material] when it has the input focus.
265
  final Color? focusColor;
266 267

  /// The color for the radio's [Material] when a pointer is hovering over it.
268
  final Color? hoverColor;
269

270 271 272 273 274
  /// The splash radius of the circular [Material] ink response.
  ///
  /// If null, then [kRadialReactionRadius] is used.
  final double? splashRadius;

275
  /// {@macro flutter.widgets.Focus.focusNode}
276
  final FocusNode? focusNode;
277 278 279 280

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

281
  @override
282
  _RadioState<T> createState() => _RadioState<T>();
283 284 285
}

class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
286
  bool get enabled => widget.onChanged != null;
287
  late Map<Type, Action<Intent>> _actionMap;
288 289 290 291

  @override
  void initState() {
    super.initState();
292 293 294 295
    _actionMap = <Type, Action<Intent>>{
      ActivateIntent: CallbackAction<ActivateIntent>(
        onInvoke: _actionHandler,
      ),
296 297 298
    };
  }

299
  void _actionHandler(ActivateIntent intent) {
300
    if (widget.onChanged != null) {
301
      widget.onChanged!(widget.value);
302
    }
303
    final RenderObject renderObject = context.findRenderObject()!;
304 305 306
    renderObject.sendSemanticsEvent(const TapSemanticEvent());
  }

307 308 309 310
  bool _focused = false;
  void _handleHighlightChanged(bool focused) {
    if (_focused != focused) {
      setState(() { _focused = focused; });
311 312 313
    }
  }

314 315 316 317
  bool _hovering = false;
  void _handleHoverChanged(bool hovering) {
    if (_hovering != hovering) {
      setState(() { _hovering = hovering; });
318 319 320
    }
  }

321
  Color _getInactiveColor(ThemeData themeData) {
322
    return enabled ? themeData.unselectedWidgetColor : themeData.disabledColor;
323 324
  }

325
  void _handleChanged(bool? selected) {
326
    if (selected == null) {
327
      widget.onChanged!(null);
328 329 330
      return;
    }
    if (selected) {
331
      widget.onChanged!(widget.value);
332
    }
333 334
  }

335
  @override
336
  Widget build(BuildContext context) {
337
    assert(debugCheckHasMaterial(context));
338
    final ThemeData themeData = Theme.of(context);
339 340 341
    Size size;
    switch (widget.materialTapTargetSize ?? themeData.materialTapTargetSize) {
      case MaterialTapTargetSize.padded:
342
        size = const Size(kMinInteractiveDimension, kMinInteractiveDimension);
343 344
        break;
      case MaterialTapTargetSize.shrinkWrap:
345
        size = const Size(kMinInteractiveDimension - 8.0, kMinInteractiveDimension - 8.0);
346 347
        break;
    }
348
    size += (widget.visualDensity ?? themeData.visualDensity).baseSizeAdjustment;
349
    final BoxConstraints additionalConstraints = BoxConstraints.tight(size);
350 351 352 353 354 355 356 357 358 359 360
    final bool selected = widget.value == widget.groupValue;
    final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor>(
      widget.mouseCursor ?? MaterialStateMouseCursor.clickable,
      <MaterialState>{
        if (!enabled) MaterialState.disabled,
        if (_hovering) MaterialState.hovered,
        if (_focused) MaterialState.focused,
        if (selected) MaterialState.selected,
      },
    );

361 362 363 364
    return FocusableActionDetector(
      actions: _actionMap,
      focusNode: widget.focusNode,
      autofocus: widget.autofocus,
365
      mouseCursor: effectiveMouseCursor,
366 367 368 369 370 371
      enabled: enabled,
      onShowFocusHighlight: _handleHighlightChanged,
      onShowHoverHighlight: _handleHoverChanged,
      child: Builder(
        builder: (BuildContext context) {
          return _RadioRenderObjectWidget(
372
            selected: selected,
373 374 375 376
            activeColor: widget.activeColor ?? themeData.toggleableActiveColor,
            inactiveColor: _getInactiveColor(themeData),
            focusColor: widget.focusColor ?? themeData.focusColor,
            hoverColor: widget.hoverColor ?? themeData.hoverColor,
377
            splashRadius: widget.splashRadius ?? kRadialReactionRadius,
378
            onChanged: enabled ? _handleChanged : null,
379
            toggleable: widget.toggleable,
380 381 382 383 384 385
            additionalConstraints: additionalConstraints,
            vsync: this,
            hasFocus: _focused,
            hovering: _hovering,
          );
        },
386
      ),
387 388 389
    );
  }
}
390 391

class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
392
  const _RadioRenderObjectWidget({
393 394 395 396 397 398 399
    Key? key,
    required this.selected,
    required this.activeColor,
    required this.inactiveColor,
    required this.focusColor,
    required this.hoverColor,
    required this.additionalConstraints,
400
    this.onChanged,
401 402 403 404
    required this.toggleable,
    required this.vsync,
    required this.hasFocus,
    required this.hovering,
405
    required this.splashRadius,
406 407 408 409
  }) : assert(selected != null),
       assert(activeColor != null),
       assert(inactiveColor != null),
       assert(vsync != null),
410
       assert(toggleable != null),
411
       super(key: key);
412 413

  final bool selected;
414 415
  final bool hasFocus;
  final bool hovering;
416
  final Color inactiveColor;
417
  final Color activeColor;
418 419
  final Color focusColor;
  final Color hoverColor;
420
  final double splashRadius;
421
  final ValueChanged<bool?>? onChanged;
422
  final bool toggleable;
423
  final TickerProvider vsync;
424
  final BoxConstraints additionalConstraints;
425

426
  @override
427
  _RenderRadio createRenderObject(BuildContext context) => _RenderRadio(
428
    value: selected,
429
    activeColor: activeColor,
430
    inactiveColor: inactiveColor,
431 432
    focusColor: focusColor,
    hoverColor: hoverColor,
433
    splashRadius: splashRadius,
434
    onChanged: onChanged,
435
    tristate: toggleable,
436
    vsync: vsync,
437
    additionalConstraints: additionalConstraints,
438 439
    hasFocus: hasFocus,
    hovering: hovering,
440 441
  );

442
  @override
443
  void updateRenderObject(BuildContext context, _RenderRadio renderObject) {
444 445 446 447
    renderObject
      ..value = selected
      ..activeColor = activeColor
      ..inactiveColor = inactiveColor
448 449
      ..focusColor = focusColor
      ..hoverColor = hoverColor
450
      ..splashRadius = splashRadius
451
      ..onChanged = onChanged
452
      ..tristate = toggleable
453
      ..additionalConstraints = additionalConstraints
454 455 456
      ..vsync = vsync
      ..hasFocus = hasFocus
      ..hovering = hovering;
457 458 459 460 461
  }
}

class _RenderRadio extends RenderToggleable {
  _RenderRadio({
462 463 464 465 466
    required bool value,
    required Color activeColor,
    required Color inactiveColor,
    required Color focusColor,
    required Color hoverColor,
467
    required double splashRadius,
468 469 470 471 472 473
    required ValueChanged<bool?>? onChanged,
    required bool tristate,
    required BoxConstraints additionalConstraints,
    required TickerProvider vsync,
    required bool hasFocus,
    required bool hovering,
474 475 476 477
  }) : super(
         value: value,
         activeColor: activeColor,
         inactiveColor: inactiveColor,
478 479
         focusColor: focusColor,
         hoverColor: hoverColor,
480
         splashRadius: splashRadius,
481
         onChanged: onChanged,
482
         tristate: tristate,
483 484
         additionalConstraints: additionalConstraints,
         vsync: vsync,
485 486
         hasFocus: hasFocus,
         hovering: hovering,
487
       );
488

489
  @override
490 491 492
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;

493
    paintRadialReaction(canvas, offset, size.center(Offset.zero));
494

495
    final Offset center = (offset & size).center;
496
    final Color radioColor = onChanged != null ? activeColor : inactiveColor;
497 498

    // Outer circle
499
    final Paint paint = Paint()
500
      ..color = Color.lerp(inactiveColor, radioColor, position.value)!
501
      ..style = PaintingStyle.stroke
502 503 504 505 506
      ..strokeWidth = 2.0;
    canvas.drawCircle(center, _kOuterRadius, paint);

    // Inner circle
    if (!position.isDismissed) {
507
      paint.style = PaintingStyle.fill;
508 509 510
      canvas.drawCircle(center, _kInnerRadius * position.value, paint);
    }
  }
511 512 513 514

  @override
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
515 516 517
    config
      ..isInMutuallyExclusiveGroup = true
      ..isChecked = value == true;
518
  }
519
}