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);
+  });
 }