Skip to content

Commit feb4c01

Browse files
victorsanniPiinks
authored andcommitted
Improve CupertinoRadio fidelity (flutter#149703)
Adds the following: * Darkens when pressed in light mode * Lightens when pressed in dark mode. * Tests that confirm `CupertinoRadio` is focusable and has correct focus colors * Tests that confirm `CupertinoRadio` uses correct default active/inactive/fill colors * Same look in disabled vs. enabled states as native macOS: | Native macOS | Flutter Before | Flutter After | --- | --- | --- | | <img width="50" alt="radio native" src="https://github.com/flutter/flutter/assets/77553258/27c8c27e-f0dc-4ad7-a8c2-361ae8b437bb"> | <img width="50" alt="flutter radio before" src="https://github.com/flutter/flutter/assets/77553258/580d9c4b-0f0d-457e-851f-73450738ee16"> | <img width="50" alt="flutter radio after" src="https://github.com/flutter/flutter/assets/77553258/da6ae21b-87f8-45d8-a2d2-da70ff4853a1"> | * Same look of an unselected radio button in dark mode as native macOS: | Native light mode | Flutter before light mode | Flutter after light mode | Native dark mode | Flutter before dark mode | Flutter after dark mode --- | --- | --- | --- | --- | --- | | <img width="23" alt="native radio light" src="https://github.com/flutter/flutter/assets/77553258/b52fc18b-e10d-4205-b10b-1536fbbf1ca0"> | <img width="23" alt="flutter radio after light" src="https://github.com/flutter/flutter/assets/77553258/54294523-8254-479c-b668-77927a8295f1"> | <img width="23" alt="flutter radio light" src="https://github.com/flutter/flutter/assets/77553258/8472deee-e5ce-4d39-9207-d788ad7f34f4"> | <img width="23" alt="native radio dark" src="https://github.com/flutter/flutter/assets/77553258/44143099-6ab4-4fb8-8a94-ebb1386022c9"> | <img width="23" alt="flutter radio before dark" src="https://github.com/flutter/flutter/assets/77553258/3411d9fb-fc7f-4b20-86a5-34fda167d5b9"> | <img width="23" alt="flutter radio dark" src="https://github.com/flutter/flutter/assets/77553258/39ea3649-142e-43ad-9681-24e1216e0987"> | ## Light mode (with focus highlight) | Native light mode | Flutter before light mode | Flutter after light mode | --- | --- | --- | | <img width="70" alt="native radio light mode" src="https://github.com/user-attachments/assets/914b9f1f-5819-4c5b-8739-8498a72b337f"> | <img width="70" alt="radio flutter focus before" src="https://github.com/user-attachments/assets/3129fca3-3310-4b2b-bcf3-98aa8f049911"> | <img width="70" alt="radio flutter focus after" src="https://github.com/user-attachments/assets/7a2089d9-b2b5-4ff0-9db9-444455301146"> | ## Dark mode | Native dark mode | Flutter before dark mode | Flutter after dark mode | --- | --- | --- | | <img width="70" alt="native radio dark mode" src="https://github.com/user-attachments/assets/4da3c055-ce89-4f37-8fcd-d4cbbc4031a0"> | <img width="70" alt="flutter before radio dark mode" src="https://github.com/user-attachments/assets/36b5f36a-f1d9-4c32-8493-3533a749cf5d"> | <img width="70" alt="flutter radio dark mode after" src="https://github.com/user-attachments/assets/28828e01-bb2f-4217-9756-2766be3919a6"> | ## Disabled light mode | Native | Flutter before | Flutter after | --- | --- | --- | | <img width="120" alt="light disabled radio native" src="https://github.com/user-attachments/assets/bf6d2561-5dcf-4882-afac-6b639fa949b0"> | <img width="120" alt="Screenshot 2024-07-30 at 3 13 30 PM" src="https://github.com/user-attachments/assets/3efc978c-fa58-44e8-877a-ea29778ea384"> | <img width="120" alt="light disabled radio flutter after" src="https://github.com/user-attachments/assets/b2c2e30a-cb8d-40d0-aa6f-75a98caa4829"> | ## Disabled dark mode | Native | Flutter before | Flutter after | --- | --- | --- | | <img width="120" alt="dark disabled radio native" src="https://github.com/user-attachments/assets/feedccc7-9802-4b0c-8038-c9eb771b0eb0"> | <img width="120" alt="Screenshot 2024-07-30 at 3 13 30 PM" src="https://github.com/user-attachments/assets/6d2f03f7-7216-4850-8c4f-f79ae05bb9da"> | <img width="136" alt="dark disabled radio flutter after" src="https://github.com/user-attachments/assets/5e03d4fc-4b8e-4518-b429-6bb58f6d988d"> | `CupertinoRadio` is missing a tristate/mixed state, but [Apple's latest HIG specs discourages its use](https://developer.apple.com/design/human-interface-guidelines/toggles#Radio-buttons). Fixes flutter#151994 ## 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 7518980 commit feb4c01

File tree

2 files changed

+540
-38
lines changed

2 files changed

+540
-38
lines changed

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

Lines changed: 190 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import 'package:flutter/foundation.dart';
1313
import 'package:flutter/widgets.dart';
1414

1515
import 'colors.dart';
16+
import 'theme.dart';
1617

1718
// Examples can assume:
1819
// late BuildContext context;
@@ -30,6 +31,37 @@ const double _kCupertinoFocusColorOpacity = 0.80;
3031
const double _kCupertinoFocusColorBrightness = 0.69;
3132
const double _kCupertinoFocusColorSaturation = 0.835;
3233

34+
// Eyeballed from a radio on a physical Macbook Pro running macOS version 14.5.
35+
final Color _kDisabledOuterColor = CupertinoColors.white.withOpacity(0.50);
36+
const Color _kDisabledInnerColor = CupertinoDynamicColor.withBrightness(
37+
color: Color.fromARGB(64, 0, 0, 0),
38+
darkColor: Color.fromARGB(64, 255, 255, 255),
39+
);
40+
const Color _kDisabledBorderColor = CupertinoDynamicColor.withBrightness(
41+
color: Color.fromARGB(64, 0, 0, 0),
42+
darkColor: Color.fromARGB(64, 0, 0, 0),
43+
);
44+
const CupertinoDynamicColor _kDefaultBorderColor = CupertinoDynamicColor.withBrightness(
45+
color: Color.fromARGB(255, 209, 209, 214),
46+
darkColor: Color.fromARGB(64, 0, 0, 0),
47+
);
48+
const CupertinoDynamicColor _kDefaultInnerColor = CupertinoDynamicColor.withBrightness(
49+
color: CupertinoColors.white,
50+
darkColor: Color.fromARGB(255, 222, 232, 248),
51+
);
52+
const CupertinoDynamicColor _kDefaultOuterColor = CupertinoDynamicColor.withBrightness(
53+
color: CupertinoColors.activeBlue,
54+
darkColor: Color.fromARGB(255, 50, 100, 215),
55+
);
56+
const double _kPressedOverlayOpacity = 0.15;
57+
const double _kCheckmarkStrokeWidth = 2.0;
58+
const double _kFocusOutlineStrokeWidth = 3.0;
59+
const double _kBorderOutlineStrokeWidth = 0.3;
60+
// In dark mode, the outer color of a radio is an opacity gradient of the
61+
// background color.
62+
const List<double> _kDarkGradientOpacities = <double>[0.14, 0.29];
63+
const List<double> _kDisabledDarkGradientOpacities = <double>[0.08, 0.14];
64+
3365
/// A macOS-style radio button.
3466
///
3567
/// Used to select between a number of mutually exclusive values. When one radio
@@ -250,24 +282,66 @@ class _CupertinoRadioState<T> extends State<CupertinoRadio<T>> with TickerProvid
250282
}
251283
}
252284

