Skip to content

Commit 5903986

Browse files
ValentinVignalmboetger
authored andcommitted
Add side to Radio (flutter#171217)
Part of flutter#168787 This PR adds `side` parameter to `Radio` to be able to control its color and width. This allows me to implement something like: https://github.com/user-attachments/assets/8065df9b-4cea-48b6-ba34-21379a831a2e In order to make it non breaking, when absent, it uses the `fillColor` ## 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
1 parent c70d11b commit 5903986

File tree

4 files changed

+445
-10
lines changed

4 files changed

+445
-10
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
/// Flutter code sample for [Radio] to showcase how to customize radio style.
8+
9+
void main() => runApp(const RadioExampleApp());
10+
11+
class RadioExampleApp extends StatelessWidget {
12+
const RadioExampleApp({super.key});
13+
14+
@override
15+
Widget build(BuildContext context) {
16+
return MaterialApp(
17+
home: Scaffold(
18+
appBar: AppBar(title: const Text('Radio Sample')),
19+
body: const Center(child: RadioExample()),
20+
),
21+
);
22+
}
23+
}
24+
25+
enum RadioType { fillColor, backgroundColor, side }
26+
27+
class RadioExample extends StatefulWidget {
28+
const RadioExample({super.key});
29+
30+
@override
31+
State<RadioExample> createState() => _RadioExampleState();
32+
}
33+
34+
class _RadioExampleState extends State<RadioExample> {
35+
RadioType? _radioType = RadioType.fillColor;
36+
37+
@override
38+
Widget build(BuildContext context) {
39+
return RadioGroup<RadioType>(
40+
groupValue: _radioType,
41+
onChanged: (RadioType? value) {
42+
setState(() {
43+
_radioType = value;
44+
});
45+
},
46+
child: Column(
47+
children: <Widget>[
48+
ListTile(
49+
title: const Text('Fill color'),
50+
leading: Radio<RadioType>(
51+
value: RadioType.fillColor,
52+
fillColor: WidgetStateColor.resolveWith((Set<WidgetState> states) {
53+
if (states.contains(WidgetState.selected)) {
54+
return Colors.deepPurple;
55+
} else {
56+
return Colors.deepPurple.shade200;
57+
}
58+
}),
59+
),
60+
),
61+
ListTile(
62+
title: const Text('Background color'),
63+
leading: Radio<RadioType>(
64+
value: RadioType.backgroundColor,
65+
backgroundColor: WidgetStateColor.resolveWith((Set<WidgetState> states) {
66+
if (states.contains(WidgetState.selected)) {
67+
return Colors.greenAccent.withOpacity(0.5);
68+
} else {
69+
return Colors.grey.shade300.withOpacity(0.3);
70+
}
71+
}),
72+
),
73+
),
74+
ListTile(
75+
title: const Text('Side'),
76+
leading: Radio<RadioType>(
77+
value: RadioType.side,
78+
side: WidgetStateBorderSide.resolveWith((Set<WidgetState> states) {
79+
if (states.contains(MaterialState.selected)) {
80+
return const BorderSide(
81+
color: Colors.red,
82+
width: 4,
83+
strokeAlign: BorderSide.strokeAlignCenter,
84+
);
85+
} else {
86+
return const BorderSide(
87+
color: Colors.grey,
88+
width: 1.5,
89+
strokeAlign: BorderSide.strokeAlignCenter,
90+
);
91+
}
92+
}),
93+
),
94+
),
95+
],
96+
),
97+
);
98+
}
99+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
import 'package:flutter_api_samples/material/radio/radio.1.dart' as example;
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
void main() {
10+
testWidgets('Radio colors can be changed', (WidgetTester tester) async {
11+
await tester.pumpWidget(const example.RadioExampleApp());
12+
13+
expect(find.widgetWithText(AppBar, 'Radio Sample'), findsOne);
14+
expect(find.widgetWithText(ListTile, 'Fill color'), findsOne);
15+
expect(find.widgetWithText(ListTile, 'Background color'), findsOne);
16+
expect(find.widgetWithText(ListTile, 'Side'), findsOne);
17+
18+
final Radio<example.RadioType> radioFillColor = tester.widget<Radio<example.RadioType>>(
19+
find.byType(Radio<example.RadioType>).first,
20+
);
21+
expect(
22+
radioFillColor.fillColor!.resolve(const <WidgetState>{WidgetState.selected}),
23+
Colors.deepPurple,
24+
);
25+
expect(radioFillColor.fillColor!.resolve(const <WidgetState>{}), Colors.deepPurple.shade200);
26+
27+
final Radio<example.RadioType> radioBackgroundColor = tester.widget<Radio<example.RadioType>>(
28+
find.byType(Radio<example.RadioType>).at(1),
29+
);
30+
expect(
31+
radioBackgroundColor.backgroundColor!.resolve(const <WidgetState>{WidgetState.selected}),
32+
Colors.greenAccent.withOpacity(0.5),
33+
);
34+
expect(
35+
radioBackgroundColor.backgroundColor!.resolve(const <WidgetState>{}),
36+
Colors.grey.shade300.withOpacity(0.3),
37+
);
38+
39+
final Radio<example.RadioType> radioSide = tester.widget<Radio<example.RadioType>>(
40+
find.byType(Radio<example.RadioType>).last,
41+
);
42+
expect(
43+
(radioSide.side! as WidgetStateBorderSide).resolve(const <WidgetState>{WidgetState.selected}),
44+
const BorderSide(color: Colors.red, width: 4, strokeAlign: BorderSide.strokeAlignCenter),
45+
);
46+
expect(
47+
(radioSide.side! as WidgetStateBorderSide).resolve(const <WidgetState>{}),
48+
const BorderSide(color: Colors.grey, width: 1.5, strokeAlign: BorderSide.strokeAlignCenter),
49+
);
50+
});
51+
}

packages/flutter/lib/src/material/radio.dart

Lines changed: 90 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ const double _kInnerRadius = 4.5;
6464
/// ** See code in examples/api/lib/material/radio/radio.0.dart **
6565
/// {@end-tool}
6666
///
67+
/// {@tool dartpad}
68+
/// Here is an example of how the you can override the default theme of a
69+
/// [Radio] with [WidgetStateProperty].
70+
///
71+
/// In this example:
72+
/// - The first [Radio] uses a custom [fillColor] that changes depending on whether
73+
/// the radio button is selected.
74+
/// - The second [Radio] applies a different [backgroundColor] based on its selection state.
75+
/// - The third [Radio] customizes the [side] property to display a different border color
76+
/// when selected or unselected.
77+
///
78+
/// ** See code in examples/api/lib/material/radio/radio.1.dart **
79+
/// {@end-tool}
80+
///
6781
/// See also:
6882
///
6983
/// * [RadioListTile], which combines this widget with a [ListTile] so that
@@ -107,6 +121,7 @@ class Radio<T> extends StatefulWidget {
107121
this.enabled,
108122
this.groupRegistry,
109123
this.backgroundColor,
124+
this.side,
110125
}) : _radioType = _RadioType.material,
111126
useCupertinoCheckmarkStyle = false;
112127

@@ -155,6 +170,7 @@ class Radio<T> extends StatefulWidget {
155170
this.enabled,
156171
this.groupRegistry,
157172
this.backgroundColor,
173+
this.side,
158174
}) : _radioType = _RadioType.adaptive;
159175

