radio.dart 16 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
    Key key,
109 110 111
    @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.materialTapTargetSize,
118
    this.visualDensity,
119 120 121
    this.focusNode,
    this.autofocus = false,
  }) : assert(autofocus != null),
122
       assert(toggleable != null),
123
       super(key: key);
124

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

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

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

162 163 164 165 166 167 168 169 170 171 172 173 174 175
  /// 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.
  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
  /// 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;

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

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

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

263 264 265 266 267 268 269 270 271 272 273 274
  /// The color for the radio's [Material] when it has the input focus.
  final Color focusColor;

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

  /// {@macro flutter.widgets.Focus.focusNode}
  final FocusNode focusNode;

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

275
  @override
276
  _RadioState<T> createState() => _RadioState<T>();
277 278 279
}

class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
280
  bool get enabled => widget.onChanged != null;
281
  Map<Type, Action<Intent>> _actionMap;
282 283 284 285

  @override
  void initState() {
    super.initState();
286 287 288 289
    _actionMap = <Type, Action<Intent>>{
      ActivateIntent: CallbackAction<ActivateIntent>(
        onInvoke: _actionHandler,
      ),
290 291 292
    };
  }

293
  void _actionHandler(ActivateIntent intent) {
294 295 296
    if (widget.onChanged != null) {
      widget.onChanged(widget.value);
    }
297
    final RenderObject renderObject = context.findRenderObject();
298 299 300
    renderObject.sendSemanticsEvent(const TapSemanticEvent());
  }

301 302 303 304
  bool _focused = false;
  void _handleHighlightChanged(bool focused) {
    if (_focused != focused) {
      setState(() { _focused = focused; });
305 306 307
    }
  }

308 309 310 311
  bool _hovering = false;
  void _handleHoverChanged(bool hovering) {
    if (_hovering != hovering) {
      setState(() { _hovering = hovering; });
312 313 314
    }
  }

315
  Color _getInactiveColor(ThemeData themeData) {
316
    return enabled ? themeData.unselectedWidgetColor : themeData.disabledColor;
317 318
  }

319
  void _handleChanged(bool selected) {
320 321 322 323 324
    if (selected == null) {
      widget.onChanged(null);
      return;
    }
    if (selected) {
325
      widget.onChanged(widget.value);
326
    }
327 328
  }

329
  @override
330
  Widget build(BuildContext context) {
331
    assert(debugCheckHasMaterial(context));
332
    final ThemeData themeData = Theme.of(context);
333 334 335 336 337 338 339 340 341
    Size size;
    switch (widget.materialTapTargetSize ?? themeData.materialTapTargetSize) {
      case MaterialTapTargetSize.padded:
        size = const Size(2 * kRadialReactionRadius + 8.0, 2 * kRadialReactionRadius + 8.0);
        break;
      case MaterialTapTargetSize.shrinkWrap:
        size = const Size(2 * kRadialReactionRadius, 2 * kRadialReactionRadius);
        break;
    }
342
    size += (widget.visualDensity ?? themeData.visualDensity).baseSizeAdjustment;
343
    final BoxConstraints additionalConstraints = BoxConstraints.tight(size);
344 345 346 347 348 349 350 351 352 353 354
    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,
      },
    );

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

class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
385
  const _RadioRenderObjectWidget({
386
    Key key,
387 388 389
    @required this.selected,
    @required this.activeColor,
    @required this.inactiveColor,
390 391
    @required this.focusColor,
    @required this.hoverColor,
392
    @required this.additionalConstraints,
393
    this.onChanged,
394
    @required this.toggleable,
395
    @required this.vsync,
396 397
    @required this.hasFocus,
    @required this.hovering,
398 399 400 401
  }) : assert(selected != null),
       assert(activeColor != null),
       assert(inactiveColor != null),
       assert(vsync != null),
402
       assert(toggleable != null),
403
       super(key: key);
404 405

  final bool selected;
406 407
  final bool hasFocus;
  final bool hovering;
408
  final Color inactiveColor;
409
  final Color activeColor;
410 411
  final Color focusColor;
  final Color hoverColor;
412
  final ValueChanged<bool> onChanged;
413
  final bool toggleable;
414
  final TickerProvider vsync;
415
  final BoxConstraints additionalConstraints;
416

417
  @override
418
  _RenderRadio createRenderObject(BuildContext context) => _RenderRadio(
419
    value: selected,
420
    activeColor: activeColor,
421
    inactiveColor: inactiveColor,
422 423
    focusColor: focusColor,
    hoverColor: hoverColor,
424
    onChanged: onChanged,
425
    tristate: toggleable,
426
    vsync: vsync,
427
    additionalConstraints: additionalConstraints,
428 429
    hasFocus: hasFocus,
    hovering: hovering,
430 431
  );

432
  @override
433
  void updateRenderObject(BuildContext context, _RenderRadio renderObject) {
434 435 436 437
    renderObject
      ..value = selected
      ..activeColor = activeColor
      ..inactiveColor = inactiveColor
438 439
      ..focusColor = focusColor
      ..hoverColor = hoverColor
440
      ..onChanged = onChanged
441
      ..tristate = toggleable
442
      ..additionalConstraints = additionalConstraints
443 444 445
      ..vsync = vsync
      ..hasFocus = hasFocus
      ..hovering = hovering;
446 447 448 449 450 451
  }
}

class _RenderRadio extends RenderToggleable {
  _RenderRadio({
    bool value,
452
    Color activeColor,
453
    Color inactiveColor,
454 455
    Color focusColor,
    Color hoverColor,
456
    ValueChanged<bool> onChanged,
457
    bool tristate,
458
    BoxConstraints additionalConstraints,
459
    @required TickerProvider vsync,
460 461
    bool hasFocus,
    bool hovering,
462 463 464 465
  }) : super(
         value: value,
         activeColor: activeColor,
         inactiveColor: inactiveColor,
466 467
         focusColor: focusColor,
         hoverColor: hoverColor,
468
         onChanged: onChanged,
469
         tristate: tristate,
470 471
         additionalConstraints: additionalConstraints,
         vsync: vsync,
472 473
         hasFocus: hasFocus,
         hovering: hovering,
474
       );
475

476
  @override
477 478 479
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;

480
    paintRadialReaction(canvas, offset, size.center(Offset.zero));
481

482
    final Offset center = (offset & size).center;
483
    final Color radioColor = onChanged != null ? activeColor : inactiveColor;
484 485

    // Outer circle
486
    final Paint paint = Paint()
487
      ..color = Color.lerp(inactiveColor, radioColor, position.value)
488
      ..style = PaintingStyle.stroke
489 490 491 492 493
      ..strokeWidth = 2.0;
    canvas.drawCircle(center, _kOuterRadius, paint);

    // Inner circle
    if (!position.isDismissed) {
494
      paint.style = PaintingStyle.fill;
495 496 497
      canvas.drawCircle(center, _kInnerRadius * position.value, paint);
    }
  }
498 499 500 501

  @override
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
502 503 504
    config
      ..isInMutuallyExclusiveGroup = true
      ..isChecked = value == true;
505
  }
506
}