285+
WidgetStateProperty<Color> get _defaultOuterColor {
286+
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
287+
if (states.contains(WidgetState.disabled)) {
288+
return CupertinoDynamicColor.resolve(_kDisabledOuterColor, context);
289+
}
290+
if (states.contains(WidgetState.selected)) {
291+
return widget.activeColor ?? CupertinoDynamicColor.resolve(_kDefaultOuterColor, context);
292+
}
293+
return widget.inactiveColor ?? CupertinoColors.white;
294+
});
295+
}
296+
297+
WidgetStateProperty<Color> get _defaultInnerColor {
298+
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
299+
if (states.contains(WidgetState.disabled) && states.contains(WidgetState.selected)) {
300+
return widget.fillColor ?? CupertinoDynamicColor.resolve(_kDisabledInnerColor, context);
301+
}
302+
if (states.contains(WidgetState.selected)) {
303+
return widget.fillColor ?? CupertinoDynamicColor.resolve(_kDefaultInnerColor, context);
304+
}
305+
return CupertinoColors.white;
306+
});
307+
}
308+
309+
WidgetStateProperty<Color> get _defaultBorderColor {
310+
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
311+
if ((states.contains(WidgetState.selected) || states.contains(WidgetState.focused))
312+
&& !states.contains(WidgetState.disabled)) {
313+
return CupertinoColors.transparent;
314+
}
315+
if (states.contains(WidgetState.disabled)) {
316+
return CupertinoDynamicColor.resolve(_kDisabledBorderColor, context);
317+
}
318+
return CupertinoDynamicColor.resolve(_kDefaultBorderColor, context);
319+
});
320+
}
321+
253322
@override
254323
Widget build(BuildContext context) {
255-
final Color effectiveActiveColor = widget.activeColor
256-
?? CupertinoColors.activeBlue;
257-
final Color effectiveInactiveColor = widget.inactiveColor
258-
?? CupertinoColors.white;
324+
// Colors need to be resolved in selected and non selected states separately.
325+
final Set<WidgetState> activeStates = states..add(WidgetState.selected);
326+
final Set<WidgetState> inactiveStates = states..remove(WidgetState.selected);
327+
328+
// Since the states getter always makes a new set, make a copy to use
329+
// throughout the lifecycle of this build method.
330+
final Set<WidgetState> currentStates = states;
331+
332+
final Color effectiveActiveColor = _defaultOuterColor.resolve(activeStates);
259333

260-
final Color effectiveFocusOverlayColor = widget.focusColor
261-
?? HSLColor
262-
.fromColor(effectiveActiveColor.withOpacity(_kCupertinoFocusColorOpacity))
263-
.withLightness(_kCupertinoFocusColorBrightness)
264-
.withSaturation(_kCupertinoFocusColorSaturation)
265-
.toColor();
334+
final Color effectiveInactiveColor = _defaultOuterColor.resolve(inactiveStates);
266335

267-
final Color effectiveActivePressedOverlayColor =
268-
HSLColor.fromColor(effectiveActiveColor).withLightness(0.45).toColor();
336+
final Color effectiveFocusOverlayColor = widget.focusColor ?? HSLColor
337+
.fromColor(effectiveActiveColor.withOpacity(_kCupertinoFocusColorOpacity))
338+
.withLightness(_kCupertinoFocusColorBrightness)
339+
.withSaturation(_kCupertinoFocusColorSaturation)
340+
.toColor();
269341

270-
final Color effectiveFillColor = widget.fillColor ?? CupertinoColors.white;
342+
final Color effectiveFillColor = _defaultInnerColor.resolve(currentStates);
343+
344+
final Color effectiveBorderColor = _defaultBorderColor.resolve(currentStates);
271345

272346
final WidgetStateProperty<MouseCursor> effectiveMouseCursor =
273347
WidgetStateProperty.resolveWith<MouseCursor>((Set<WidgetState> states) {
@@ -303,14 +377,19 @@ class _CupertinoRadioState<T> extends State<CupertinoRadio<T>> with TickerProvid
303377
onFocusChange: onFocusChange,
304378
size: _size,
305379
painter: _painter
380+
..position = position
381+
..reaction = reaction
306382
..focusColor = effectiveFocusOverlayColor
307383
..downPosition = downPosition
308384
..isFocused = focused
309-
..activeColor = downPosition != null ? effectiveActivePressedOverlayColor : effectiveActiveColor
385+
..activeColor = effectiveActiveColor
310386
..inactiveColor = effectiveInactiveColor
311387
..fillColor = effectiveFillColor
312388
..value = value
313-
..checkmarkStyle = widget.useCheckmarkStyle,
389+
..checkmarkStyle = widget.useCheckmarkStyle
390+
..isActive = widget.onChanged != null
391+
..borderColor = effectiveBorderColor
392+
..brightness = CupertinoTheme.of(context).brightness,
314393
),
315394
);
316395
}
@@ -347,22 +426,65 @@ class _RadioPainter extends ToggleablePainter {
347426
notifyListeners();
348427
}
349428

