Skip to content

Commit 2aa9bb2

Browse files
author
Hans Muller
authored
Tri-state Checkbox (flutter#14611)
1 parent 8507b72 commit 2aa9bb2

File tree

5 files changed

+275
-71
lines changed

5 files changed

+275
-71
lines changed

packages/flutter/lib/src/material/checkbox.dart

+165-50
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import 'toggleable.dart';
2121
/// rebuild the checkbox with a new [value] to update the visual appearance of
2222
/// the checkbox.
2323
///
24+
/// The checkbox can optionally display three values - true, false, and null -
25+
/// if [tristate] is true. When [value] is null a dash is displayed. By default
26+
/// [tristate] is false and the checkbox's [value] must be true or false.
27+
///
2428
/// Requires one of its ancestors to be a [Material] widget.
2529
///
2630
/// See also:
@@ -43,16 +47,20 @@ class Checkbox extends StatefulWidget {
4347
///
4448
/// The following arguments are required:
4549
///
46-
/// * [value], which determines whether the checkbox is checked, and must not
47-
/// be null.
50+
/// * [value], which determines whether the checkbox is checked. The [value]
51+
/// can only be be null if [tristate] is true.
4852
/// * [onChanged], which is called when the value of the checkbox should
4953
/// change. It can be set to null to disable the checkbox.
54+
///
55+
/// The value of [tristate] must not be null.
5056
const Checkbox({
5157
Key key,
5258
@required this.value,
59+
this.tristate: false,
5360
@required this.onChanged,
5461
this.activeColor,
55-
}) : assert(value != null),
62+
}) : assert(tristate != null),
63+
assert(tristate || value != null),
5664
super(key: key);
5765

