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();
+ });
}