Improve CupertinoRadio fidelity (#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 https://github.com/flutter/flutter/issues/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>
This commit is contained in:
Victor Sanni 2024-08-02 18:32:17 -07:00 committed by GitHub
parent 9893ec99d0
commit 6b73de27bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 540 additions and 38 deletions

View File

@ -13,6 +13,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'colors.dart';
import 'theme.dart';
// Examples can assume:
// late BuildContext context;
@ -30,6 +31,37 @@ const double _kCupertinoFocusColorOpacity = 0.80;
const double _kCupertinoFocusColorBrightness = 0.69;
const double _kCupertinoFocusColorSaturation = 0.835;
// Eyeballed from a radio on a physical Macbook Pro running macOS version 14.5.
final Color _kDisabledOuterColor = CupertinoColors.white.withOpacity(0.50);
const Color _kDisabledInnerColor = CupertinoDynamicColor.withBrightness(
color: Color.fromARGB(64, 0, 0, 0),
darkColor: Color.fromARGB(64, 255, 255, 255),
);
const Color _kDisabledBorderColor = CupertinoDynamicColor.withBrightness(
color: Color.fromARGB(64, 0, 0, 0),
darkColor: Color.fromARGB(64, 0, 0, 0),
);
const CupertinoDynamicColor _kDefaultBorderColor = CupertinoDynamicColor.withBrightness(
color: Color.fromARGB(255, 209, 209, 214),
darkColor: Color.fromARGB(64, 0, 0, 0),
);
const CupertinoDynamicColor _kDefaultInnerColor = CupertinoDynamicColor.withBrightness(
color: CupertinoColors.white,
darkColor: Color.fromARGB(255, 222, 232, 248),
);
const CupertinoDynamicColor _kDefaultOuterColor = CupertinoDynamicColor.withBrightness(
color: CupertinoColors.activeBlue,
darkColor: Color.fromARGB(255, 50, 100, 215),
);
const double _kPressedOverlayOpacity = 0.15;
const double _kCheckmarkStrokeWidth = 2.0;
const double _kFocusOutlineStrokeWidth = 3.0;
const double _kBorderOutlineStrokeWidth = 0.3;
// In dark mode, the outer color of a radio is an opacity gradient of the
// background color.
const List<double> _kDarkGradientOpacities = <double>[0.14, 0.29];
const List<double> _kDisabledDarkGradientOpacities = <double>[0.08, 0.14];
/// A macOS-style radio button.
///
/// 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
}
}
WidgetStateProperty<Color> get _defaultOuterColor {
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
if (states.contains(WidgetState.disabled)) {
return CupertinoDynamicColor.resolve(_kDisabledOuterColor, context);
}
if (states.contains(WidgetState.selected)) {
return widget.activeColor ?? CupertinoDynamicColor.resolve(_kDefaultOuterColor, context);
}
return widget.inactiveColor ?? CupertinoColors.white;
});
}
WidgetStateProperty<Color> get _defaultInnerColor {
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
if (states.contains(WidgetState.disabled) && states.contains(WidgetState.selected)) {
return widget.fillColor ?? CupertinoDynamicColor.resolve(_kDisabledInnerColor, context);
}
if (states.contains(WidgetState.selected)) {
return widget.fillColor ?? CupertinoDynamicColor.resolve(_kDefaultInnerColor, context);
}
return CupertinoColors.white;
});
}
WidgetStateProperty<Color> get _defaultBorderColor {
return WidgetStateProperty.resolveWith((Set<WidgetState> states) {
if ((states.contains(WidgetState.selected) || states.contains(WidgetState.focused))
&& !states.contains(WidgetState.disabled)) {
return CupertinoColors.transparent;
}
if (states.contains(WidgetState.disabled)) {
return CupertinoDynamicColor.resolve(_kDisabledBorderColor, context);
}
return CupertinoDynamicColor.resolve(_kDefaultBorderColor, context);
});
}
@override
Widget build(BuildContext context) {
final Color effectiveActiveColor = widget.activeColor
?? CupertinoColors.activeBlue;
final Color effectiveInactiveColor = widget.inactiveColor
?? CupertinoColors.white;
// Colors need to be resolved in selected and non selected states separately.
final Set<WidgetState> activeStates = states..add(WidgetState.selected);
final Set<WidgetState> inactiveStates = states..remove(WidgetState.selected);
final Color effectiveFocusOverlayColor = widget.focusColor
?? HSLColor
.fromColor(effectiveActiveColor.withOpacity(_kCupertinoFocusColorOpacity))
.withLightness(_kCupertinoFocusColorBrightness)
.withSaturation(_kCupertinoFocusColorSaturation)
.toColor();
// Since the states getter always makes a new set, make a copy to use
// throughout the lifecycle of this build method.
final Set<WidgetState> currentStates = states;
final Color effectiveActivePressedOverlayColor =
HSLColor.fromColor(effectiveActiveColor).withLightness(0.45).toColor();
final Color effectiveActiveColor = _defaultOuterColor.resolve(activeStates);
final Color effectiveFillColor = widget.fillColor ?? CupertinoColors.white;
final Color effectiveInactiveColor = _defaultOuterColor.resolve(inactiveStates);
final Color effectiveFocusOverlayColor = widget.focusColor ?? HSLColor
.fromColor(effectiveActiveColor.withOpacity(_kCupertinoFocusColorOpacity))
.withLightness(_kCupertinoFocusColorBrightness)
.withSaturation(_kCupertinoFocusColorSaturation)
.toColor();
final Color effectiveFillColor = _defaultInnerColor.resolve(currentStates);
final Color effectiveBorderColor = _defaultBorderColor.resolve(currentStates);
final WidgetStateProperty<MouseCursor> effectiveMouseCursor =
WidgetStateProperty.resolveWith<MouseCursor>((Set<WidgetState> states) {
@ -303,14 +377,19 @@ class _CupertinoRadioState<T> extends State<CupertinoRadio<T>> with TickerProvid
onFocusChange: onFocusChange,
size: _size,
painter: _painter
..position = position
..reaction = reaction
..focusColor = effectiveFocusOverlayColor
..downPosition = downPosition
..isFocused = focused
..activeColor = downPosition != null ? effectiveActivePressedOverlayColor : effectiveActiveColor
..activeColor = effectiveActiveColor
..inactiveColor = effectiveInactiveColor
..fillColor = effectiveFillColor
..value = value
..checkmarkStyle = widget.useCheckmarkStyle,
..checkmarkStyle = widget.useCheckmarkStyle
..isActive = widget.onChanged != null
..borderColor = effectiveBorderColor
..brightness = CupertinoTheme.of(context).brightness,
),
);
}
@ -347,22 +426,65 @@ class _RadioPainter extends ToggleablePainter {
notifyListeners();
}
Brightness? get brightness => _brightness;
Brightness? _brightness;
set brightness(Brightness? value) {
if (_brightness == value) {
return;
}
_brightness = value;
notifyListeners();
}
Color get borderColor => _borderColor!;
Color? _borderColor;
set borderColor(Color value) {
if (_borderColor == value) {
return;
}
_borderColor = value;
notifyListeners();
}
void _drawPressedOverlay(Canvas canvas, Offset center, double radius) {
final Paint pressedPaint = Paint()
..color = brightness == Brightness.light
? CupertinoColors.black.withOpacity(_kPressedOverlayOpacity)
: CupertinoColors.white.withOpacity(_kPressedOverlayOpacity);
canvas.drawCircle(center, radius, pressedPaint);
}
void _drawFillGradient(Canvas canvas, Offset center, double radius, Color topColor, Color bottomColor) {
final LinearGradient fillGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[topColor, bottomColor],
);
final Rect circleRect = Rect.fromCircle(center: center, radius: radius);
final Paint gradientPaint = Paint()
..shader = fillGradient.createShader(circleRect);
canvas.drawPath(Path()..addOval(circleRect), gradientPaint);
}
void _drawOuterBorder(Canvas canvas, Offset center) {
final Paint borderPaint = Paint()
..style = PaintingStyle.stroke
..color = borderColor
..strokeWidth = _kBorderOutlineStrokeWidth;
canvas.drawCircle(center, _kOuterRadius, borderPaint);
}
@override
void paint(Canvas canvas, Size size) {
final Offset center = (Offset.zero & size).center;
final Paint paint = Paint()
..color = inactiveColor
..style = PaintingStyle.fill
..strokeWidth = 0.1;
if (checkmarkStyle) {
if (value ?? false) {
final Path path = Path();
final Paint checkPaint = Paint()
..color = activeColor
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeWidth = _kCheckmarkStrokeWidth
..strokeCap = StrokeCap.round;
final double width = _size.width;
final Offset origin = Offset(center.dx - (width/2), center.dy - (width/2));
@ -377,27 +499,57 @@ class _RadioPainter extends ToggleablePainter {
canvas.drawPath(path, checkPaint);
}
} else {
// Outer border
canvas.drawCircle(center, _kOuterRadius, paint);
paint.style = PaintingStyle.stroke;
paint.color = CupertinoColors.inactiveGray;
canvas.drawCircle(center, _kOuterRadius, paint);
if (value ?? false) {
paint.style = PaintingStyle.fill;
paint.color = activeColor;
canvas.drawCircle(center, _kOuterRadius, paint);
paint.color = fillColor;
canvas.drawCircle(center, _kInnerRadius, paint);
final Paint outerPaint = Paint()..color = activeColor;
// Draw a gradient in dark mode if the radio is disabled.
if (brightness == Brightness.dark && !isActive) {
_drawFillGradient(
canvas,
center,
_kOuterRadius,
outerPaint.color.withOpacity(isActive ? _kDarkGradientOpacities[0] : _kDisabledDarkGradientOpacities[0]),
outerPaint.color.withOpacity(isActive ? _kDarkGradientOpacities[1] : _kDisabledDarkGradientOpacities[1]),
);
} else {
canvas.drawCircle(center, _kOuterRadius, outerPaint);
}
// The outer circle's opacity changes when the radio is pressed.
if (downPosition != null) {
_drawPressedOverlay(canvas, center, _kOuterRadius);
}
final Paint innerPaint = Paint()..color = fillColor;
canvas.drawCircle(center, _kInnerRadius, innerPaint);
// Draw an outer border if the radio is disabled and selected.
if (!isActive) {
_drawOuterBorder(canvas, center);
}
} else {
final Paint paint = Paint();
paint.color = isActive ? inactiveColor : _kDisabledOuterColor;
if (brightness == Brightness.dark) {
_drawFillGradient(
canvas,
center,
_kOuterRadius,
paint.color.withOpacity(isActive ? _kDarkGradientOpacities[0] : _kDisabledDarkGradientOpacities[0]),
paint.color.withOpacity(isActive ? _kDarkGradientOpacities[1] : _kDisabledDarkGradientOpacities[1]),
);
} else {
canvas.drawCircle(center, _kOuterRadius, paint);
}
// The entire circle's opacity changes when the radio is pressed.
if (downPosition != null) {
_drawPressedOverlay(canvas, center, _kOuterRadius);
}
_drawOuterBorder(canvas, center);
}
}
if (isFocused) {
paint.style = PaintingStyle.stroke;
paint.color = focusColor;
paint.strokeWidth = 3.0;
canvas.drawCircle(center, _kOuterRadius + 1.5, paint);
final Paint focusPaint = Paint()
..style = PaintingStyle.stroke
..color = focusColor
..strokeWidth = _kFocusOutlineStrokeWidth;
canvas.drawCircle(center, _kOuterRadius + _kFocusOutlineStrokeWidth / 2, focusPaint);
}
}
}