160176
/// {@macro flutter.widget.RawRadio.value}
@@ -411,6 +427,21 @@ class Radio<T> extends StatefulWidget {
411427
/// If null, then it is transparent in all states.
412428
final WidgetStateProperty<Color?>? backgroundColor;
413429

430+
/// The side for the circular border of the radio button, in all
431+
/// [WidgetState]s.
432+
///
433+
/// This property can be a [BorderSide] or a [WidgetStateBorderSide] to leverage
434+
/// widget state resolution.
435+
///
436+
/// Resolves in the following states:
437+
/// * [WidgetState.selected].
438+
/// * [WidgetState.hovered].
439+
/// * [WidgetState.focused].
440+
/// * [WidgetState.disabled].
441+
///
442+
/// If null, then it defaults to a border using the fill color.
443+
final BorderSide? side;
444+
414445
@override
415446
State<Radio<T>> createState() => _RadioState<T>();
416447
}
@@ -517,6 +548,7 @@ class _RadioState<T> extends State<Radio<T>> {
517548
visualDensity: widget.visualDensity,
518549
materialTapTargetSize: widget.materialTapTargetSize,
519550
backgroundColor: widget.backgroundColor,
551+
side: widget.side,
520552
);
521553
},
522554
);
@@ -553,6 +585,7 @@ class _RadioPaint extends StatefulWidget {
553585
required this.visualDensity,
554586
required this.materialTapTargetSize,
555587
required this.backgroundColor,
588+
required this.side,
556589
});
557590

