diff --git a/packages/flutter/lib/src/material/radio.dart b/packages/flutter/lib/src/material/radio.dart index ef1baacaf9d..166a7914034 100644 --- a/packages/flutter/lib/src/material/radio.dart +++ b/packages/flutter/lib/src/material/radio.dart @@ -106,6 +106,7 @@ class Radio extends StatefulWidget { this.autofocus = false, this.enabled, this.groupRegistry, + this.backgroundColor, }) : _radioType = _RadioType.material, useCupertinoCheckmarkStyle = false; @@ -153,6 +154,7 @@ class Radio extends StatefulWidget { this.useCupertinoCheckmarkStyle = false, this.enabled, this.groupRegistry, + this.backgroundColor, }) : _radioType = _RadioType.adaptive; /// {@macro flutter.widget.RawRadio.value} @@ -398,6 +400,17 @@ class Radio extends StatefulWidget { /// {@endtemplate} final bool? enabled; + /// The color of the background of the radio button, in all [WidgetState]s. + /// + /// Resolves in the following states: + /// * [WidgetState.selected]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// If null, then it is transparent in all states. + final WidgetStateProperty? backgroundColor; + @override State> createState() => _RadioState(); } @@ -503,6 +516,7 @@ class _RadioState extends State> { splashRadius: widget.splashRadius, visualDensity: widget.visualDensity, materialTapTargetSize: widget.materialTapTargetSize, + backgroundColor: widget.backgroundColor, ); }, ); @@ -538,6 +552,7 @@ class _RadioPaint extends StatefulWidget { required this.splashRadius, required this.visualDensity, required this.materialTapTargetSize, + required this.backgroundColor, }); final ToggleableStateMixin toggleableState; @@ -549,6 +564,7 @@ class _RadioPaint extends StatefulWidget { final double? splashRadius; final VisualDensity? visualDensity; final MaterialTapTargetSize? materialTapTargetSize; + final MaterialStateProperty? backgroundColor; @override State createState() => _RadioPaintState(); @@ -598,6 +614,11 @@ class _RadioPaintState extends State<_RadioPaint> { radioTheme.fillColor?.resolve(inactiveStates); final Color effectiveInactiveColor = inactiveColor ?? defaults.fillColor!.resolve(inactiveStates)!; + // TODO(ValentinVignal): ADd backgroundColor to RadioThemeData. + final Color activeBackgroundColor = + widget.backgroundColor?.resolve(activeStates) ?? Colors.transparent; + final Color inactiveBackgroundColor = + widget.backgroundColor?.resolve(inactiveStates) ?? Colors.transparent; final Set focusedStates = widget.toggleableState.states..add(MaterialState.focused); @@ -675,24 +696,52 @@ class _RadioPaintState extends State<_RadioPaint> { ..isFocused = widget.toggleableState.states.contains(MaterialState.focused) ..isHovered = widget.toggleableState.states.contains(MaterialState.hovered) ..activeColor = effectiveActiveColor - ..inactiveColor = effectiveInactiveColor, + ..inactiveColor = effectiveInactiveColor + ..activeBackgroundColor = activeBackgroundColor + ..inactiveBackgroundColor = inactiveBackgroundColor, ); } } class _RadioPainter extends ToggleablePainter { + Color get inactiveBackgroundColor => _inactiveBackgroundColor!; + Color? _inactiveBackgroundColor; + set inactiveBackgroundColor(Color? value) { + if (_inactiveBackgroundColor == value) { + return; + } + _inactiveBackgroundColor = value; + notifyListeners(); + } + + Color get activeBackgroundColor => _activeBackgroundColor!; + Color? _activeBackgroundColor; + set activeBackgroundColor(Color? value) { + if (_activeBackgroundColor == value) { + return; + } + _activeBackgroundColor = value; + notifyListeners(); + } + @override void paint(Canvas canvas, Size size) { paintRadialReaction(canvas: canvas, origin: size.center(Offset.zero)); final Offset center = (Offset.zero & size).center; - // Outer circle + // Background final Paint paint = Paint() - ..color = Color.lerp(inactiveColor, activeColor, position.value)! - ..style = PaintingStyle.stroke - ..strokeWidth = 2.0; + ..color = Color.lerp(inactiveBackgroundColor, activeBackgroundColor, position.value)! + ..style = PaintingStyle.fill; + canvas.drawCircle(center, _kOuterRadius, paint); + + // Outer circle + paint + ..color = Color.lerp(inactiveColor, activeColor, position.value)! + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0; canvas.drawCircle(center, _kOuterRadius, paint); // Inner circle diff --git a/packages/flutter/test/material/radio_test.dart b/packages/flutter/test/material/radio_test.dart index b05d31b7ff4..e1edcca8439 100644 --- a/packages/flutter/test/material/radio_test.dart +++ b/packages/flutter/test/material/radio_test.dart @@ -2096,4 +2096,210 @@ void main() { ); focusNode.dispose(); }); + + testWidgets('Radio button background color resolves in enabled/disabled states', ( + WidgetTester tester, + ) async { + const Color activeEnabledBackgroundColor = Color(0xFF000001); + const Color activeDisabledBackgroundColor = Color(0xFF000002); + const Color inactiveEnabledBackgroundColor = Color(0xFF000003); + const Color inactiveDisabledBackgroundColor = Color(0xFF000004); + + Color getBackgroundColor(Set states) { + if (states.contains(MaterialState.disabled)) { + if (states.contains(MaterialState.selected)) { + return activeDisabledBackgroundColor; + } + return inactiveDisabledBackgroundColor; + } + if (states.contains(MaterialState.selected)) { + return activeEnabledBackgroundColor; + } + return inactiveEnabledBackgroundColor; + } + + final MaterialStateProperty backgroundColor = MaterialStateColor.resolveWith( + getBackgroundColor, + ); + + int? groupValue = 0; + const Key radioKey = Key('radio'); + Widget buildApp({required bool enabled}) { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: RadioGroup( + groupValue: groupValue, + onChanged: (int? newValue) { + setState(() { + groupValue = newValue; + }); + }, + child: Radio( + key: radioKey, + value: 0, + fillColor: backgroundColor, + enabled: enabled, + ), + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(enabled: true)); + + // Selected and enabled. + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: activeEnabledBackgroundColor), + ); + + // Check when the radio isn't selected. + groupValue = 1; + await tester.pumpWidget(buildApp(enabled: true)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: inactiveEnabledBackgroundColor), + ); + + // Check when the radio is selected, but disabled. + groupValue = 0; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: activeDisabledBackgroundColor), + ); + + // Check when the radio is unselected and disabled. + groupValue = 1; + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: inactiveDisabledBackgroundColor), + ); + }); + + testWidgets('Radio fill color resolves in hovered/focused states', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'radio'); + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + const Color hoveredBackgroundColor = Color(0xFF000001); + const Color focusedBackgroundColor = Color(0xFF000002); + + Color getBackgroundColor(Set states) { + if (states.contains(MaterialState.hovered)) { + return hoveredBackgroundColor; + } + if (states.contains(MaterialState.focused)) { + return focusedBackgroundColor; + } + return Colors.transparent; + } + + final MaterialStateProperty backgroundColor = MaterialStateColor.resolveWith( + getBackgroundColor, + ); + + int? groupValue = 0; + const Key radioKey = Key('radio'); + final ThemeData theme = ThemeData(); + Widget buildApp() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Container( + width: 100, + height: 100, + color: Colors.white, + child: RadioGroup( + groupValue: groupValue, + onChanged: (int? newValue) { + setState(() { + groupValue = newValue; + }); + }, + child: Radio( + autofocus: true, + focusNode: focusNode, + key: radioKey, + value: 0, + fillColor: backgroundColor, + ), + ), + ); + }, + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect() + ..circle(color: theme.colorScheme.primary.withOpacity(0.1)) + ..circle(color: focusedBackgroundColor), + ); + + // Start hovering + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(radioKey))); + await tester.pumpAndSettle(); + + expect( + Material.of(tester.element(find.byKey(radioKey))), + paints + ..rect( + color: const Color(0xffffffff), + rect: const Rect.fromLTRB(350.0, 250.0, 450.0, 350.0), + ) + ..circle(color: theme.colorScheme.primary.withOpacity(0.08)) + ..circle(color: hoveredBackgroundColor), + ); + + focusNode.dispose(); + }); }