radio.dart 12.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 'theme.dart';
13
import 'theme_data.dart';
14
import 'toggleable.dart';
15

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

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

121
  /// The value represented by this radio button.
Hixie's avatar
Hixie committed
122
  final T value;
123

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

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

158 159
  /// The color to use when this radio button is selected.
  ///
160
  /// Defaults to [ThemeData.toggleableActiveColor].
161 162
  final Color activeColor;

163 164 165 166 167 168
  /// Configures the minimum size of the tap target.
  ///
  /// Defaults to [ThemeData.materialTapTargetSize].
  ///
  /// See also:
  ///
169
  ///  * [MaterialTapTargetSize], for a description of how this affects tap targets.
170 171
  final MaterialTapTargetSize materialTapTargetSize;

172 173 174 175 176 177 178 179 180 181
  /// 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;

182 183 184 185 186 187 188 189 190 191 192 193
  /// 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;

194
  @override
195
  _RadioState<T> createState() => _RadioState<T>();
196 197 198
}

class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
199 200 201 202 203 204 205
  bool get enabled => widget.onChanged != null;
  Map<LocalKey, ActionFactory> _actionMap;

  @override
  void initState() {
    super.initState();
    _actionMap = <LocalKey, ActionFactory>{
206
      ActivateAction.key: _createAction,
207 208 209 210 211 212 213 214 215 216 217 218 219
    };
  }

  void _actionHandler(FocusNode node, Intent intent){
    if (widget.onChanged != null) {
      widget.onChanged(widget.value);
    }
    final RenderObject renderObject = node.context.findRenderObject();
    renderObject.sendSemanticsEvent(const TapSemanticEvent());
  }

  Action _createAction() {
    return CallbackAction(
220
      ActivateAction.key,
221 222 223 224
      onInvoke: _actionHandler,
    );
  }

225 226 227 228
  bool _focused = false;
  void _handleHighlightChanged(bool focused) {
    if (_focused != focused) {
      setState(() { _focused = focused; });
229 230 231
    }
  }

232 233 234 235
  bool _hovering = false;
  void _handleHoverChanged(bool hovering) {
    if (_hovering != hovering) {
      setState(() { _hovering = hovering; });
236 237 238
    }
  }

239
  Color _getInactiveColor(ThemeData themeData) {
240
    return enabled ? themeData.unselectedWidgetColor : themeData.disabledColor;
241 242
  }

243 244
  void _handleChanged(bool selected) {
    if (selected)
245
      widget.onChanged(widget.value);
246 247
  }

248
  @override
249
  Widget build(BuildContext context) {
250
    assert(debugCheckHasMaterial(context));
251
    final ThemeData themeData = Theme.of(context);
252 253 254 255 256 257 258 259 260
    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;
    }
261
    size += (widget.visualDensity ?? themeData.visualDensity).baseSizeAdjustment;
262
    final BoxConstraints additionalConstraints = BoxConstraints.tight(size);
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284
    return FocusableActionDetector(
      actions: _actionMap,
      focusNode: widget.focusNode,
      autofocus: widget.autofocus,
      enabled: enabled,
      onShowFocusHighlight: _handleHighlightChanged,
      onShowHoverHighlight: _handleHoverChanged,
      child: Builder(
        builder: (BuildContext context) {
          return _RadioRenderObjectWidget(
            selected: widget.value == widget.groupValue,
            activeColor: widget.activeColor ?? themeData.toggleableActiveColor,
            inactiveColor: _getInactiveColor(themeData),
            focusColor: widget.focusColor ?? themeData.focusColor,
            hoverColor: widget.hoverColor ?? themeData.hoverColor,
            onChanged: enabled ? _handleChanged : null,
            additionalConstraints: additionalConstraints,
            vsync: this,
            hasFocus: _focused,
            hovering: _hovering,
          );
        },
285
      ),
286 287 288
    );
  }
}
289 290

class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
291
  const _RadioRenderObjectWidget({
292
    Key key,
293 294 295
    @required this.selected,
    @required this.activeColor,
    @required this.inactiveColor,
296 297
    @required this.focusColor,
    @required this.hoverColor,
298
    @required this.additionalConstraints,
299 300
    this.onChanged,
    @required this.vsync,
301 302
    @required this.hasFocus,
    @required this.hovering,
303 304 305 306 307
  }) : assert(selected != null),
       assert(activeColor != null),
       assert(inactiveColor != null),
       assert(vsync != null),
       super(key: key);
308 309

  final bool selected;
310 311
  final bool hasFocus;
  final bool hovering;
312
  final Color inactiveColor;
313
  final Color activeColor;
314 315
  final Color focusColor;
  final Color hoverColor;
316
  final ValueChanged<bool> onChanged;
317
  final TickerProvider vsync;
318
  final BoxConstraints additionalConstraints;
319

320
  @override
321
  _RenderRadio createRenderObject(BuildContext context) => _RenderRadio(
322
    value: selected,
323
    activeColor: activeColor,
324
    inactiveColor: inactiveColor,
325 326
    focusColor: focusColor,
    hoverColor: hoverColor,
327 328
    onChanged: onChanged,
    vsync: vsync,
329
    additionalConstraints: additionalConstraints,
330 331
    hasFocus: hasFocus,
    hovering: hovering,
332 333
  );

334
  @override
335
  void updateRenderObject(BuildContext context, _RenderRadio renderObject) {
336 337 338 339
    renderObject
      ..value = selected
      ..activeColor = activeColor
      ..inactiveColor = inactiveColor
340 341
      ..focusColor = focusColor
      ..hoverColor = hoverColor
342
      ..onChanged = onChanged
343
      ..additionalConstraints = additionalConstraints
344 345 346
      ..vsync = vsync
      ..hasFocus = hasFocus
      ..hovering = hovering;
347 348 349 350 351 352
  }
}

class _RenderRadio extends RenderToggleable {
  _RenderRadio({
    bool value,
353
    Color activeColor,
354
    Color inactiveColor,
355 356
    Color focusColor,
    Color hoverColor,
357
    ValueChanged<bool> onChanged,
358
    BoxConstraints additionalConstraints,
359
    @required TickerProvider vsync,
360 361
    bool hasFocus,
    bool hovering,
362 363 364 365 366
  }) : super(
         value: value,
         tristate: false,
         activeColor: activeColor,
         inactiveColor: inactiveColor,
367 368
         focusColor: focusColor,
         hoverColor: hoverColor,
369 370 371
         onChanged: onChanged,
         additionalConstraints: additionalConstraints,
         vsync: vsync,
372 373
         hasFocus: hasFocus,
         hovering: hovering,
374
       );
375

376
  @override
377 378 379
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;

380
    paintRadialReaction(canvas, offset, size.center(Offset.zero));
381

382
    final Offset center = (offset & size).center;
383
    final Color radioColor = onChanged != null ? activeColor : inactiveColor;
384 385

    // Outer circle
386
    final Paint paint = Paint()
387
      ..color = Color.lerp(inactiveColor, radioColor, position.value)
388
      ..style = PaintingStyle.stroke
389 390 391 392 393
      ..strokeWidth = 2.0;
    canvas.drawCircle(center, _kOuterRadius, paint);

    // Inner circle
    if (!position.isDismissed) {
394
      paint.style = PaintingStyle.fill;
395 396 397
      canvas.drawCircle(center, _kInnerRadius * position.value, paint);
    }
  }
398 399 400 401

  @override
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
402 403 404
    config
      ..isInMutuallyExclusiveGroup = true
      ..isChecked = value == true;
405
  }
406
}