Skip to content

Commit 303cbb7

Browse files
victorsanniPiinks
andauthored
Improve CupertinoCheckbox fidelity (#151441)
**NOTE: Previous [PR](flutter/flutter#148804) was closed because of a bad merge leading to pollution with unrelated commits.** This PR improves on the look and feel of `CupertinoCheckbox` to more closely match native iOS/macOS checkboxes. Adds the following updates from a native macOS checkbox: * Fill color of an unchecked checkbox is a linear gradient that goes from darker at the top to lighter at the bottom in dark mode * Size of box reduced from 18.0 to 14.0 * Stroke width of check reduced from 2.5 to 2.0 * Border color changed from solid black to gray black in light mode and a transparent gray in dark mode * In light mode, checkbox darkens when pressed * In dark mode, checkbox lightens when pressed * Default blue color of a checked checkbox is darker in dark mode ### Light Mode | Native macOS | Flutter Before | Flutter After | | ----------- | ----------- | ----------- | | <img width="63" alt="native checkbox" src="https://github.com/flutter/flutter/assets/77553258/d57d4c78-2e67-49fb-9491-a5acee3782a7"> | <img width="66" alt="Screenshot 2024-06-27 at 10 23 18 AM" src="https://github.com/flutter/flutter/assets/77553258/31c913ff-d36f-4eb5-b737-3a9117bd7eff"> | <img width="66" alt="Screenshot 2024-06-27 at 10 39 22 AM" src="https://github.com/flutter/flutter/assets/77553258/ace8ef29-efae-4049-8f78-13fd39851947"> | ### Dark Mode - Checked | Native macOS | Flutter Before | Flutter After | | ----------- | ----------- | ----------- | | <img width="22" alt="native light" src="https://github.com/user-attachments/assets/fc52d5e1-7ab0-4a5d-b0fa-5b5bee3ed39d"> | <img width="22" alt="flutter before light" src="https://github.com/user-attachments/assets/16e033a1-d2dd-4fb2-a5a5-f730c5f7cdc7"> | <img width="22" alt="flutter after light" src="https://github.com/user-attachments/assets/8c0cff99-930e-4f5e-8540-e64294c1b4fa"> | ### Dark Mode - Unchecked | Native macOS | Flutter Before | Flutter After | | ----------- | ----------- | ----------- | | <img width="22" alt="native dark mode" src="https://github.com/user-attachments/assets/333280a0-85db-4464-9663-03ef7eafc270"> | <img width="22" alt="flutter before dark mode" src="https://github.com/user-attachments/assets/a46e01ec-0d0b-4bb7-8d08-4b2723424a12"> | <img width="22" alt="flutter dark mode" src="https://github.com/user-attachments/assets/a70ae4ad-f1ad-4441-a416-350cbdc32679"> | ### Light Mode - Disabled | Native macOS | Flutter Before | Flutter After | | --- | --- | --- | | <img width="121" alt="native disabled checkbox" src="https://github.com/user-attachments/assets/ed050d14-efec-49dd-82b6-1e7ed7fa99f9"> | <img width="136" alt="flutter b4 disabled checkbox" src="https://github.com/user-attachments/assets/564918cf-f936-448d-b975-7bf9248bbf35"> | <img width="156" alt="flutter disabled checkbox" src="https://github.com/user-attachments/assets/82f672a7-12e8-469c-99af-9f94c959df8f"> | ### Dark Mode - Disabled | Native macOS | Flutter Before | Flutter After | | --- | --- | --- | | <img width="110" alt="disabled dark checkbox native" src="https://github.com/user-attachments/assets/02a43b3f-5619-4b05-9066-2fd43a58c956"> | <img width="136" alt="disabled dark checkbox flutter b4" src="https://github.com/user-attachments/assets/3a3db322-2002-4808-adc0-b10a7ab42381"> | <img width="140" alt="disabled dark checkbox flutter" src="https://github.com/user-attachments/assets/cb91955a-8302-4dc7-8050-221fa2a7045f"> Fixes #148719. Related PR exploring these changes: #147892 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [Features we expect every widget to implement]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat [Data Driven Fixes]: https://github.com/flutter/flutter/wiki/Data-driven-Fixes --------- Co-authored-by: Kate Lovett <katelovett@google.com>
1 parent 0b41781 commit 303cbb7

File tree

2 files changed

+465
-53
lines changed

2 files changed

+465
-53
lines changed

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

Lines changed: 173 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
/// @docImport 'switch.dart';
99
library;
1010

11+
import 'package:flutter/foundation.dart';
1112
import 'package:flutter/widgets.dart';
1213

1314
import 'colors.dart';
1415
import 'constants.dart';
16+
import 'theme.dart';
1517

1618
// Examples can assume:
1719
// bool _throwShotAway = false;
@@ -23,6 +25,33 @@ const double _kCupertinoFocusColorOpacity = 0.80;
2325
const double _kCupertinoFocusColorBrightness = 0.69;
2426
const double _kCupertinoFocusColorSaturation = 0.835;
2527

28+
// Eyeballed from a checkbox on a physical Macbook Pro running macOS version 14.5.
29+
const Color _kDisabledCheckColor = CupertinoDynamicColor.withBrightness(
30+
color: Color.fromARGB(64, 0, 0, 0),
31+
darkColor: Color.fromARGB(64, 255, 255, 255),
32+
);
33+
const Color _kDisabledBorderColor = CupertinoDynamicColor.withBrightness(
34+
color: Color.fromARGB(13, 0, 0, 0),
35+
darkColor: Color.fromARGB(13, 0, 0, 0),
36+
);
37+
const CupertinoDynamicColor _kDefaultBorderColor = CupertinoDynamicColor.withBrightness(
38+
color: Color.fromARGB(255, 209, 209, 214),
39+
darkColor: Color.fromARGB(50, 128, 128, 128),
40+
);
41+
const CupertinoDynamicColor _kDefaultFillColor = CupertinoDynamicColor.withBrightness(
42+
color: CupertinoColors.activeBlue,
43+
darkColor: Color.fromARGB(255, 50, 100, 215),
44+
);
45+
const Color _kDefaultCheckColor = CupertinoDynamicColor.withBrightness(
46+
color: CupertinoColors.white,
47+
darkColor: Color.fromARGB(255, 222, 232, 248),
48+
);
49+
const double _kPressedOverlayOpacity = 0.15;
50+
// In dark mode, the fill color of a checkbox is an opacity gradient of the
51+
// background color.
52+
const List<double> _kDarkGradientOpacities = <double>[0.14, 0.29];
53+
const List<double> _kDisabledDarkGradientOpacities = <double>[0.08, 0.14];
54+
2655
/// A macOS style checkbox.
2756
///
2857
/// The checkbox itself does not maintain any state. Instead, when the state of
@@ -133,7 +162,8 @@ class CupertinoCheckbox extends StatefulWidget {
133162

134163
/// The color to use for the check icon when this checkbox is checked.
135164
///
136-
/// If null, then the value of [CupertinoColors.white] is used.
165+
/// If null, then the value of [CupertinoColors.white] is used if the checkbox
166+
/// is enabled. If the checkbox is disabled, a grey-black color is used.
137167
final Color? checkColor;
138168

139169
/// If true, the checkbox's [value] can be true, false, or null.
@@ -162,8 +192,25 @@ class CupertinoCheckbox extends StatefulWidget {
162192

163193
/// The color and width of the checkbox's border.
164194
///
165-
/// If this property is null, then the side defaults to a one pixel wide
166-
/// black, solid border.
195+
/// This property can be a [WidgetStateBorderSide] that can
196+
/// specify different border color and widths depending on the
197+
/// checkbox's state.
198+
///
199+
/// Resolves in the following states:
200+
/// * [WidgetState.pressed].
201+
/// * [WidgetState.selected].
202+
/// * [WidgetState.hovered].
203+
/// * [WidgetState.focused].
204+
/// * [WidgetState.disabled].
205+
/// * [WidgetState.error].
206+
///
207+
/// If this property is not a [WidgetStateBorderSide] and it is
208+
/// non-null, then it is only rendered when the checkbox's value is
209+
/// false. The difference in interpretation is for backwards
210+
/// compatibility.
211+
///
212+
/// If this property is null and the checkbox's value is false, then the side
213+
/// defaults to a one pixel wide grey-black border.
167214
final BorderSide? side;
168215

169216
/// The shape of the checkbox.
@@ -180,7 +227,7 @@ class CupertinoCheckbox extends StatefulWidget {
180227
final String? semanticLabel;
181228

182229
/// The width of a checkbox widget.
183-
static const double width = 18.0;
230+
static const double width = 14.0;
184231

185232
@override
186233
State<CupertinoCheckbox> createState() => _CupertinoCheckboxState();
@@ -221,19 +268,67 @@ class _CupertinoCheckboxState extends State<CupertinoCheckbox> with TickerProvid
221268
@override
222269
bool? get value => widget.value;
223270

224-
void onFocusChange(bool value) {
225-
if (focused != value) {
226-
focused = value;
271+
WidgetStateProperty<Color> get _defaultFillColor {
272+
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
273+
if (states.contains(WidgetState.disabled)) {
274+
return CupertinoColors.white.withOpacity(0.5);
275+
}
276+
if (states.contains(WidgetState.selected)) {
277+
return widget.activeColor ?? CupertinoDynamicColor.resolve(_kDefaultFillColor, context);
278+
}
279+
return CupertinoColors.white;
280+
});
281+
}
282+
283+
WidgetStateProperty<Color> get _defaultCheckColor {
284+
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
285+
if (states.contains(WidgetState.disabled) && states.contains(WidgetState.selected)) {
286+
return widget.checkColor ?? CupertinoDynamicColor.resolve(_kDisabledCheckColor, context);
287+
}
288+
if (states.contains(WidgetState.selected)) {
289+
return widget.checkColor ?? CupertinoDynamicColor.resolve(_kDefaultCheckColor, context);
290+
}
291+
return CupertinoColors.white;
292+
});
293+
}
294+
295+
WidgetStateProperty<BorderSide> get _defaultSide {
296+
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
297+
if ((states.contains(WidgetState.selected) || states.contains(WidgetState.focused))
298+
&& !states.contains(WidgetState.disabled)) {
299+
return const BorderSide(width: 0.0, color: CupertinoColors.transparent);
300+
}
301+
if (states.contains(WidgetState.disabled)) {
302+
return BorderSide(color: CupertinoDynamicColor.resolve(_kDisabledBorderColor, context));
303+
}
304+
return BorderSide(color: CupertinoDynamicColor.resolve(_kDefaultBorderColor, context));
305+
});
306+
}
307+
308+
BorderSide? _resolveSide(BorderSide? side, Set<WidgetState> states) {
309+
if (side is WidgetStateBorderSide) {
310+
return WidgetStateProperty.resolveAs<BorderSide?>(side, states);
227311
}
312+
if (!states.contains(WidgetState.selected)) {
313+
return side;
314+
}
315+
return null;
228316
}
229317

230318
@override
231319
Widget build(BuildContext context) {
232-
final Color effectiveActiveColor = widget.activeColor
233-
?? CupertinoColors.activeBlue;
234-
final Color? inactiveColor = widget.inactiveColor;
235-
final Color effectiveInactiveColor = inactiveColor
236-
?? CupertinoColors.inactiveGray;
320+
// Colors need to be resolved in selected and non selected states separately.
321+
final Set<WidgetState> activeStates = states..add(WidgetState.selected);
322+
final Set<WidgetState> inactiveStates = states..remove(WidgetState.selected);
323+
324+
// Since the states getter always makes a new set, make a copy to use
325+
// throughout the lifecycle of this build method.
326+
final Set<WidgetState> currentStates = states;
327+
328+
final Color effectiveActiveColor = _defaultFillColor.resolve(activeStates);
329+
330+
final BorderSide effectiveBorderSide = _resolveSide(widget.side, currentStates)
331+
?? _defaultSide.resolve(currentStates);
237332

238333
final Color effectiveFocusOverlayColor = widget.focusColor
239334
?? HSLColor
@@ -242,32 +337,32 @@ class _CupertinoCheckboxState extends State<CupertinoCheckbox> with TickerProvid
242337
.withSaturation(_kCupertinoFocusColorSaturation)
243338
.toColor();
244339

245-
final Color effectiveCheckColor = widget.checkColor
246-
?? CupertinoColors.white;
247-
248340
return Semantics(
249341
label: widget.semanticLabel,
250342
checked: widget.value ?? false,
251343
mixed: widget.tristate ? widget.value == null : null,
252344
child: buildToggleable(
253345
focusNode: widget.focusNode,
254346
autofocus: widget.autofocus,
255-
onFocusChange: onFocusChange,
256347
size: const Size.square(kMinInteractiveDimensionCupertino),
257348
painter: _painter
349+
..position = position
350+
..reaction = reaction
258351
..focusColor = effectiveFocusOverlayColor
259-
..isFocused = focused
260352
..downPosition = downPosition
353+
..isFocused = currentStates.contains(WidgetState.focused)
354+
..isHovered = currentStates.contains(WidgetState.hovered)
261355
..activeColor = effectiveActiveColor
262-
..inactiveColor = effectiveInactiveColor
263-
..checkColor = effectiveCheckColor
356+
..inactiveColor = _defaultFillColor.resolve(inactiveStates)
357+
..checkColor = _defaultCheckColor.resolve(currentStates)
264358
..value = value
265359
..previousValue = _previousValue
266360
..isActive = widget.onChanged != null
267361
..shape = widget.shape ?? RoundedRectangleBorder(
268362
borderRadius: BorderRadius.circular(4.0),
269363
)
270-
..side = widget.side,
364+
..side = effectiveBorderSide
365+
..brightness = CupertinoTheme.of(context).brightness
271366
),
272367
);
273368
}
@@ -314,16 +409,26 @@ class _CheckboxPainter extends ToggleablePainter {
314409
notifyListeners();
315410
}
316411

317-
BorderSide? get side => _side;
412+
BorderSide get side => _side!;
318413
BorderSide? _side;
319-
set side(BorderSide? value) {
414+
set side(BorderSide value) {
320415
if (_side == value) {
321416
return;
322417
}
323418
_side = value;
324419
notifyListeners();
325420
}
326421

422+
Brightness? get brightness => _brightness;
423+
Brightness? _brightness;
424+
set brightness(Brightness? value) {
425+
if (_brightness == value) {
426+
return;
427+
}
428+
_brightness = value;
429+
notifyListeners();
430+
}
431+
327432
Rect _outerRectAt(Offset origin) {
328433
const double size = CupertinoCheckbox.width;
329434
final Rect rect = Rect.fromLTWH(origin.dx, origin.dy, size, size);
@@ -341,12 +446,36 @@ class _CheckboxPainter extends ToggleablePainter {
341446
return Paint()
342447
..color = checkColor
343448
..style = PaintingStyle.stroke
344-
..strokeWidth = 2.5
449+
..strokeWidth = 2.0
345450
..strokeCap = StrokeCap.round;
346451
}
347452

348-
void _drawBox(Canvas canvas, Rect outer, Paint paint, BorderSide? side, bool fill) {
349-
if (fill) {
453+
// Draw a gradient from the top to the bottom of the checkbox.
454+
void _drawFillGradient(Canvas canvas, Rect outer, Color topColor, Color bottomColor) {
455+
final LinearGradient fillGradient = LinearGradient(
456+
begin: Alignment.topCenter,
457+
end: Alignment.bottomCenter,
458+
// Eyeballed from a checkbox on a physical Macbook Pro running macOS version 14.5.
459+
colors: <Color>[
460+
topColor,
461+
bottomColor,
462+
],
463+
);
464+
final Paint gradientPaint = Paint()
465+
..shader = fillGradient.createShader(outer);
466+
canvas.drawPath(shape.getOuterPath(outer), gradientPaint);
467+
}
468+
469+
void _drawBox(Canvas canvas, Rect outer, Paint paint, BorderSide? side, bool value) {
470+
// Draw a gradient in dark mode except when the checkbox is enabled and checked.
471+
if (brightness == Brightness.dark && !(isActive && value)) {
472+
_drawFillGradient(
473+
canvas,
474+
outer,
475+
paint.color.withOpacity(isActive ? _kDarkGradientOpacities[0] : _kDisabledDarkGradientOpacities[0]),
476+
paint.color.withOpacity(isActive ? _kDarkGradientOpacities[1] : _kDisabledDarkGradientOpacities[1]),
477+
);
478+
} else {
350479
canvas.drawPath(shape.getOuterPath(outer), paint);
351480
}
352481
if (side != null) {
@@ -359,12 +488,11 @@ class _CheckboxPainter extends ToggleablePainter {
359488
// The ratios for the offsets below were found from looking at the checkbox
360489
// examples on in the HIG docs. The distance from the needed point to the
361490
// edge was measured, then divided by the total width.
362-
const Offset start = Offset(CupertinoCheckbox.width * 0.25, CupertinoCheckbox.width * 0.52);
363-
const Offset mid = Offset(CupertinoCheckbox.width * 0.46, CupertinoCheckbox.width * 0.75);
364-
const Offset end = Offset(CupertinoCheckbox.width * 0.72, CupertinoCheckbox.width * 0.29);
491+
const Offset start = Offset(CupertinoCheckbox.width * 0.22, CupertinoCheckbox.width * 0.54);
492+
const Offset mid = Offset(CupertinoCheckbox.width * 0.40, CupertinoCheckbox.width * 0.75);
493+
const Offset end = Offset(CupertinoCheckbox.width * 0.78, CupertinoCheckbox.width * 0.25);
365494
path.moveTo(origin.dx + start.dx, origin.dy + start.dy);
366495
path.lineTo(origin.dx + mid.dx, origin.dy + mid.dy);
367-
canvas.drawPath(path, paint);
368496
path.moveTo(origin.dx + mid.dx, origin.dy + mid.dy);
369497
path.lineTo(origin.dx + end.dx, origin.dy + end.dy);
370498
canvas.drawPath(path, paint);
@@ -382,33 +510,34 @@ class _CheckboxPainter extends ToggleablePainter {
382510
void paint(Canvas canvas, Size size) {
383511
final Paint strokePaint = _createStrokePaint();
384512
final Offset origin = size / 2.0 - const Size.square(CupertinoCheckbox.width) / 2.0 as Offset;
385-
386513
final Rect outer = _outerRectAt(origin);
387514
final Paint paint = Paint()..color = _colorAt(value ?? true);
388515

389-
if (value == false) {
390-
391-
final BorderSide border = side ?? BorderSide(color: paint.color);
392-
_drawBox(canvas, outer, paint, border, false);
393-
} else {
394-
395-
_drawBox(canvas, outer, paint, side, true);
396-
if (value ?? false) {
516+
switch (value) {
517+
case false:
518+
_drawBox(canvas, outer, paint, side, value ?? true);
519+
case true:
520+
_drawBox(canvas, outer, paint, side, value ?? true);
397521
_drawCheck(canvas, origin, strokePaint);
398-
} else {
522+
case null:
523+
_drawBox(canvas, outer, paint, side, value ?? true);
399524
_drawDash(canvas, origin, strokePaint);
400-
}
401525
}
402-
526+
// The checkbox's opacity changes when pressed.
527+
if (downPosition != null) {
528+
final Paint pressedPaint = Paint()
529+
..color = brightness == Brightness.light
530+
? CupertinoColors.black.withOpacity(_kPressedOverlayOpacity)
531+
: CupertinoColors.white.withOpacity(_kPressedOverlayOpacity);
532+
canvas.drawPath(shape.getOuterPath(outer), pressedPaint);
533+
}
403534
if (isFocused) {
404535
final Rect focusOuter = outer.inflate(1);
405-
406536
final Paint borderPaint = Paint()
407537
..color = focusColor
408538
..style = PaintingStyle.stroke
409539
..strokeWidth = 3.5;
410-
411-
_drawBox(canvas, focusOuter, borderPaint, side, true);
540+
_drawBox(canvas, focusOuter, borderPaint, side, value ?? true);
412541
}
413542
}
414543
}

0 commit comments

Comments
 (0)