Tri-state Checkbox (#14611)
diff --git a/packages/flutter/lib/src/material/checkbox.dart b/packages/flutter/lib/src/material/checkbox.dart index 5865e50..8661722 100644 --- a/packages/flutter/lib/src/material/checkbox.dart +++ b/packages/flutter/lib/src/material/checkbox.dart
@@ -21,6 +21,10 @@ /// rebuild the checkbox with a new [value] to update the visual appearance of /// the checkbox. /// +/// The checkbox can optionally display three values - true, false, and null - +/// if [tristate] is true. When [value] is null a dash is displayed. By default +/// [tristate] is false and the checkbox's [value] must be true or false. +/// /// Requires one of its ancestors to be a [Material] widget. /// /// See also: @@ -43,16 +47,20 @@ /// /// The following arguments are required: /// - /// * [value], which determines whether the checkbox is checked, and must not - /// be null. + /// * [value], which determines whether the checkbox is checked. The [value] + /// can only be be null if [tristate] is true. /// * [onChanged], which is called when the value of the checkbox should /// change. It can be set to null to disable the checkbox. + /// + /// The value of [tristate] must not be null. const Checkbox({ Key key, @required this.value, + this.tristate: false, @required this.onChanged, this.activeColor, - }) : assert(value != null), + }) : assert(tristate != null), + assert(tristate || value != null), super(key: key); /// Whether this checkbox is checked. @@ -66,7 +74,12 @@ /// change state until the parent widget rebuilds the checkbox with the new /// value. /// - /// If null, the checkbox will be displayed as disabled. + /// If this callback is null, the checkbox will be displayed as disabled + /// and will not respond to input gestures. + /// + /// When the checkbox is tapped, if [tristate] is false (the default) then + /// the [onChanged] callback will be applied to `!value`. If [tristate] is + /// true this callback cycle from false to true to null. /// /// The callback provided to [onChanged] should update the state of the parent /// [StatefulWidget] using the [State.setState] method, so that the parent @@ -89,6 +102,18 @@ /// Defaults to accent color of the current [Theme]. final Color activeColor; + /// If true the checkbox's [value] can be true, false, or null. + /// + /// Checkbox displays a dash when its value is null. + /// + /// When a tri-state checkbox is tapped its [onChanged] callback will be + /// applied to true if the current value is null or false, false otherwise. + /// Typically tri-state checkboxes are disabled (the onChanged callback is + /// null) so they don't respond to taps. + /// + /// If tristate is false (the default), [value] must not be null. + final bool tristate; + /// The width of a checkbox widget. static const double width = 18.0; @@ -103,6 +128,7 @@ final ThemeData themeData = Theme.of(context); return new _CheckboxRenderObjectWidget( value: widget.value, + tristate: widget.tristate, activeColor: widget.activeColor ?? themeData.accentColor, inactiveColor: widget.onChanged != null ? themeData.unselectedWidgetColor : themeData.disabledColor, onChanged: widget.onChanged, @@ -115,17 +141,20 @@ const _CheckboxRenderObjectWidget({ Key key, @required this.value, + @required this.tristate, @required this.activeColor, @required this.inactiveColor, @required this.onChanged, @required this.vsync, - }) : assert(value != null), + }) : assert(tristate != null), + assert(tristate || value != null), assert(activeColor != null), assert(inactiveColor != null), assert(vsync != null), super(key: key); final bool value; + final bool tristate; final Color activeColor; final Color inactiveColor; final ValueChanged<bool> onChanged; @@ -134,6 +163,7 @@ @override _RenderCheckbox createRenderObject(BuildContext context) => new _RenderCheckbox( value: value, + tristate: tristate, activeColor: activeColor, inactiveColor: inactiveColor, onChanged: onChanged, @@ -144,6 +174,7 @@ void updateRenderObject(BuildContext context, _RenderCheckbox renderObject) { renderObject ..value = value + ..tristate = tristate ..activeColor = activeColor ..inactiveColor = inactiveColor ..onChanged = onChanged @@ -158,67 +189,151 @@ class _RenderCheckbox extends RenderToggleable { _RenderCheckbox({ bool value, + bool tristate, Color activeColor, Color inactiveColor, ValueChanged<bool> onChanged, @required TickerProvider vsync, - }): super( - value: value, - activeColor: activeColor, - inactiveColor: inactiveColor, - onChanged: onChanged, - size: const Size(2 * kRadialReactionRadius, 2 * kRadialReactionRadius), - vsync: vsync, - ); + }): _oldValue = value, + super( + value: value, + tristate: tristate, + activeColor: activeColor, + inactiveColor: inactiveColor, + onChanged: onChanged, + size: const Size(2 * kRadialReactionRadius, 2 * kRadialReactionRadius), + vsync: vsync, + ); + + bool _oldValue; + + @override + set value(bool newValue) { + if (newValue == value) + return; + _oldValue = value; + super.value = newValue; + } + + // The square outer bounds of the checkbox at t, with the specified origin. + // At t == 0.0, the outer rect's size is _kEdgeSize (Checkbox.width) + // At t == 0.5, .. is _kEdgeSize - _kStrokeWidth + // At t == 1.0, .. is _kEdgeSize + RRect _outerRectAt(Offset origin, double t) { + final double inset = 1.0 - (t - 0.5).abs() * 2.0; + final double size = _kEdgeSize - inset * _kStrokeWidth; + final Rect rect = new Rect.fromLTWH(origin.dx + inset, origin.dy + inset, size, size); + return new RRect.fromRectAndRadius(rect, _kEdgeRadius); + } + + // The checkbox's border color if value == false, or its fill color when + // value == true or null. + Color _colorAt(double t) { + // As t goes from 0.0 to 0.25, animate from the inactiveColor to activeColor. + return onChanged == null + ? inactiveColor + : (t >= 0.25 ? activeColor : Color.lerp(inactiveColor, activeColor, t * 4.0)); + } + + // White stroke used to paint the check and dash. + void _initStrokePaint(Paint paint) { + paint + ..color = const Color(0xFFFFFFFF) + ..style = PaintingStyle.stroke + ..strokeWidth = _kStrokeWidth; + } + + void _drawBorder(Canvas canvas, RRect outer, double t, Paint paint) { + assert(t >= 0.0 && t <= 0.5); + final double size = outer.width; + // As t goes from 0.0 to 1.0, gradually fill the outer RRect. + final RRect inner = outer.deflate(math.min(size / 2.0, _kStrokeWidth + size * t)); + canvas.drawDRRect(outer, inner, paint); + } + + void _drawCheck(Canvas canvas, Offset origin, double t, Paint paint) { + assert(t >= 0.0 && t <= 1.0); + // As t goes from 0.0 to 1.0, animate the two checkmark strokes from the + // mid point outwards. + final Path path = new Path(); + const Offset start = const Offset(_kEdgeSize * 0.15, _kEdgeSize * 0.45); + const Offset mid = const Offset(_kEdgeSize * 0.4, _kEdgeSize * 0.7); + const Offset end = const Offset(_kEdgeSize * 0.85, _kEdgeSize * 0.25); + final Offset drawStart = Offset.lerp(start, mid, 1.0 - t); + final Offset drawEnd = Offset.lerp(mid, end, t); + path.moveTo(origin.dx + drawStart.dx, origin.dy + drawStart.dy); + path.lineTo(origin.dx + mid.dx, origin.dy + mid.dy); + path.lineTo(origin.dx + drawEnd.dx, origin.dy + drawEnd.dy); + canvas.drawPath(path, paint); + } + + void _drawDash(Canvas canvas, Offset origin, double t, Paint paint) { + assert(t >= 0.0 && t <= 1.0); + // As t goes from 0.0 to 1.0, animate the horizontal line from the + // mid point outwards. + const Offset start = const Offset(_kEdgeSize * 0.2, _kEdgeSize * 0.5); + const Offset mid = const Offset(_kEdgeSize * 0.5, _kEdgeSize * 0.5); + const Offset end = const Offset(_kEdgeSize * 0.8, _kEdgeSize * 0.5); + final Offset drawStart = Offset.lerp(start, mid, 1.0 - t); + final Offset drawEnd = Offset.lerp(mid, end, t); + canvas.drawLine(origin + drawStart, origin + drawEnd, paint); + } @override void paint(PaintingContext context, Offset offset) { - final Canvas canvas = context.canvas; - - final double offsetX = offset.dx + (size.width - _kEdgeSize) / 2.0; - final double offsetY = offset.dy + (size.height - _kEdgeSize) / 2.0; - paintRadialReaction(canvas, offset, size.center(Offset.zero)); - final double t = position.value; + final Offset origin = offset + (size / 2.0 - const Size.square(_kEdgeSize) / 2.0); + final AnimationStatus status = position.status; + final double tNormalized = status == AnimationStatus.forward || status == AnimationStatus.completed + ? position.value + : 1.0 - position.value; - Color borderColor = inactiveColor; - if (onChanged != null) - borderColor = t >= 0.25 ? activeColor : Color.lerp(inactiveColor, activeColor, t * 4.0); + // Four cases: false to null, false to true, null to false, true to false + if (_oldValue == false || value == false) { + final double t = value == false ? 1.0 - tNormalized : tNormalized; + final RRect outer = _outerRectAt(origin, t); + final Paint paint = new Paint()..color = _colorAt(t); - final Paint paint = new Paint() - ..color = borderColor; + if (t <= 0.5) { + _drawBorder(canvas, outer, t, paint); + } else { + canvas.drawRRect(outer, paint); - final double inset = 1.0 - (t - 0.5).abs() * 2.0; - final double rectSize = _kEdgeSize - inset * _kStrokeWidth; - final Rect rect = new Rect.fromLTWH(offsetX + inset, offsetY + inset, rectSize, rectSize); - - final RRect outer = new RRect.fromRectAndRadius(rect, _kEdgeRadius); - if (t <= 0.5) { - // Outline - final RRect inner = outer.deflate(math.min(rectSize / 2.0, _kStrokeWidth + rectSize * t)); - canvas.drawDRRect(outer, inner, paint); - } else { - // Background + _initStrokePaint(paint); + final double tShrink = (t - 0.5) * 2.0; + if (_oldValue == null) + _drawDash(canvas, origin, tShrink, paint); + else + _drawCheck(canvas, origin, tShrink, paint); + } + } else { // Two cases: null to true, true to null + final RRect outer = _outerRectAt(origin, 1.0); + final Paint paint = new Paint() ..color = _colorAt(1.0); canvas.drawRRect(outer, paint); - // White inner check - final double value = (t - 0.5) * 2.0; - paint - ..color = const Color(0xFFFFFFFF) - ..style = PaintingStyle.stroke - ..strokeWidth = _kStrokeWidth; - final Path path = new Path(); - const Offset start = const Offset(_kEdgeSize * 0.15, _kEdgeSize * 0.45); - const Offset mid = const Offset(_kEdgeSize * 0.4, _kEdgeSize * 0.7); - const Offset end = const Offset(_kEdgeSize * 0.85, _kEdgeSize * 0.25); - final Offset drawStart = Offset.lerp(start, mid, 1.0 - value); - final Offset drawEnd = Offset.lerp(mid, end, value); - path.moveTo(offsetX + drawStart.dx, offsetY + drawStart.dy); - path.lineTo(offsetX + mid.dx, offsetY + mid.dy); - path.lineTo(offsetX + drawEnd.dx, offsetY + drawEnd.dy); - canvas.drawPath(path, paint); + _initStrokePaint(paint); + if (tNormalized <= 0.5) { + final double tShrink = 1.0 - tNormalized * 2.0; + if (_oldValue == true) + _drawCheck(canvas, origin, tShrink, paint); + else + _drawDash(canvas, origin, tShrink, paint); + } else { + final double tExpand = (tNormalized - 0.5) * 2.0; + if (value == true) + _drawCheck(canvas, origin, tExpand, paint); + else + _drawDash(canvas, origin, tExpand, paint); + } } } + + // TODO(hmuller): smooth segues for cases where the value changes + // in the middle of position's animation cycle. + // https://github.com/flutter/flutter/issues/14674 + + // TODO(hmuller): accessibility support for tristate checkboxes. + // https://github.com/flutter/flutter/issues/14677 }
diff --git a/packages/flutter/lib/src/material/radio.dart b/packages/flutter/lib/src/material/radio.dart index 422a82a..a8b1f48 100644 --- a/packages/flutter/lib/src/material/radio.dart +++ b/packages/flutter/lib/src/material/radio.dart
@@ -176,6 +176,7 @@ @required TickerProvider vsync, }): super( value: value, + tristate: false, activeColor: activeColor, inactiveColor: inactiveColor, onChanged: onChanged,
diff --git a/packages/flutter/lib/src/material/switch.dart b/packages/flutter/lib/src/material/switch.dart index 35d32f4..99e8999 100644 --- a/packages/flutter/lib/src/material/switch.dart +++ b/packages/flutter/lib/src/material/switch.dart
@@ -247,6 +247,7 @@ _textDirection = textDirection, super( value: value, + tristate: false, activeColor: activeColor, inactiveColor: inactiveColor, onChanged: onChanged,
diff --git a/packages/flutter/lib/src/material/toggleable.dart b/packages/flutter/lib/src/material/toggleable.dart index 79de2cf..505d8ca 100644 --- a/packages/flutter/lib/src/material/toggleable.dart +++ b/packages/flutter/lib/src/material/toggleable.dart
@@ -21,20 +21,23 @@ abstract class RenderToggleable extends RenderConstrainedBox { /// Creates a toggleable render object. /// - /// The [value], [activeColor], and [inactiveColor] arguments must not be - /// null. + /// The [activeColor], and [inactiveColor] arguments must not be + /// null. The [value] can only be null if tristate is true. RenderToggleable({ @required bool value, + bool tristate: false, Size size, @required Color activeColor, @required Color inactiveColor, ValueChanged<bool> onChanged, @required TickerProvider vsync, - }) : assert(value != null), + }) : assert(tristate != null), + assert(tristate || value != null), assert(activeColor != null), assert(inactiveColor != null), assert(vsync != null), _value = value, + _tristate = tristate, _activeColor = activeColor, _inactiveColor = inactiveColor, _onChanged = onChanged, @@ -47,7 +50,7 @@ ..onTapCancel = _handleTapCancel; _positionController = new AnimationController( duration: _kToggleDuration, - value: value ? 1.0 : 0.0, + value: value == false ? 0.0 : 1.0, vsync: vsync, ); _position = new CurvedAnimation( @@ -79,8 +82,9 @@ /// The visual value of the control. /// /// When the control is inactive, the [value] is false and this animation has - /// the value 0.0. When the control is active, the value is true and this - /// animation has the value 1.0. When the control is changing from inactive + /// the value 0.0. When the control is active, the value either true or tristate + /// is true and the value is null. When the control is active the animation + /// has a value of 1.0. When the control is changing from inactive /// to active (or vice versa), [value] is the target value and this animation /// gradually updates from 0.0 to 1.0 (or vice versa). CurvedAnimation get position => _position; @@ -110,7 +114,11 @@ reactionController.resync(vsync); } - /// Whether this control is current "active" (checked, on, selected) or "inactive" (unchecked, off, not selected). + /// False if this control is "inactive" (not checked, off, or unselected). + /// + /// If value is true then the control "active" (checked, on, or selected). If + /// tristate is true and value is null, then the control is considered to be + /// in its third or "indeterminate" state. /// /// When the value changes, this object starts the [positionController] and /// [position] animations to animate the visual appearance of the control to @@ -118,7 +126,7 @@ bool get value => _value; bool _value; set value(bool value) { - assert(value != null); + assert(tristate || value != null); if (value == _value) return; _value = value; @@ -126,10 +134,29 @@ _position ..curve = Curves.easeIn ..reverseCurve = Curves.easeOut; - if (value) - _positionController.forward(); - else - _positionController.reverse(); + switch (_positionController.status) { + case AnimationStatus.forward: + case AnimationStatus.completed: + _positionController.reverse(); + break; + default: + _positionController.forward(); + } + } + + /// If true, [value] can be true, false, or null, otherwise [value] must + /// be true or false. + /// + /// When [tristate] is true and [value] is null, then the control is + /// considered to be in its third or "indeterminate" state. + bool get tristate => _tristate; + bool _tristate; + set tristate(bool value) { + assert(tristate != null); + if (value == _tristate) + return; + _tristate = value; + markNeedsSemanticsUpdate(); } /// The color that should be used in the active state (i.e., when [value] is true). @@ -196,10 +223,10 @@ @override void attach(PipelineOwner owner) { super.attach(owner); - if (value) - _positionController.forward(); - else + if (value == false) _positionController.reverse(); + else + _positionController.forward(); if (isInteractive) { switch (_reactionController.status) { case AnimationStatus.forward: @@ -223,12 +250,17 @@ super.detach(); } + // Handle the case where the _positionController's value changes because + // the user dragged the toggleable: we may reach 0.0 or 1.0 without + // seeing a tap. The Switch does this. void _handlePositionStateChanged(AnimationStatus status) { - if (isInteractive) { - if (status == AnimationStatus.completed && !_value) + if (isInteractive && !tristate) { + if (status == AnimationStatus.completed && _value == false) { onChanged(true); - else if (status == AnimationStatus.dismissed && _value) + } + else if (status == AnimationStatus.dismissed && _value != false) { onChanged(false); + } } } @@ -240,8 +272,19 @@ } void _handleTap() { - if (isInteractive) - onChanged(!_value); + if (!isInteractive) + return; + switch (value) { + case false: + onChanged(true); + break; + case true: + onChanged(tristate ? null : false); + break; + default: // case null: + onChanged(false); + break; + } } void _handleTapUp(TapUpDetails details) { @@ -290,7 +333,7 @@ config.isEnabled = isInteractive; if (isInteractive) config.onTap = _handleTap; - config.isChecked = _value; + config.isChecked = _value != false; } @override
diff --git a/packages/flutter/test/material/checkbox_test.dart b/packages/flutter/test/material/checkbox_test.dart index afa3aac..beb15b6 100644 --- a/packages/flutter/test/material/checkbox_test.dart +++ b/packages/flutter/test/material/checkbox_test.dart
@@ -102,4 +102,48 @@ semantics.dispose(); }); + + testWidgets('CheckBox tristate: true', (WidgetTester tester) async { + bool checkBoxValue; + + await tester.pumpWidget( + new Material( + child: new StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return new Checkbox( + tristate: true, + value: checkBoxValue, + onChanged: (bool value) { + setState(() { + checkBoxValue = value; + }); + }, + ); + }, + ), + ), + ); + + expect(tester.widget<Checkbox>(find.byType(Checkbox)).value, null); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(checkBoxValue, false); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(checkBoxValue, true); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + expect(checkBoxValue, null); + + checkBoxValue = true; + await tester.pumpAndSettle(); + expect(checkBoxValue, true); + + checkBoxValue = null; + await tester.pumpAndSettle(); + expect(checkBoxValue, null); + }); }