From b086fe7b79b80a9567f61660b1565efba773fdc3 Mon Sep 17 00:00:00 2001 From: Valentin Vignal <32538273+ValentinVignal@users.noreply.github.com> Date: Tue, 10 Jun 2025 09:38:08 +0800 Subject: [PATCH] Add `backgroundColor` to `Radio` (#169415) Part of https://github.com/flutter/flutter/issues/168787 Adds `backgroundColor` property to `Radio` This code sample is now possible:
Example ```dart // Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'package:flutter/material.dart'; void main() { runApp(const MyWidget()); } class MyWidget extends StatefulWidget { const MyWidget({super.key}); @override State createState() => _MyWidgetState(); } class _MyWidgetState extends State { bool? _value; @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: Column( children: [ Radio( value: true, groupValue: _value, toggleable: true, onChanged: (bool? value) { setState(() { // Toggle the value when the radio button is pressed _value = value; }); }, activeColor: Colors.red, fillColor: MaterialStateProperty.resolveWith((Set states) { if (states.contains(MaterialState.selected)) { return Colors.green; } return Colors.blue; // Default color when not selected }), backgroundColor: MaterialStateProperty.resolveWith(( Set states, ) { if (states.contains(MaterialState.selected)) { return Colors.orange.withOpacity(0.5); } return Colors.purple.withOpacity(0.5); // Default background color when not selected }), ), Radio( value: false, groupValue: _value, toggleable: true, onChanged: (bool? value) { setState(() { // Toggle the value when the radio button is pressed _value = value; }); }, ), ], ), ), ); } } ```
https://github.com/user-attachments/assets/d1a9d422-89f6-4b28-bb6c-add6ead13a21 ## 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. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#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/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- packages/flutter/lib/src/material/radio.dart | 59 ++++- .../flutter/test/material/radio_test.dart | 206 ++++++++++++++++++ 2 files changed, 260 insertions(+), 5 deletions(-) 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(); + }); }