Unverified Commit a8b3d1b7 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add toggleable attribute to Radio (#53846)

This adds a new toggleable attribute to the Radio widget. This allows a radio group to be set back to an indeterminate state if the selected radio button is selected again.

Fixes #53791
parent e97c385c
...@@ -40,8 +40,10 @@ bool _isRadioSelected(int index) => ...@@ -40,8 +40,10 @@ bool _isRadioSelected(int index) =>
List<Radio<Location>> get _radios => List<Radio<Location>>.from( List<Radio<Location>> get _radios => List<Radio<Location>>.from(
_radioFinder.evaluate().map<Widget>((Element e) => e.widget)); _radioFinder.evaluate().map<Widget>((Element e) => e.widget));
// [find.byType] and [find.widgetWithText] do not match subclasses; `Radio` is not sufficient to find a `Radio<_Location>`. // [find.byType] and [find.widgetWithText] do not match subclasses; `Radio` is
// Another approach is to grab the `runtimeType` of a dummy instance; see packages/flutter/test/material/control_list_tile_test.dart. // not sufficient to find a `Radio<_Location>`. Another approach is to grab the
// `runtimeType` of a dummy instance; see
// packages/flutter/test/material/radio_list_tile_test.dart.
Finder get _radioFinder => Finder get _radioFinder =>
find.byWidgetPredicate((Widget w) => w is Radio<Location>); find.byWidgetPredicate((Widget w) => w is Radio<Location>);
......
...@@ -108,6 +108,7 @@ class Radio<T> extends StatefulWidget { ...@@ -108,6 +108,7 @@ class Radio<T> extends StatefulWidget {
@required this.value, @required this.value,
@required this.groupValue, @required this.groupValue,
@required this.onChanged, @required this.onChanged,
this.toggleable = false,
this.activeColor, this.activeColor,
this.focusColor, this.focusColor,
this.hoverColor, this.hoverColor,
...@@ -116,6 +117,7 @@ class Radio<T> extends StatefulWidget { ...@@ -116,6 +117,7 @@ class Radio<T> extends StatefulWidget {
this.focusNode, this.focusNode,
this.autofocus = false, this.autofocus = false,
}) : assert(autofocus != null), }) : assert(autofocus != null),
assert(toggleable != null),
super(key: key); super(key: key);
/// The value represented by this radio button. /// The value represented by this radio button.
...@@ -155,6 +157,69 @@ class Radio<T> extends StatefulWidget { ...@@ -155,6 +157,69 @@ class Radio<T> extends StatefulWidget {
/// ``` /// ```
final ValueChanged<T> onChanged; final ValueChanged<T> onChanged;
/// Set to true if this radio button is allowed to be returned to an
/// indeterminate state by selecting it again when selected.
///
/// To indicate returning to an indeterminate state, [onChanged] will be
/// called with null.
///
/// If true, [onChanged] can be called with [value] when selected while
/// [groupValue] != [value], or with null when selected again while
/// [groupValue] == [value].
///
/// If false, [onChanged] will be called with [value] when it is selected
/// while [groupValue] != [value], and only by selecting another radio button
/// in the group (i.e. changing the value of [groupValue]) can this radio
/// button be unselected.
///
/// The default is false.
///
/// {@tool dartpad --template=stateful_widget_scaffold}
/// This example shows how to enable deselecting a radio button by setting the
/// [toggleable] attribute.
///
/// ```dart
/// int groupValue;
/// static const List<String> selections = <String>[
/// 'Hercules Mulligan',
/// 'Eliza Hamilton',
/// 'Philip Schuyler',
/// 'Maria Reynolds',
/// 'Samuel Seabury',
/// ];
///
/// @override
/// Widget build(BuildContext context) {
/// return Scaffold(
/// body: ListView.builder(
/// itemBuilder: (context, index) {
/// return Row(
/// mainAxisSize: MainAxisSize.min,
/// crossAxisAlignment: CrossAxisAlignment.center,
/// children: <Widget>[
/// Radio<int>(
/// value: index,
/// groupValue: groupValue,
/// // TRY THIS: Try setting the toggleable value to false and
/// // see how that changes the behavior of the widget.
/// toggleable: true,
/// onChanged: (int value) {
/// setState(() {
/// groupValue = value;
/// });
/// }),
/// Text(selections[index]),
/// ],
/// );
/// },
/// itemCount: selections.length,
/// ),
/// );
/// }
/// ```
/// {@end-tool}
final bool toggleable;
/// The color to use when this radio button is selected. /// The color to use when this radio button is selected.
/// ///
/// Defaults to [ThemeData.toggleableActiveColor]. /// Defaults to [ThemeData.toggleableActiveColor].
...@@ -207,7 +272,7 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { ...@@ -207,7 +272,7 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
}; };
} }
void _actionHandler(FocusNode node, Intent intent){ void _actionHandler(FocusNode node, Intent intent) {
if (widget.onChanged != null) { if (widget.onChanged != null) {
widget.onChanged(widget.value); widget.onChanged(widget.value);
} }
...@@ -241,9 +306,14 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { ...@@ -241,9 +306,14 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
} }
void _handleChanged(bool selected) { void _handleChanged(bool selected) {
if (selected) if (selected == null) {
widget.onChanged(null);
return;
}
if (selected) {
widget.onChanged(widget.value); widget.onChanged(widget.value);
} }
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
...@@ -276,6 +346,7 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin { ...@@ -276,6 +346,7 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
focusColor: widget.focusColor ?? themeData.focusColor, focusColor: widget.focusColor ?? themeData.focusColor,
hoverColor: widget.hoverColor ?? themeData.hoverColor, hoverColor: widget.hoverColor ?? themeData.hoverColor,
onChanged: enabled ? _handleChanged : null, onChanged: enabled ? _handleChanged : null,
toggleable: widget.toggleable,
additionalConstraints: additionalConstraints, additionalConstraints: additionalConstraints,
vsync: this, vsync: this,
hasFocus: _focused, hasFocus: _focused,
...@@ -297,6 +368,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget { ...@@ -297,6 +368,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
@required this.hoverColor, @required this.hoverColor,
@required this.additionalConstraints, @required this.additionalConstraints,
this.onChanged, this.onChanged,
@required this.toggleable,
@required this.vsync, @required this.vsync,
@required this.hasFocus, @required this.hasFocus,
@required this.hovering, @required this.hovering,
...@@ -304,6 +376,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget { ...@@ -304,6 +376,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
assert(activeColor != null), assert(activeColor != null),
assert(inactiveColor != null), assert(inactiveColor != null),
assert(vsync != null), assert(vsync != null),
assert(toggleable != null),
super(key: key); super(key: key);
final bool selected; final bool selected;
...@@ -314,6 +387,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget { ...@@ -314,6 +387,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
final Color focusColor; final Color focusColor;
final Color hoverColor; final Color hoverColor;
final ValueChanged<bool> onChanged; final ValueChanged<bool> onChanged;
final bool toggleable;
final TickerProvider vsync; final TickerProvider vsync;
final BoxConstraints additionalConstraints; final BoxConstraints additionalConstraints;
...@@ -325,6 +399,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget { ...@@ -325,6 +399,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
focusColor: focusColor, focusColor: focusColor,
hoverColor: hoverColor, hoverColor: hoverColor,
onChanged: onChanged, onChanged: onChanged,
tristate: toggleable,
vsync: vsync, vsync: vsync,
additionalConstraints: additionalConstraints, additionalConstraints: additionalConstraints,
hasFocus: hasFocus, hasFocus: hasFocus,
...@@ -340,6 +415,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget { ...@@ -340,6 +415,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
..focusColor = focusColor ..focusColor = focusColor
..hoverColor = hoverColor ..hoverColor = hoverColor
..onChanged = onChanged ..onChanged = onChanged
..tristate = toggleable
..additionalConstraints = additionalConstraints ..additionalConstraints = additionalConstraints
..vsync = vsync ..vsync = vsync
..hasFocus = hasFocus ..hasFocus = hasFocus
...@@ -355,18 +431,19 @@ class _RenderRadio extends RenderToggleable { ...@@ -355,18 +431,19 @@ class _RenderRadio extends RenderToggleable {
Color focusColor, Color focusColor,
Color hoverColor, Color hoverColor,
ValueChanged<bool> onChanged, ValueChanged<bool> onChanged,
bool tristate,
BoxConstraints additionalConstraints, BoxConstraints additionalConstraints,
@required TickerProvider vsync, @required TickerProvider vsync,
bool hasFocus, bool hasFocus,
bool hovering, bool hovering,
}) : super( }) : super(
value: value, value: value,
tristate: false,
activeColor: activeColor, activeColor: activeColor,
inactiveColor: inactiveColor, inactiveColor: inactiveColor,
focusColor: focusColor, focusColor: focusColor,
hoverColor: hoverColor, hoverColor: hoverColor,
onChanged: onChanged, onChanged: onChanged,
tristate: tristate,
additionalConstraints: additionalConstraints, additionalConstraints: additionalConstraints,
vsync: vsync, vsync: vsync,
hasFocus: hasFocus, hasFocus: hasFocus,
......
...@@ -309,6 +309,7 @@ class RadioListTile<T> extends StatelessWidget { ...@@ -309,6 +309,7 @@ class RadioListTile<T> extends StatelessWidget {
@required this.value, @required this.value,
@required this.groupValue, @required this.groupValue,
@required this.onChanged, @required this.onChanged,
this.toggleable = false,
this.activeColor, this.activeColor,
this.title, this.title,
this.subtitle, this.subtitle,
...@@ -317,7 +318,9 @@ class RadioListTile<T> extends StatelessWidget { ...@@ -317,7 +318,9 @@ class RadioListTile<T> extends StatelessWidget {
this.secondary, this.secondary,
this.selected = false, this.selected = false,
this.controlAffinity = ListTileControlAffinity.platform, this.controlAffinity = ListTileControlAffinity.platform,
}) : assert(isThreeLine != null),
}) : assert(toggleable != null),
assert(isThreeLine != null),
assert(!isThreeLine || subtitle != null), assert(!isThreeLine || subtitle != null),
assert(selected != null), assert(selected != null),
assert(controlAffinity != null), assert(controlAffinity != null),
...@@ -361,6 +364,62 @@ class RadioListTile<T> extends StatelessWidget { ...@@ -361,6 +364,62 @@ class RadioListTile<T> extends StatelessWidget {
/// ``` /// ```
final ValueChanged<T> onChanged; final ValueChanged<T> onChanged;
/// Set to true if this radio list tile is allowed to be returned to an
/// indeterminate state by selecting it again when selected.
///
/// To indicate returning to an indeterminate state, [onChanged] will be
/// called with null.
///
/// If true, [onChanged] can be called with [value] when selected while
/// [groupValue] != [value], or with null when selected again while
/// [groupValue] == [value].
///
/// If false, [onChanged] will be called with [value] when it is selected
/// while [groupValue] != [value], and only by selecting another radio button
/// in the group (i.e. changing the value of [groupValue]) can this radio
/// list tile be unselected.
///
/// The default is false.
///
/// {@tool dartpad --template=stateful_widget_scaffold}
/// This example shows how to enable deselecting a radio button by setting the
/// [toggleable] attribute.
///
/// ```dart
/// int groupValue;
/// static const List<String> selections = <String>[
/// 'Hercules Mulligan',
/// 'Eliza Hamilton',
/// 'Philip Schuyler',
/// 'Maria Reynolds',
/// 'Samuel Seabury',
/// ];
///
/// @override
/// Widget build(BuildContext context) {
/// return Scaffold(
/// body: ListView.builder(
/// itemBuilder: (context, index) {
/// return RadioListTile<int>(
/// value: index,
/// groupValue: groupValue,
/// toggleable: true,
/// title: Text(selections[index]),
/// onChanged: (int value) {
/// setState(() {
/// groupValue = value;
/// });
/// },
/// );
/// },
/// itemCount: selections.length,
/// ),
/// );
/// }
/// ```
/// {@end-tool}
final bool toggleable;
/// The color to use when this radio button is selected. /// The color to use when this radio button is selected.
/// ///
/// Defaults to accent color of the current [Theme]. /// Defaults to accent color of the current [Theme].
...@@ -416,6 +475,7 @@ class RadioListTile<T> extends StatelessWidget { ...@@ -416,6 +475,7 @@ class RadioListTile<T> extends StatelessWidget {
value: value, value: value,
groupValue: groupValue, groupValue: groupValue,
onChanged: onChanged, onChanged: onChanged,
toggleable: toggleable,
activeColor: activeColor, activeColor: activeColor,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
); );
...@@ -442,7 +502,15 @@ class RadioListTile<T> extends StatelessWidget { ...@@ -442,7 +502,15 @@ class RadioListTile<T> extends StatelessWidget {
isThreeLine: isThreeLine, isThreeLine: isThreeLine,
dense: dense, dense: dense,
enabled: onChanged != null, enabled: onChanged != null,
onTap: onChanged != null && !checked ? () { onChanged(value); } : null, onTap: onChanged != null ? () {
if (toggleable && checked) {
onChanged(null);
return;
}
if (!checked) {
onChanged(value);
}
} : null,
selected: selected, selected: selected,
), ),
), ),
......
...@@ -66,6 +66,61 @@ void main() { ...@@ -66,6 +66,61 @@ void main() {
expect(log, isEmpty); expect(log, isEmpty);
}); });
testWidgets('Radio can be toggled when toggleable is set', (WidgetTester tester) async {
final Key key = UniqueKey();
final List<int> log = <int>[];
await tester.pumpWidget(Material(
child: Center(
child: Radio<int>(
key: key,
value: 1,
groupValue: 2,
onChanged: log.add,
toggleable: true,
),
),
));
await tester.tap(find.byKey(key));
expect(log, equals(<int>[1]));
log.clear();
await tester.pumpWidget(Material(
child: Center(
child: Radio<int>(
key: key,
value: 1,
groupValue: 1,
onChanged: log.add,
toggleable: true,
),
),
));
await tester.tap(find.byKey(key));
expect(log, equals(<int>[null]));
log.clear();
await tester.pumpWidget(Material(
child: Center(
child: Radio<int>(
key: key,
value: 1,
groupValue: null,
onChanged: log.add,
toggleable: true,
),
),
));
await tester.tap(find.byKey(key));
expect(log, equals(<int>[1]));
});
testWidgets('Radio size is configurable by ThemeData.materialTapTargetSize', (WidgetTester tester) async { testWidgets('Radio size is configurable by ThemeData.materialTapTargetSize', (WidgetTester tester) async {
final Key key1 = UniqueKey(); final Key key1 = UniqueKey();
await tester.pumpWidget( await tester.pumpWidget(
...@@ -443,7 +498,7 @@ void main() { ...@@ -443,7 +498,7 @@ void main() {
); );
}); });
testWidgets('Radio can be toggled by keyboard shortcuts', (WidgetTester tester) async { testWidgets('Radio can be controlled by keyboard shortcuts', (WidgetTester tester) async {
tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
int groupValue = 1; int groupValue = 1;
const Key radioKey0 = Key('radio0'); const Key radioKey0 = Key('radio0');
......
...@@ -8,7 +8,113 @@ import 'package:flutter/material.dart'; ...@@ -8,7 +8,113 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart'; import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart';
Widget wrap({ Widget child }) {
return MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: Material(child: child),
),
);
}
void main() { void main() {
testWidgets('SwitchListTile control test', (WidgetTester tester) async {
final List<dynamic> log = <dynamic>[];
await tester.pumpWidget(wrap(
child: SwitchListTile(
value: true,
onChanged: (bool value) { log.add(value); },
title: const Text('Hello'),
),
));
await tester.tap(find.text('Hello'));
log.add('-');
await tester.tap(find.byType(Switch));
expect(log, equals(<dynamic>[false, '-', false]));
});
testWidgets('SwitchListTile control test', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(wrap(
child: Column(
children: <Widget>[
SwitchListTile(
value: true,
onChanged: (bool value) { },
title: const Text('AAA'),
secondary: const Text('aaa'),
),
CheckboxListTile(
value: true,
onChanged: (bool value) { },
title: const Text('BBB'),
secondary: const Text('bbb'),
),
RadioListTile<bool>(
value: true,
groupValue: false,
onChanged: (bool value) { },
title: const Text('CCC'),
secondary: const Text('ccc'),
),
],
),
));
// This test verifies that the label and the control get merged.
expect(semantics, hasSemantics(TestSemantics.root(
children: <TestSemantics>[
TestSemantics.rootChild(
id: 1,
rect: const Rect.fromLTWH(0.0, 0.0, 800.0, 56.0),
transform: null,
flags: <SemanticsFlag>[
SemanticsFlag.hasEnabledState,
SemanticsFlag.hasToggledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
SemanticsFlag.isToggled,
],
actions: SemanticsAction.tap.index,
label: 'aaa\nAAA',
),
TestSemantics.rootChild(
id: 3,
rect: const Rect.fromLTWH(0.0, 0.0, 800.0, 56.0),
transform: Matrix4.translationValues(0.0, 56.0, 0.0),
flags: <SemanticsFlag>[
SemanticsFlag.hasCheckedState,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isChecked,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
],
actions: SemanticsAction.tap.index,
label: 'bbb\nBBB',
),
TestSemantics.rootChild(
id: 5,
rect: const Rect.fromLTWH(0.0, 0.0, 800.0, 56.0),
transform: Matrix4.translationValues(0.0, 112.0, 0.0),
flags: <SemanticsFlag>[
SemanticsFlag.hasCheckedState,
SemanticsFlag.hasEnabledState,
SemanticsFlag.isEnabled,
SemanticsFlag.isFocusable,
SemanticsFlag.isInMutuallyExclusiveGroup,
],
actions: SemanticsAction.tap.index,
label: 'CCC\nccc',
),
],
)));
semantics.dispose();
});
testWidgets('SwitchListTile has the right colors', (WidgetTester tester) async { testWidgets('SwitchListTile has the right colors', (WidgetTester tester) async {
bool value = false; bool value = false;
await tester.pumpWidget( await tester.pumpWidget(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment