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) =>
List<Radio<Location>> get _radios => List<Radio<Location>>.from(
_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>`.
// Another approach is to grab the `runtimeType` of a dummy instance; see packages/flutter/test/material/control_list_tile_test.dart.
// [find.byType] and [find.widgetWithText] do not match subclasses; `Radio` is
// 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 =>
find.byWidgetPredicate((Widget w) => w is Radio<Location>);
......
......@@ -108,6 +108,7 @@ class Radio<T> extends StatefulWidget {
@required this.value,
@required this.groupValue,
@required this.onChanged,
this.toggleable = false,
this.activeColor,
this.focusColor,
this.hoverColor,
......@@ -116,6 +117,7 @@ class Radio<T> extends StatefulWidget {
this.focusNode,
this.autofocus = false,
}) : assert(autofocus != null),
assert(toggleable != null),
super(key: key);
/// The value represented by this radio button.
......@@ -155,6 +157,69 @@ class Radio<T> extends StatefulWidget {
/// ```
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.
///
/// Defaults to [ThemeData.toggleableActiveColor].
......@@ -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) {
widget.onChanged(widget.value);
}
......@@ -241,8 +306,13 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
}
void _handleChanged(bool selected) {
if (selected)
if (selected == null) {
widget.onChanged(null);
return;
}
if (selected) {
widget.onChanged(widget.value);
}
}
@override
......@@ -276,6 +346,7 @@ class _RadioState<T> extends State<Radio<T>> with TickerProviderStateMixin {
focusColor: widget.focusColor ?? themeData.focusColor,
hoverColor: widget.hoverColor ?? themeData.hoverColor,
onChanged: enabled ? _handleChanged : null,
toggleable: widget.toggleable,
additionalConstraints: additionalConstraints,
vsync: this,
hasFocus: _focused,
......@@ -297,6 +368,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
@required this.hoverColor,
@required this.additionalConstraints,
this.onChanged,
@required this.toggleable,
@required this.vsync,
@required this.hasFocus,
@required this.hovering,
......@@ -304,6 +376,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
assert(activeColor != null),
assert(inactiveColor != null),
assert(vsync != null),
assert(toggleable != null),
super(key: key);
final bool selected;
......@@ -314,6 +387,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
final Color focusColor;
final Color hoverColor;
final ValueChanged<bool> onChanged;
final bool toggleable;
final TickerProvider vsync;
final BoxConstraints additionalConstraints;
......@@ -325,6 +399,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
focusColor: focusColor,
hoverColor: hoverColor,
onChanged: onChanged,
tristate: toggleable,
vsync: vsync,
additionalConstraints: additionalConstraints,
hasFocus: hasFocus,
......@@ -340,6 +415,7 @@ class _RadioRenderObjectWidget extends LeafRenderObjectWidget {
..focusColor = focusColor
..hoverColor = hoverColor
..onChanged = onChanged
..tristate = toggleable
..additionalConstraints = additionalConstraints
..vsync = vsync
..hasFocus = hasFocus
......@@ -355,18 +431,19 @@ class _RenderRadio extends RenderToggleable {
Color focusColor,
Color hoverColor,
ValueChanged<bool> onChanged,
bool tristate,
BoxConstraints additionalConstraints,
@required TickerProvider vsync,
bool hasFocus,
bool hovering,
}) : super(
value: value,
tristate: false,
activeColor: activeColor,
inactiveColor: inactiveColor,
focusColor: focusColor,
hoverColor: hoverColor,
onChanged: onChanged,
tristate: tristate,
additionalConstraints: additionalConstraints,
vsync: vsync,
hasFocus: hasFocus,
......
......@@ -309,6 +309,7 @@ class RadioListTile<T> extends StatelessWidget {
@required this.value,
@required this.groupValue,
@required this.onChanged,
this.toggleable = false,
this.activeColor,
this.title,
this.subtitle,
......@@ -317,7 +318,9 @@ class RadioListTile<T> extends StatelessWidget {
this.secondary,
this.selected = false,
this.controlAffinity = ListTileControlAffinity.platform,
}) : assert(isThreeLine != null),
}) : assert(toggleable != null),
assert(isThreeLine != null),
assert(!isThreeLine || subtitle != null),
assert(selected != null),
assert(controlAffinity != null),
......@@ -361,6 +364,62 @@ class RadioListTile<T> extends StatelessWidget {
/// ```
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.
///
/// Defaults to accent color of the current [Theme].
......@@ -416,6 +475,7 @@ class RadioListTile<T> extends StatelessWidget {
value: value,
groupValue: groupValue,
onChanged: onChanged,
toggleable: toggleable,
activeColor: activeColor,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
......@@ -442,7 +502,15 @@ class RadioListTile<T> extends StatelessWidget {
isThreeLine: isThreeLine,
dense: dense,
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,
),
),
......
......@@ -66,6 +66,61 @@ void main() {
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 {
final Key key1 = UniqueKey();
await tester.pumpWidget(
......@@ -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;
int groupValue = 1;
const Key radioKey0 = Key('radio0');
......
......@@ -8,7 +8,113 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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() {
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 {
bool value = false;
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