radio.dart 13.1 KB
Newer Older
1 2 3 4
// Copyright 2015 The Chromium Authors. All rights reserved.
// 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 snippet --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 116 117 118
    this.focusNode,
    this.autofocus = false,
  }) : assert(autofocus != null),
       super(key: key);
119

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

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

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

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

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

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

183
  @override
184
  _RadioState<T> createState() => _RadioState<T>();
185 186 187
}

class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
188 189 190 191 192 193 194 195 196 197 198
  bool get enabled => widget.onChanged != null;
  Map<LocalKey, ActionFactory> _actionMap;
  bool _showHighlight = false;

  @override
  void initState() {
    super.initState();
    _actionMap = <LocalKey, ActionFactory>{
      SelectAction.key: _createAction,
      if (!kIsWeb) ActivateAction.key: _createAction,
    };
199 200
    _updateHighlightMode(FocusManager.instance.highlightMode);
    FocusManager.instance.addHighlightModeListener(_handleFocusHighlightModeChange);
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218
  }

  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(
      SelectAction.key,
      onInvoke: _actionHandler,
    );
  }

  void _updateHighlightMode(FocusHighlightMode mode) {
219
    switch (FocusManager.instance.highlightMode) {
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238
      case FocusHighlightMode.touch:
        _showHighlight = false;
        break;
      case FocusHighlightMode.traditional:
        _showHighlight = true;
        break;
    }
  }

  void _handleFocusHighlightModeChange(FocusHighlightMode mode) {
    if (!mounted) {
      return;
    }
    setState(() { _updateHighlightMode(mode); });
  }

  bool hovering = false;
  void _handleMouseEnter(PointerEnterEvent event) => setState(() { hovering = true; });
  void _handleMouseExit(PointerExitEvent event) => setState(() { hovering = false; });
239

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

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

249
  @override
250
  Widget build(BuildContext context) {
251
    assert(debugCheckHasMaterial(context));
252
    final ThemeData themeData = Theme.of(context);
253 254 255 256 257 258 259 260 261
    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;
    }
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 285 286 287 288 289 290
    return MouseRegion(
      onEnter: enabled ? _handleMouseEnter : null,
      onExit: enabled ? _handleMouseExit : null,
      child: Actions(
        actions: _actionMap,
        child: Focus(
          focusNode: widget.focusNode,
          autofocus: widget.autofocus,
          canRequestFocus: enabled,
          debugLabel: '${describeIdentity(widget)}(${widget.value})',
          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: enabled && _showHighlight && Focus.of(context).hasFocus,
                hovering: enabled && _showHighlight && hovering,
              );
            },
          ),
        ),
      ),
291 292 293
    );
  }
}
294 295

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

  final bool selected;
315 316
  final bool hasFocus;
  final bool hovering;
317
  final Color inactiveColor;
318
  final Color activeColor;
319 320
  final Color focusColor;
  final Color hoverColor;
321
  final ValueChanged<bool> onChanged;
322
  final TickerProvider vsync;
323
  final BoxConstraints additionalConstraints;
324

325
  @override
326
  _RenderRadio createRenderObject(BuildContext context) => _RenderRadio(
327
    value: selected,
328
    activeColor: activeColor,
329
    inactiveColor: inactiveColor,
330 331
    focusColor: focusColor,
    hoverColor: hoverColor,
332 333
    onChanged: onChanged,
    vsync: vsync,
334
    additionalConstraints: additionalConstraints,
335 336
    hasFocus: hasFocus,
    hovering: hovering,
337 338
  );

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

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

381
  @override
382 383 384
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;

385
    paintRadialReaction(canvas, offset, size.center(Offset.zero));
386

387
    final Offset center = (offset & size).center;
388
    final Color radioColor = onChanged != null ? activeColor : inactiveColor;
389 390

    // Outer circle
391
    final Paint paint = Paint()
392
      ..color = Color.lerp(inactiveColor, radioColor, position.value)
393
      ..style = PaintingStyle.stroke
394 395 396 397 398
      ..strokeWidth = 2.0;
    canvas.drawCircle(center, _kOuterRadius, paint);

    // Inner circle
    if (!position.isDismissed) {
399
      paint.style = PaintingStyle.fill;
400 401 402
      canvas.drawCircle(center, _kInnerRadius * position.value, paint);
    }
  }
403 404 405 406

  @override
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
407 408 409
    config
      ..isInMutuallyExclusiveGroup = true
      ..isChecked = value == true;
410
  }
411
}