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:

<details>

<summary>Example</summary>

```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<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  bool? _value;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Column(
          children: <Widget>[
            Radio<bool>(
              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<Color>((Set<MaterialState> states) {
                if (states.contains(MaterialState.selected)) {
                  return Colors.green;
                }
                return Colors.blue; // Default color when not selected
              }),
              backgroundColor: MaterialStateProperty.resolveWith<Color>((
                Set<MaterialState> 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<bool>(
              value: false,
              groupValue: _value,
              toggleable: true,
              onChanged: (bool? value) {
                setState(() {
                  // Toggle the value when the radio button is pressed
                  _value = value;
                });
              },
            ),
          ],
        ),
      ),
    );
  }
}
```
</details>


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].

<!-- Links -->
[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
This commit is contained in:
Valentin Vignal 2025-06-10 09:38:08 +08:00 committed by GitHub
parent 85867b0911
commit b086fe7b79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 260 additions and 5 deletions

View File

@ -106,6 +106,7 @@ class Radio<T> extends StatefulWidget {
this.autofocus = false,
this.enabled,
this.groupRegistry,
this.backgroundColor,
}) : _radioType = _RadioType.material,
useCupertinoCheckmarkStyle = false;
@ -153,6 +154,7 @@ class Radio<T> 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<T> 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<Color?>? backgroundColor;
@override
State<Radio<T>> createState() => _RadioState<T>();
}
@ -503,6 +516,7 @@ class _RadioState<T> extends State<Radio<T>> {
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<Color?>? backgroundColor;
@override
State<StatefulWidget> 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<MaterialState> 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

View File

@ -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<MaterialState> 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<Color> 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<int>(
groupValue: groupValue,
onChanged: (int? newValue) {
setState(() {
groupValue = newValue;
});
},
child: Radio<int>(
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<MaterialState> states) {
if (states.contains(MaterialState.hovered)) {
return hoveredBackgroundColor;
}
if (states.contains(MaterialState.focused)) {
return focusedBackgroundColor;
}
return Colors.transparent;
}
final MaterialStateProperty<Color> 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<int>(
groupValue: groupValue,
onChanged: (int? newValue) {
setState(() {
groupValue = newValue;
});
},
child: Radio<int>(
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();
});
}