5866
/// Whether this checkbox is checked.
@@ -66,7 +74,12 @@ class Checkbox extends StatefulWidget {
6674
/// change state until the parent widget rebuilds the checkbox with the new
6775
/// value.
6876
///
69-
/// If null, the checkbox will be displayed as disabled.
77+
/// If this callback is null, the checkbox will be displayed as disabled
78+
/// and will not respond to input gestures.
79+
///
80+
/// When the checkbox is tapped, if [tristate] is false (the default) then
81+
/// the [onChanged] callback will be applied to `!value`. If [tristate] is
82+
/// true this callback cycle from false to true to null.
7083
///
7184
/// The callback provided to [onChanged] should update the state of the parent
7285
/// [StatefulWidget] using the [State.setState] method, so that the parent
@@ -89,6 +102,18 @@ class Checkbox extends StatefulWidget {
89102
/// Defaults to accent color of the current [Theme].
90103
final Color activeColor;
91104

105+
/// If true the checkbox's [value] can be true, false, or null.
106+
///
107+
/// Checkbox displays a dash when its value is null.
108+
///
109+
/// When a tri-state checkbox is tapped its [onChanged] callback will be
110+
/// applied to true if the current value is null or false, false otherwise.
111+
/// Typically tri-state checkboxes are disabled (the onChanged callback is
112+
/// null) so they don't respond to taps.
113+
///
114+
/// If tristate is false (the default), [value] must not be null.
115+
final bool tristate;
116+
92117
/// The width of a checkbox widget.
93118
static const double width = 18.0;
94119

@@ -103,6 +128,7 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin {
103128
final ThemeData themeData = Theme.of(context);
104129
return new _CheckboxRenderObjectWidget(
105130
value: widget.value,
131+
tristate: widget.tristate,
106132
activeColor: widget.activeColor ?? themeData.accentColor,
107133
inactiveColor: widget.onChanged != null ? themeData.unselectedWidgetColor : themeData.disabledColor,
108134
onChanged: widget.onChanged,
@@ -115,17 +141,20 @@ class _CheckboxRenderObjectWidget extends LeafRenderObjectWidget {
115141
const _CheckboxRenderObjectWidget({
116142
Key key,
117143
@required this.value,
144+
@required this.tristate,
118145
@required this.activeColor,
119146
@required this.inactiveColor,
120147
@required this.onChanged,
121148
@required this.vsync,
122-
}) : assert(value != null),
149+
}) : assert(tristate != null),
150+
assert(tristate || value != null),
123151
assert(activeColor != null),
124152
assert(inactiveColor != null),
125153
assert(vsync != null),
126154
super(key: key);
127155

128156
final bool value;
157+
final bool tristate;
129158
final Color activeColor;
130159
final Color inactiveColor;
131160
final ValueChanged<bool> onChanged;
@@ -134,6 +163,7 @@ class _CheckboxRenderObjectWidget extends LeafRenderObjectWidget {
134163
@override
135164
_RenderCheckbox createRenderObject(BuildContext context) => new _RenderCheckbox(
136165
value: value,
166+
tristate: tristate,
137167
activeColor: activeColor,
138168
inactiveColor: inactiveColor,
139169
onChanged: onChanged,
@@ -144,6 +174,7 @@ class _CheckboxRenderObjectWidget extends LeafRenderObjectWidget {
144174
void updateRenderObject(BuildContext context, _RenderCheckbox renderObject) {
145175
renderObject
146176
..value = value
177+
..tristate = tristate
147178
..activeColor = activeColor
148179
..inactiveColor = inactiveColor
149180
..onChanged = onChanged
@@ -158,67 +189,151 @@ const double _kStrokeWidth = 2.0;
158189
class _RenderCheckbox extends RenderToggleable {
159190
_RenderCheckbox({
160191
bool value,
192+
bool tristate,
161193
Color activeColor,
162194
Color inactiveColor,
163195
ValueChanged<bool> onChanged,
164196
@required TickerProvider vsync,
165-
}): super(
166-
value: value,
167-
activeColor: activeColor,
168-
inactiveColor: inactiveColor,
169-
onChanged: onChanged,
170-
size: const Size(2 * kRadialReactionRadius, 2 * kRadialReactionRadius),
171-
vsync: vsync,
172-
);
197+
}): _oldValue = value,
198+
super(
199+
value: value,
200+
tristate: tristate,
201+
activeColor: activeColor,
202+
inactiveColor: inactiveColor,
203+
onChanged: onChanged,
204+
size: const Size(2 * kRadialReactionRadius, 2 * kRadialReactionRadius),
205+
vsync: vsync,
206+
);
207+
208+
bool _oldValue;
173209

174210
@override
175-
void paint(PaintingContext context, Offset offset) {
211+
set value(bool newValue) {
212+
if (newValue == value)
213+
return;
214+
_oldValue = value;
215+
super.value = newValue;
216+
}
176217

177-
final Canvas canvas = context.canvas;
218+
// The square outer bounds of the checkbox at t, with the specified origin.
219+
// At t == 0.0, the outer rect's size is _kEdgeSize (Checkbox.width)
220+
// At t == 0.5, .. is _kEdgeSize - _kStrokeWidth
221+
// At t == 1.0, .. is _kEdgeSize
222+
RRect _outerRectAt(Offset origin, double t) {
223+
final double inset = 1.0 - (t - 0.5).abs() * 2.0;
224+
final double size = _kEdgeSize - inset * _kStrokeWidth;
225+
final Rect rect = new Rect.fromLTWH(origin.dx + inset, origin.dy + inset, size, size);
226+
return new RRect.fromRectAndRadius(rect, _kEdgeRadius);
227+
}
178228

179-
final double offsetX = offset.dx + (size.width - _kEdgeSize) / 2.0;
180-
final double offsetY = offset.dy + (size.height - _kEdgeSize) / 2.0;
229+
// The checkbox's border color if value == false, or its fill color when
230+
// value == true or null.
231+
Color _colorAt(double t) {
232+
// As t goes from 0.0 to 0.25, animate from the inactiveColor to activeColor.
233+
return onChanged == null
234+
? inactiveColor
235+
: (t >= 0.25 ? activeColor : Color.lerp(inactiveColor, activeColor, t * 4.0));
236+
}
237+
238+
// White stroke used to paint the check and dash.
239+
void _initStrokePaint(Paint paint) {
240+
paint
241+
..color = const Color(0xFFFFFFFF)
242+
..style = PaintingStyle.stroke
243+
..strokeWidth = _kStrokeWidth;
244+
}
245+
246+
void _drawBorder(Canvas canvas, RRect outer, double t, Paint paint) {
247+
assert(t >= 0.0 && t <= 0.5);
248+
final double size = outer.width;
249+
// As t goes from 0.0 to 1.0, gradually fill the outer RRect.
250+
final RRect inner = outer.deflate(math.min(size / 2.0, _kStrokeWidth + size * t));
251+
canvas.drawDRRect(outer, inner, paint);
252+
}
253+
254+
void _drawCheck(Canvas canvas, Offset origin, double t, Paint paint) {
255+
assert(t >= 0.0 && t <= 1.0);
256+
// As t goes from 0.0 to 1.0, animate the two checkmark strokes from the
257+
// mid point outwards.
258+
final Path path = new Path();
259+
const Offset start = const Offset(_kEdgeSize * 0.15, _kEdgeSize * 0.45);
260+
const Offset mid = const Offset(_kEdgeSize * 0.4, _kEdgeSize * 0.7);
261+
const Offset end = const Offset(_kEdgeSize * 0.85, _kEdgeSize * 0.25);
262+
final Offset drawStart = Offset.lerp(start, mid, 1.0 - t);
263+
final Offset drawEnd = Offset.lerp(mid, end, t);
264+
path.moveTo(origin.dx + drawStart.dx, origin.dy + drawStart.dy);
265+
path.lineTo(origin.dx + mid.dx, origin.dy + mid.dy);
266+
path.lineTo(origin.dx + drawEnd.dx, origin.dy + drawEnd.dy);
267+
canvas.drawPath(path, paint);
268+
}
181269

270+
void _drawDash(Canvas canvas, Offset origin, double t, Paint paint) {
271+
assert(t >= 0.0 && t <= 1.0);
272+
// As t goes from 0.0 to 1.0, animate the horizontal line from the
273+
// mid point outwards.
274+
const Offset start = const Offset(_kEdgeSize * 0.2, _kEdgeSize * 0.5);
275+
const Offset mid = const Offset(_kEdgeSize * 0.5, _kEdgeSize * 0.5);
276+
const Offset end = const Offset(_kEdgeSize * 0.8, _kEdgeSize * 0.5);
277+
final Offset drawStart = Offset.lerp(start, mid, 1.0 - t);
278+
final Offset drawEnd = Offset.lerp(mid, end, t);
279+
canvas.drawLine(origin + drawStart, origin + drawEnd, paint);
280+
}
281+
282+
@override
283+
void paint(PaintingContext context, Offset offset) {
284+
final Canvas canvas = context.canvas;
182285
paintRadialReaction(canvas, offset, size.center(Offset.zero));
183286

184-
final double t = position.value;
287+
final Offset origin = offset + (size / 2.0 - const Size.square(_kEdgeSize) / 2.0);
288+
final AnimationStatus status = position.status;
289+
final double tNormalized = status == AnimationStatus.forward || status == AnimationStatus.completed
290+
? position.value
291+
: 1.0 - position.value;
185292

186-
Color borderColor = inactiveColor;
187-
if (onChanged != null)
188-
borderColor = t >= 0.25 ? activeColor : Color.lerp(inactiveColor, activeColor, t * 4.0);
293+
// Four cases: false to null, false to true, null to false, true to false
294+
if (_oldValue == false || value == false) {
295+
final double t = value == false ? 1.0 - tNormalized : tNormalized;
296+
final RRect outer = _outerRectAt(origin, t);
297+
final Paint paint = new Paint()..color = _colorAt(t);
189298

190-
final Paint paint = new Paint()
191-
..color = borderColor;
299+
if (t <= 0.5) {
300+
_drawBorder(canvas, outer, t, paint);
301+
} else {
302+
canvas.drawRRect(outer, paint);
192303

193-
final double inset = 1.0 - (t - 0.5).abs() * 2.0;
194-
final double rectSize = _kEdgeSize - inset * _kStrokeWidth;
195-
final Rect rect = new Rect.fromLTWH(offsetX + inset, offsetY + inset, rectSize, rectSize);
196-
197-
final RRect outer = new RRect.fromRectAndRadius(rect, _kEdgeRadius);
198-
if (t <= 0.5) {
199-
// Outline
200-
final RRect inner = outer.deflate(math.min(rectSize / 2.0, _kStrokeWidth + rectSize * t));
201-
canvas.drawDRRect(outer, inner, paint);
202-
} else {
203-
// Background
304+
_initStrokePaint(paint);
305+
final double tShrink = (t - 0.5) * 2.0;
306+
if (_oldValue == null)
307+
_drawDash(canvas, origin, tShrink, paint);
308+
else
309+
_drawCheck(canvas, origin, tShrink, paint);
310+
}
311+
} else { // Two cases: null to true, true to null
312+
final RRect outer = _outerRectAt(origin, 1.0);
313+
final Paint paint = new Paint() ..color = _colorAt(1.0);
204314
canvas.drawRRect(outer, paint);
205315

206-
// White inner check
207-
final double value = (t - 0.5) * 2.0;
208-
paint
209-
..color = const Color(0xFFFFFFFF)
210-
..style = PaintingStyle.stroke
211-
..strokeWidth = _kStrokeWidth;
212-
final Path path = new Path();
213-
const Offset start = const Offset(_kEdgeSize * 0.15, _kEdgeSize * 0.45);
214-
const Offset mid = const Offset(_kEdgeSize * 0.4, _kEdgeSize * 0.7);
215-
const Offset end = const Offset(_kEdgeSize * 0.85, _kEdgeSize * 0.25);
216-
final Offset drawStart = Offset.lerp(start, mid, 1.0 - value);
217-
final Offset drawEnd = Offset.lerp(mid, end, value);
218-
path.moveTo(offsetX + drawStart.dx, offsetY + drawStart.dy);
219-
path.lineTo(offsetX + mid.dx, offsetY + mid.dy);
220-
path.lineTo(offsetX + drawEnd.dx, offsetY + drawEnd.dy);
221-
canvas.drawPath(path, paint);
316+
_initStrokePaint(paint);
317+
if (tNormalized <= 0.5) {
318+
final double tShrink = 1.0 - tNormalized * 2.0;
319+
if (_oldValue == true)
320+
_drawCheck(canvas, origin, tShrink, paint);
321+
else
322+
_drawDash(canvas, origin, tShrink, paint);
323+
} else {
324+
final double tExpand = (tNormalized - 0.5) * 2.0;
325+
if (value == true)
326+
_drawCheck(canvas, origin, tExpand, paint);
327+
else
328+
_drawDash(canvas, origin, tExpand, paint);
329+
}
222330
}
223331
}
332+
333+
// TODO(hmuller): smooth segues for cases where the value changes
334+
// in the middle of position's animation cycle.
335+
// https://github.com/flutter/flutter/issues/14674
336+
337+
// TODO(hmuller): accessibility support for tristate checkboxes.
338+
// https://github.com/flutter/flutter/issues/14677
224339
}

packages/flutter/lib/src/material/radio.dart

+1
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ class _RenderRadio extends RenderToggleable {
176176
@required TickerProvider vsync,
177177
}): super(
178178
value: value,
179+
tristate: false,
179180
activeColor: activeColor,
180181
inactiveColor: inactiveColor,
181182
onChanged: onChanged,

packages/flutter/lib/src/material/switch.dart

+1
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ class _RenderSwitch extends RenderToggleable {
247247
_textDirection = textDirection,
248248
super(
249249
value: value,
250+
tristate: false,
250251
activeColor: activeColor,
251252
inactiveColor: inactiveColor,
252253
onChanged: onChanged,

0 commit comments

Comments
 (0)