429+
Brightness? get brightness => _brightness;
430+
Brightness? _brightness;
431+
set brightness(Brightness? value) {
432+
if (_brightness == value) {
433+
return;
434+
}
435+
_brightness = value;
436+
notifyListeners();
437+
}
438+
439+
Color get borderColor => _borderColor!;
440+
Color? _borderColor;
441+
set borderColor(Color value) {
442+
if (_borderColor == value) {
443+
return;
444+
}
445+
_borderColor = value;
446+
notifyListeners();
447+
}
448+
449+
void _drawPressedOverlay(Canvas canvas, Offset center, double radius) {
450+
final Paint pressedPaint = Paint()
451+
..color = brightness == Brightness.light
452+
? CupertinoColors.black.withOpacity(_kPressedOverlayOpacity)
453+
: CupertinoColors.white.withOpacity(_kPressedOverlayOpacity);
454+
canvas.drawCircle(center, radius, pressedPaint);
455+
}
456+
457+
void _drawFillGradient(Canvas canvas, Offset center, double radius, Color topColor, Color bottomColor) {
458+
final LinearGradient fillGradient = LinearGradient(
459+
begin: Alignment.topCenter,
460+
end: Alignment.bottomCenter,
461+
colors: <Color>[topColor, bottomColor],
462+
);
463+
final Rect circleRect = Rect.fromCircle(center: center, radius: radius);
464+
final Paint gradientPaint = Paint()
465+
..shader = fillGradient.createShader(circleRect);
466+
canvas.drawPath(Path()..addOval(circleRect), gradientPaint);
467+
}
468+
469+
void _drawOuterBorder(Canvas canvas, Offset center) {
470+
final Paint borderPaint = Paint()
471+
..style = PaintingStyle.stroke
472+
..color = borderColor
473+
..strokeWidth = _kBorderOutlineStrokeWidth;
474+
canvas.drawCircle(center, _kOuterRadius, borderPaint);
475+
}
476+
350477
@override
351478
void paint(Canvas canvas, Size size) {
352479
final Offset center = (Offset.zero & size).center;
353480

354-
final Paint paint = Paint()
355-
..color = inactiveColor
356-
..style = PaintingStyle.fill
357-
..strokeWidth = 0.1;
358-
359481
if (checkmarkStyle) {
360482
if (value ?? false) {
361483
final Path path = Path();
362484
final Paint checkPaint = Paint()
363485
..color = activeColor
364486
..style = PaintingStyle.stroke
365-
..strokeWidth = 2
487+
..strokeWidth = _kCheckmarkStrokeWidth
366488
..strokeCap = StrokeCap.round;
367489
final double width = _size.width;
368490
final Offset origin = Offset(center.dx - (width/2), center.dy - (width/2));
@@ -377,27 +499,57 @@ class _RadioPainter extends ToggleablePainter {
377499
canvas.drawPath(path, checkPaint);
378500
}
379501
} else {
380-
// Outer border
381-
canvas.drawCircle(center, _kOuterRadius, paint);
382-
383-
paint.style = PaintingStyle.stroke;
384-
paint.color = CupertinoColors.inactiveGray;
385-
canvas.drawCircle(center, _kOuterRadius, paint);
386-
387502
if (value ?? false) {
388-
paint.style = PaintingStyle.fill;
389-
paint.color = activeColor;
390-
canvas.drawCircle(center, _kOuterRadius, paint);
391-
paint.color = fillColor;
392-
canvas.drawCircle(center, _kInnerRadius, paint);
503+
final Paint outerPaint = Paint()..color = activeColor;
504+
// Draw a gradient in dark mode if the radio is disabled.
505+
if (brightness == Brightness.dark && !isActive) {
506+
_drawFillGradient(
507+
canvas,
508+
center,
509+
_kOuterRadius,
510+
outerPaint.color.withOpacity(isActive ? _kDarkGradientOpacities[0] : _kDisabledDarkGradientOpacities[0]),
511+
outerPaint.color.withOpacity(isActive ? _kDarkGradientOpacities[1] : _kDisabledDarkGradientOpacities[1]),
512+
);
513+
} else {
514+
canvas.drawCircle(center, _kOuterRadius, outerPaint);
515+
}
516+
// The outer circle's opacity changes when the radio is pressed.
517+
if (downPosition != null) {
518+
_drawPressedOverlay(canvas, center, _kOuterRadius);
519+
}
520+
final Paint innerPaint = Paint()..color = fillColor;
521+
canvas.drawCircle(center, _kInnerRadius, innerPaint);
522+
// Draw an outer border if the radio is disabled and selected.
523+
if (!isActive) {
524+
_drawOuterBorder(canvas, center);
525+
}
526+
} else {
527+
final Paint paint = Paint();
528+
paint.color = isActive ? inactiveColor : _kDisabledOuterColor;
529+
if (brightness == Brightness.dark) {
530+
_drawFillGradient(
531+
canvas,
532+
center,
533+
_kOuterRadius,
534+
paint.color.withOpacity(isActive ? _kDarkGradientOpacities[0] : _kDisabledDarkGradientOpacities[0]),
535+
paint.color.withOpacity(isActive ? _kDarkGradientOpacities[1] : _kDisabledDarkGradientOpacities[1]),
536+
);
537+
} else {
538+
canvas.drawCircle(center, _kOuterRadius, paint);
539+
}
540+
// The entire circle's opacity changes when the radio is pressed.
541+
if (downPosition != null) {
542+
_drawPressedOverlay(canvas, center, _kOuterRadius);
543+
}
544+
_drawOuterBorder(canvas, center);
393545
}
394546
}
395-
396547
if (isFocused) {
397-
paint.style = PaintingStyle.stroke;
398-
paint.color = focusColor;
399-
paint.strokeWidth = 3.0;
400-
canvas.drawCircle(center, _kOuterRadius + 1.5, paint);
548+
final Paint focusPaint = Paint()
549+
..style = PaintingStyle.stroke
550+
..color = focusColor
551+
..strokeWidth = _kFocusOutlineStrokeWidth;
552+
canvas.drawCircle(center, _kOuterRadius + _kFocusOutlineStrokeWidth / 2, focusPaint);
401553
}
402554
}
403555
}

0 commit comments

Comments
 (0)