558591
final ToggleableStateMixin toggleableState;
@@ -565,6 +598,7 @@ class _RadioPaint extends StatefulWidget {
565598
final VisualDensity? visualDensity;
566599
final MaterialTapTargetSize? materialTapTargetSize;
567600
final WidgetStateProperty<Color?>? backgroundColor;
601+
final BorderSide? side;
568602

569603
@override
570604
State<StatefulWidget> createState() => _RadioPaintState();
@@ -591,6 +625,16 @@ class _RadioPaintState extends State<_RadioPaint> {
591625
});
592626
}
593627

628+
BorderSide? _resolveSide(BorderSide? side, Set<MaterialState> states) {
629+
if (side is WidgetStateProperty) {
630+
return WidgetStateProperty.resolveAs<BorderSide?>(side, states);
631+
}
632+
if (!states.contains(WidgetState.selected)) {
633+
return side;
634+
}
635+
return null;
636+
}
637+
594638
@override
595639
Widget build(BuildContext context) {
596640
final RadioThemeData radioTheme = RadioTheme.of(context);
@@ -678,6 +722,21 @@ class _RadioPaintState extends State<_RadioPaint> {
678722
),
679723
};
680724
size += effectiveVisualDensity.baseSizeAdjustment;
725+
// TODO(ValentinVignal): Add side to RadioThemeData.
726+
final BorderSide activeSide =
727+
_resolveSide(widget.side, activeStates) ??
728+
BorderSide(
729+
color: effectiveActiveColor,
730+
width: 2.0,
731+
strokeAlign: BorderSide.strokeAlignCenter,
732+
);
733+
final BorderSide inactiveSide =
734+
_resolveSide(widget.side, inactiveStates) ??
735+
BorderSide(
736+
color: effectiveInactiveColor,
737+
width: 2.0,
738+
strokeAlign: BorderSide.strokeAlignCenter,
739+
);
681740

682741
return CustomPaint(
683742
size: size,
@@ -698,7 +757,9 @@ class _RadioPaintState extends State<_RadioPaint> {
698757
..activeColor = effectiveActiveColor
699758
..inactiveColor = effectiveInactiveColor
700759
..activeBackgroundColor = activeBackgroundColor
701-
..inactiveBackgroundColor = inactiveBackgroundColor,
760+
..inactiveBackgroundColor = inactiveBackgroundColor
761+
..activeSide = activeSide
762+
..inactiveSide = inactiveSide,
702763
);
703764
}
704765
}
@@ -724,11 +785,36 @@ class _RadioPainter extends ToggleablePainter {
724785
notifyListeners();
725786
}
726787

788+
BorderSide get inactiveSide => _inactiveSide!;
789+
BorderSide? _inactiveSide;
790+
set inactiveSide(BorderSide? value) {
791+
if (_inactiveSide == value) {
792+
return;
793+
}
794+
_inactiveSide = value;
795+
notifyListeners();
796+
}
797+
798+
BorderSide get activeSide => _activeSide!;
799+
BorderSide? _activeSide;
800+
set activeSide(BorderSide? value) {
801+
if (_activeSide == value) {
802+
return;
803+
}
804+
_activeSide = value;
805+
notifyListeners();
806+
}
807+
727808
@override
728809
void paint(Canvas canvas, Size size) {
729810
paintRadialReaction(canvas: canvas, origin: size.center(Offset.zero));
730811

731-
final Offset center = (Offset.zero & size).center;
812+
final Rect rect = Offset.zero & size;
813+
final Offset center = rect.center;
814+
final Rect effectiveRect = (center & const Size.square(_kOuterRadius * 2)).translate(
815+
-_kOuterRadius,
816+
-_kOuterRadius,
817+
);
732818

733819
// Background
734820
final Paint backgroundPaint =
@@ -738,12 +824,8 @@ class _RadioPainter extends ToggleablePainter {
738824
canvas.drawCircle(center, _kOuterRadius, backgroundPaint);
739825

740826
// Outer circle
741-
final Paint outerCirclePaint =
742-
Paint()
743-
..color = Color.lerp(inactiveColor, activeColor, position.value)!
744-
..style = PaintingStyle.stroke
745-
..strokeWidth = 2.0;
746-
canvas.drawCircle(center, _kOuterRadius, outerCirclePaint);
827+
final BorderSide side = BorderSide.lerp(inactiveSide, activeSide, position.value);
828+
CircleBorder(side: side).paint(canvas, effectiveRect);
747829

748830
// Inner circle
749831
if (!position.isDismissed) {

0 commit comments

Comments
 (0)