View File

@ -2,6 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// reduced-test-set:
// This file is run as part of a reduced test set in CI on Mac and Windows
// machines.
@Tags(<String>['reduced-test-set'])
library;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
@ -434,6 +440,350 @@ void main() {
await gesture.up();
});
testWidgets('Radio has correct default active/inactive/fill/border colors in light mode', (WidgetTester tester) async {
Widget buildRadio({required int value, required int groupValue}) {
return CupertinoApp(
home: Center(
child: RepaintBoundary(
child: CupertinoRadio<int>(
value: value,
groupValue: groupValue,
onChanged: (int? i) { },
),
),
),
);
}
await tester.pumpWidget(buildRadio(value: 1, groupValue: 1));
await expectLater(
find.byType(CupertinoRadio<int>),
matchesGoldenFile('radio.light_theme.selected.png'),
);
await tester.pumpWidget(buildRadio(value: 1, groupValue: 2));
await expectLater(
find.byType(CupertinoRadio<int>),
matchesGoldenFile('radio.light_theme.unselected.png'),
);
});
testWidgets('Radio has correct default active/inactive/fill/border colors in dark mode', (WidgetTester tester) async {
Widget buildRadio({required int value, required int groupValue, bool enabled = true}) {
return CupertinoApp(
theme: const CupertinoThemeData(brightness: Brightness.dark),
home: Center(
child: RepaintBoundary(
child: CupertinoRadio<int>(
value: value,
groupValue: groupValue,
onChanged: enabled ? (int? i) { } : null,
),
),
),
);
}
await tester.pumpWidget(buildRadio(value: 1, groupValue: 1));
await expectLater(
find.byType(CupertinoRadio<int>),
matchesGoldenFile('radio.dark_theme.selected.png'),
);
await tester.pumpWidget(buildRadio(value: 1, groupValue: 2));
await expectLater(
find.byType(CupertinoRadio<int>),
matchesGoldenFile('radio.dark_theme.unselected.png'),
);
});
testWidgets('Disabled radio has correct default active/inactive/fill/border colors in light mode', (WidgetTester tester) async {
Widget buildRadio({required int value, required int groupValue}) {
return CupertinoApp(
home: Center(
child: RepaintBoundary(
child: CupertinoRadio<int>(
value: value,
groupValue: groupValue,
onChanged: null,
),
),
),
);
}
await tester.pumpWidget(buildRadio(value: 1, groupValue: 1));
await expectLater(
find.byType(CupertinoRadio<int>),
matchesGoldenFile('radio.disabled_light_theme.selected.png'),
);
await tester.pumpWidget(buildRadio(value: 1, groupValue: 2));
await expectLater(
find.byType(CupertinoRadio<int>),
matchesGoldenFile('radio.disabled_light_theme.unselected.png'),
);
});
testWidgets('Disabled radio has correct default active/inactive/fill/border colors in dark mode', (WidgetTester tester) async {
Widget buildRadio({required int value, required int groupValue}) {
return CupertinoApp(
theme: const CupertinoThemeData(brightness: Brightness.dark),
home: Center(
child: RepaintBoundary(
child: CupertinoRadio<int>(
value: value,
groupValue: groupValue,
onChanged: null,
),
),
),
);
}
await tester.pumpWidget(buildRadio(value: 1, groupValue: 1));
await expectLater(
find.byType(CupertinoRadio<int>),
matchesGoldenFile('radio.disabled_dark_theme.selected.png'),
);
await tester.pumpWidget(buildRadio(value: 1, groupValue: 2));
await expectLater(
find.byType(CupertinoRadio<int>),
matchesGoldenFile('radio.disabled_dark_theme.unselected.png'),
);
});
testWidgets('Radio can set inactive/active/fill colors', (WidgetTester tester) async {
const Color inactiveBorderColor = Color(0xffd1d1d6);
const Color activeColor = Color(0x0000000A);
const Color fillColor = Color(0x0000000B);
const Color inactiveColor = Color(0x0000000C);
const double innerRadius = 2.975;
const double outerRadius = 7.0;
await tester.pumpWidget(CupertinoApp(
home: Center(
child: CupertinoRadio<int>(
value: 1,
groupValue: 2,
onChanged: (int? i) { },
activeColor: activeColor,
fillColor: fillColor,
inactiveColor: inactiveColor,
),
),
));
expect(
find.byType(CupertinoRadio<int>),
paints
..circle(radius: outerRadius, style: PaintingStyle.fill, color: inactiveColor)
..circle(radius: outerRadius, style: PaintingStyle.stroke, color: inactiveBorderColor),
reason: 'Unselected radio button should use inactive and border colors',
);
await tester.pumpWidget(CupertinoApp(
home: Center(
child: CupertinoRadio<int>(
value: 1,
groupValue: 1,
onChanged: (int? i) { },
activeColor: activeColor,
fillColor: fillColor,
inactiveColor: inactiveColor,
),
),
));
expect(
find.byType(CupertinoRadio<int>),
paints
..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeColor)
..circle(radius: innerRadius, style: PaintingStyle.fill, color: fillColor),
reason: 'Selected radio button should use active and fill colors',
);
});
testWidgets('Radio is slightly darkened when pressed in light mode', (WidgetTester tester) async {
const Color activeInnerColor = Color(0xffffffff);
const Color activeOuterColor = Color(0xff007aff);
const Color inactiveBorderColor = Color(0xffd1d1d6);
const Color inactiveOuterColor = Color(0xffffffff);
const double innerRadius = 2.975;
const double outerRadius = 7.0;
const Color pressedShadowColor = Color(0x26ffffff);
await tester.pumpWidget(CupertinoApp(
home: Center(
child: CupertinoRadio<int>(
value: 1,
groupValue: 2,
onChanged: (int? i) { },
),
),
));
final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.byType(CupertinoRadio<int>)));
await tester.pump();
expect(
find.byType(CupertinoRadio<int>),
paints
..circle(radius: outerRadius, style: PaintingStyle.fill, color: inactiveOuterColor)
..circle(radius: outerRadius, style: PaintingStyle.fill, color: pressedShadowColor)
..circle(radius: outerRadius, style: PaintingStyle.stroke, color: inactiveBorderColor),
reason: 'Unselected pressed radio button is slightly darkened',
);
await tester.pumpWidget(CupertinoApp(
home: Center(
child: CupertinoRadio<int>(
value: 2,
groupValue: 2,
onChanged: (int? i) { },
),
),
));
final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.byType(CupertinoRadio<int>)));
await tester.pump();
expect(
find.byType(CupertinoRadio<int>),
paints
..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeOuterColor)
..circle(radius: outerRadius, style: PaintingStyle.fill, color: pressedShadowColor)
..circle(radius: innerRadius, style: PaintingStyle.fill, color: activeInnerColor),
reason: 'Selected pressed radio button is slightly darkened',
);
// Finish gestures to release resources.
await gesture1.up();
await gesture2.up();
await tester.pump();
});
testWidgets('Radio is slightly lightened when pressed in dark mode', (WidgetTester tester) async {
const Color activeInnerColor = Color(0xffffffff);
const Color activeOuterColor = Color(0xff007aff);
const Color inactiveBorderColor = Color(0x40000000);
const double innerRadius = 2.975;
const double outerRadius = 7.0;
const Color pressedShadowColor = Color(0x26ffffff);
await tester.pumpWidget(CupertinoApp(
theme: const CupertinoThemeData(brightness: Brightness.dark),
home: Center(
child: CupertinoRadio<int>(
value: 1,
groupValue: 2,
onChanged: (int? i) { },
),
),
));
final TestGesture gesture1 = await tester.startGesture(tester.getCenter(find.byType(CupertinoRadio<int>)));
await tester.pump();
expect(
find.byType(CupertinoRadio<int>),
paints
..path()
..circle(radius: outerRadius, style: PaintingStyle.fill, color: pressedShadowColor)
..circle(radius: outerRadius, style: PaintingStyle.stroke, color: inactiveBorderColor),
reason: 'Unselected pressed radio button is slightly lightened',
);
await tester.pumpWidget(CupertinoApp(
home: Center(
child: CupertinoRadio<int>(
value: 2,
groupValue: 2,
onChanged: (int? i) { },
),
),
));
final TestGesture gesture2 = await tester.startGesture(tester.getCenter(find.byType(CupertinoRadio<int>)));
await tester.pump();
expect(
find.byType(CupertinoRadio<int>),
paints
..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeOuterColor)
..circle(radius: outerRadius, style: PaintingStyle.fill, color: pressedShadowColor)
..circle(radius: innerRadius, style: PaintingStyle.fill, color: activeInnerColor),
reason: 'Selected pressed radio button is slightly lightened',
);
// Finish gestures to release resources.
await gesture1.up();
await gesture2.up();
await tester.pump();
});
testWidgets('Radio is focusable and has correct focus colors', (WidgetTester tester) async {
const Color activeInnerColor = Color(0xffffffff);
const Color activeOuterColor = Color(0xff007aff);
const Color defaultFocusColor = Color(0xcc6eadf2);
const double innerRadius = 2.975;
const double outerRadius = 7.0;
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
final FocusNode node = FocusNode();
addTearDown(node.dispose);
await tester.pumpWidget(CupertinoApp(
home: Center(
child: CupertinoRadio<int>(
value: 1,
groupValue: 1,
onChanged: (int? i) { },
focusNode: node,
autofocus: true,
),
),
));
await tester.pump();
expect(node.hasPrimaryFocus, isTrue);
expect(
find.byType(CupertinoRadio<int>),
paints
..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeOuterColor)
..circle(radius: innerRadius, style: PaintingStyle.fill, color: activeInnerColor)
..circle(strokeWidth: 3.0, style: PaintingStyle.stroke, color: defaultFocusColor),
reason: 'Radio is focusable and shows the default focus color',
);
});
testWidgets('Radio can configure a focus color', (WidgetTester tester) async {
const Color activeInnerColor = Color(0xffffffff);
const Color activeOuterColor = Color(0xff007aff);
const Color focusColor = Color(0x0000000A);
const double innerRadius = 2.975;
const double outerRadius = 7.0;
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
final FocusNode node = FocusNode();
addTearDown(node.dispose);
await tester.pumpWidget(CupertinoApp(
home: Center(
child: CupertinoRadio<int>(
value: 1,
groupValue: 1,
onChanged: (int? i) { },
focusColor: focusColor,
focusNode: node,
autofocus: true,
),
),
));
await tester.pump();
expect(node.hasPrimaryFocus, isTrue);
expect(
find.byType(CupertinoRadio<int>),
paints
..circle(radius: outerRadius, style: PaintingStyle.fill, color: activeOuterColor)
..circle(radius: innerRadius, style: PaintingStyle.fill, color: activeInnerColor)
..circle(strokeWidth: 3.0, style: PaintingStyle.stroke, color: focusColor),
reason: 'Radio configures the color of the focus outline',
);
});
testWidgets('Radio configures mouse cursor', (WidgetTester tester) async {
await tester.pumpWidget(CupertinoApp(
home: Center(