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
diff --git a/dev/integration_tests/flutter_gallery/test/demo/material/expansion_panels_demo_test.dart b/dev/integration_tests/flutter_gallery/test/demo/material/expansion_panels_demo_test.dart
index e9f9ab6..8c3278c 100644
--- a/dev/integration_tests/flutter_gallery/test/demo/material/expansion_panels_demo_test.dart
+++ b/dev/integration_tests/flutter_gallery/test/demo/material/expansion_panels_demo_test.dart
@@ -40,8 +40,10 @@
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>);
diff --git a/packages/flutter/lib/src/material/radio.dart b/packages/flutter/lib/src/material/radio.dart
index 5ebd28e..fe76b22 100644
--- a/packages/flutter/lib/src/material/radio.dart
+++ b/packages/flutter/lib/src/material/radio.dart
@@ -108,6 +108,7 @@
@required this.value,
@required this.groupValue,
@required this.onChanged,
+ this.toggleable = false,
this.activeColor,
this.focusColor,
this.hoverColor,
@@ -116,6 +117,7 @@
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 @@
/// ```
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 @@
};
}
- void _actionHandler(FocusNode node, Intent intent){
+ void _actionHandler(FocusNode node, Intent intent) {
if (widget.onChanged != null) {
widget.onChanged(widget.value);
}
@@ -241,8 +306,13 @@
}
void _handleChanged(bool selected) {
- if (selected)
+ if (selected == null) {
+ widget.onChanged(null);
+ return;
+ }
+ if (selected) {
widget.onChanged(widget.value);
+ }
}
@override
@@ -276,6 +346,7 @@
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 @@
@required this.hoverColor,
@required this.additionalConstraints,
this.onChanged,
+ @required this.toggleable,
@required this.vsync,
@required this.hasFocus,
@required this.hovering,
@@ -304,6 +376,7 @@
assert(activeColor != null),
assert(inactiveColor != null),
assert(vsync != null),
+ assert(toggleable != null),
super(key: key);
final bool selected;
@@ -314,6 +387,7 @@
final Color focusColor;
final Color hoverColor;
final ValueChanged<bool> onChanged;
+ final bool toggleable;
final TickerProvider vsync;
final BoxConstraints additionalConstraints;
@@ -325,6 +399,7 @@
focusColor: focusColor,
hoverColor: hoverColor,
onChanged: onChanged,
+ tristate: toggleable,
vsync: vsync,
additionalConstraints: additionalConstraints,
hasFocus: hasFocus,
@@ -340,6 +415,7 @@
..focusColor = focusColor
..hoverColor = hoverColor
..onChanged = onChanged
+ ..tristate = toggleable
..additionalConstraints = additionalConstraints
..vsync = vsync
..hasFocus = hasFocus
@@ -355,18 +431,19 @@
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,
diff --git a/packages/flutter/lib/src/material/radio_list_tile.dart b/packages/flutter/lib/src/material/radio_list_tile.dart
index 6187cda..6a56ad2 100644
--- a/packages/flutter/lib/src/material/radio_list_tile.dart
+++ b/packages/flutter/lib/src/material/radio_list_tile.dart
@@ -309,6 +309,7 @@
@required this.value,
@required this.groupValue,
@required this.onChanged,
+ this.toggleable = false,
this.activeColor,
this.title,
this.subtitle,
@@ -317,7 +318,9 @@
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 @@
/// ```
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 @@
value: value,
groupValue: groupValue,
onChanged: onChanged,
+ toggleable: toggleable,
activeColor: activeColor,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
@@ -442,7 +502,15 @@
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,
),
),
diff --git a/packages/flutter/test/material/control_list_tile_test.dart b/packages/flutter/test/material/control_list_tile_test.dart
deleted file mode 100644
index 887ea62..0000000
--- a/packages/flutter/test/material/control_list_tile_test.dart
+++ /dev/null
@@ -1,288 +0,0 @@
-// 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_test/flutter_test.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/rendering.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('RadioListTile should initialize according to groupValue', (WidgetTester tester) async {
- final List<int> values = <int>[0, 1, 2];
- int selectedValue;
- // Constructor parameters are required for [RadioListTile], but they are
- // irrelevant when searching with [find.byType].
- final Type radioListTileType = const RadioListTile<int>(
- value: 0,
- groupValue: 0,
- onChanged: null,
- ).runtimeType;
-
- List<RadioListTile<int>> generatedRadioListTiles;
- List<RadioListTile<int>> findTiles() => find
- .byType(radioListTileType)
- .evaluate()
- .map<Widget>((Element element) => element.widget)
- .cast<RadioListTile<int>>()
- .toList();
-
- Widget buildFrame() {
- return wrap(
- child: StatefulBuilder(
- builder: (BuildContext context, StateSetter setState) {
- return Scaffold(
- body: ListView.builder(
- itemCount: values.length,
- itemBuilder: (BuildContext context, int index) => RadioListTile<int>(
- onChanged: (int value) {
- setState(() { selectedValue = value; });
- },
- value: values[index],
- groupValue: selectedValue,
- title: Text(values[index].toString()),
- ),
- ),
- );
- },
- ),
- );
- }
-
- await tester.pumpWidget(buildFrame());
- generatedRadioListTiles = findTiles();
-
- expect(generatedRadioListTiles[0].checked, equals(false));
- expect(generatedRadioListTiles[1].checked, equals(false));
- expect(generatedRadioListTiles[2].checked, equals(false));
-
- selectedValue = 1;
-
- await tester.pumpWidget(buildFrame());
- generatedRadioListTiles = findTiles();
-
- expect(generatedRadioListTiles[0].checked, equals(false));
- expect(generatedRadioListTiles[1].checked, equals(true));
- expect(generatedRadioListTiles[2].checked, equals(false));
- });
-
- testWidgets('RadioListTile control tests', (WidgetTester tester) async {
- final List<int> values = <int>[0, 1, 2];
- int selectedValue;
- // Constructor parameters are required for [Radio], but they are irrelevant
- // when searching with [find.byType].
- final Type radioType = const Radio<int>(
- value: 0,
- groupValue: 0,
- onChanged: null,
- ).runtimeType;
- final List<dynamic> log = <dynamic>[];
-
- Widget buildFrame() {
- return wrap(
- child: StatefulBuilder(
- builder: (BuildContext context, StateSetter setState) {
- return Scaffold(
- body: ListView.builder(
- itemCount: values.length,
- itemBuilder: (BuildContext context, int index) => RadioListTile<int>(
- onChanged: (int value) {
- log.add(value);
- setState(() { selectedValue = value; });
- },
- value: values[index],
- groupValue: selectedValue,
- title: Text(values[index].toString()),
- ),
- ),
- );
- },
- ),
- );
- }
-
- // Tests for tapping between [Radio] and [ListTile]
- await tester.pumpWidget(buildFrame());
- await tester.tap(find.text('1'));
- log.add('-');
- await tester.tap(find.byType(radioType).at(2));
- expect(log, equals(<dynamic>[1, '-', 2]));
- log.add('-');
- await tester.tap(find.text('1'));
-
- log.clear();
- selectedValue = null;
-
- // Tests for tapping across [Radio]s exclusively
- await tester.pumpWidget(buildFrame());
- await tester.tap(find.byType(radioType).at(1));
- log.add('-');
- await tester.tap(find.byType(radioType).at(2));
- expect(log, equals(<dynamic>[1, '-', 2]));
-
- log.clear();
- selectedValue = null;
-
- // Tests for tapping across [ListTile]s exclusively
- await tester.pumpWidget(buildFrame());
- await tester.tap(find.text('1'));
- log.add('-');
- await tester.tap(find.text('2'));
- expect(log, equals(<dynamic>[1, '-', 2]));
- });
-
- testWidgets('Selected RadioListTile should not trigger onChanged', (WidgetTester tester) async {
- // Regression test for https://github.com/flutter/flutter/issues/30311
- final List<int> values = <int>[0, 1, 2];
- int selectedValue;
- // Constructor parameters are required for [Radio], but they are irrelevant
- // when searching with [find.byType].
- final Type radioType = const Radio<int>(
- value: 0,
- groupValue: 0,
- onChanged: null,
- ).runtimeType;
- final List<dynamic> log = <dynamic>[];
-
- Widget buildFrame() {
- return wrap(
- child: StatefulBuilder(
- builder: (BuildContext context, StateSetter setState) {
- return Scaffold(
- body: ListView.builder(
- itemCount: values.length,
- itemBuilder: (BuildContext context, int index) => RadioListTile<int>(
- onChanged: (int value) {
- log.add(value);
- setState(() { selectedValue = value; });
- },
- value: values[index],
- groupValue: selectedValue,
- title: Text(values[index].toString()),
- ),
- ),
- );
- },
- ),
- );
- }
-
- await tester.pumpWidget(buildFrame());
- await tester.tap(find.text('0'));
- await tester.pump();
- expect(log, equals(<int>[0]));
-
- await tester.tap(find.text('0'));
- expect(log, equals(<int>[0]));
-
- await tester.tap(find.byType(radioType).at(0));
- await tester.pump();
- expect(log, equals(<int>[0]));
- });
-
- 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();
- });
-
-}
diff --git a/packages/flutter/test/material/radio_list_tile_test.dart b/packages/flutter/test/material/radio_list_tile_test.dart
new file mode 100644
index 0000000..fd76a03
--- /dev/null
+++ b/packages/flutter/test/material/radio_list_tile_test.dart
@@ -0,0 +1,575 @@
+// 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 'dart:ui';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutter/material.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('RadioListTile should initialize according to groupValue',
+ (WidgetTester tester) async {
+ final List<int> values = <int>[0, 1, 2];
+ int selectedValue;
+ // Constructor parameters are required for [RadioListTile], but they are
+ // irrelevant when searching with [find.byType].
+ final Type radioListTileType = const RadioListTile<int>(
+ value: 0,
+ groupValue: 0,
+ onChanged: null,
+ ).runtimeType;
+
+ List<RadioListTile<int>> generatedRadioListTiles;
+ List<RadioListTile<int>> findTiles() => find
+ .byType(radioListTileType)
+ .evaluate()
+ .map<Widget>((Element element) => element.widget)
+ .cast<RadioListTile<int>>()
+ .toList();
+
+ Widget buildFrame() {
+ return wrap(
+ child: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return Scaffold(
+ body: ListView.builder(
+ itemCount: values.length,
+ itemBuilder: (BuildContext context, int index) => RadioListTile<int>(
+ onChanged: (int value) {
+ setState(() {
+ selectedValue = value;
+ });
+ },
+ value: values[index],
+ groupValue: selectedValue,
+ title: Text(values[index].toString()),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ await tester.pumpWidget(buildFrame());
+ generatedRadioListTiles = findTiles();
+
+ expect(generatedRadioListTiles[0].checked, equals(false));
+ expect(generatedRadioListTiles[1].checked, equals(false));
+ expect(generatedRadioListTiles[2].checked, equals(false));
+
+ selectedValue = 1;
+
+ await tester.pumpWidget(buildFrame());
+ generatedRadioListTiles = findTiles();
+
+ expect(generatedRadioListTiles[0].checked, equals(false));
+ expect(generatedRadioListTiles[1].checked, equals(true));
+ expect(generatedRadioListTiles[2].checked, equals(false));
+ });
+
+ testWidgets('RadioListTile simple control test', (WidgetTester tester) async {
+ final Key key = UniqueKey();
+ final Key titleKey = UniqueKey();
+ final List<int> log = <int>[];
+
+ await tester.pumpWidget(
+ wrap(
+ child: RadioListTile<int>(
+ key: key,
+ value: 1,
+ groupValue: 2,
+ onChanged: log.add,
+ title: Text('Title', key: titleKey),
+ ),
+ ),
+ );
+
+ await tester.tap(find.byKey(key));
+
+ expect(log, equals(<int>[1]));
+ log.clear();
+
+ await tester.pumpWidget(
+ wrap(
+ child: RadioListTile<int>(
+ key: key,
+ value: 1,
+ groupValue: 1,
+ onChanged: log.add,
+ activeColor: Colors.green[500],
+ title: Text('Title', key: titleKey),
+ ),
+ ),
+ );
+
+ await tester.tap(find.byKey(key));
+
+ expect(log, isEmpty);
+
+ await tester.pumpWidget(
+ wrap(
+ child: RadioListTile<int>(
+ key: key,
+ value: 1,
+ groupValue: 2,
+ onChanged: null,
+ title: Text('Title', key: titleKey),
+ ),
+ ),
+ );
+
+ await tester.tap(find.byKey(key));
+
+ expect(log, isEmpty);
+
+ await tester.pumpWidget(
+ wrap(
+ child: RadioListTile<int>(
+ key: key,
+ value: 1,
+ groupValue: 2,
+ onChanged: log.add,
+ title: Text('Title', key: titleKey),
+ ),
+ ),
+ );
+
+ await tester.tap(find.byKey(titleKey));
+
+ expect(log, equals(<int>[1]));
+ });
+
+ testWidgets('RadioListTile control tests', (WidgetTester tester) async {
+ final List<int> values = <int>[0, 1, 2];
+ int selectedValue;
+ // Constructor parameters are required for [Radio], but they are irrelevant
+ // when searching with [find.byType].
+ final Type radioType = const Radio<int>(
+ value: 0,
+ groupValue: 0,
+ onChanged: null,
+ ).runtimeType;
+ final List<dynamic> log = <dynamic>[];
+
+ Widget buildFrame() {
+ return wrap(
+ child: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return Scaffold(
+ body: ListView.builder(
+ itemCount: values.length,
+ itemBuilder: (BuildContext context, int index) => RadioListTile<int>(
+ onChanged: (int value) {
+ log.add(value);
+ setState(() {
+ selectedValue = value;
+ });
+ },
+ value: values[index],
+ groupValue: selectedValue,
+ title: Text(values[index].toString()),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ // Tests for tapping between [Radio] and [ListTile]
+ await tester.pumpWidget(buildFrame());
+ await tester.tap(find.text('1'));
+ log.add('-');
+ await tester.tap(find.byType(radioType).at(2));
+ expect(log, equals(<dynamic>[1, '-', 2]));
+ log.add('-');
+ await tester.tap(find.text('1'));
+
+ log.clear();
+ selectedValue = null;
+
+ // Tests for tapping across [Radio]s exclusively
+ await tester.pumpWidget(buildFrame());
+ await tester.tap(find.byType(radioType).at(1));
+ log.add('-');
+ await tester.tap(find.byType(radioType).at(2));
+ expect(log, equals(<dynamic>[1, '-', 2]));
+
+ log.clear();
+ selectedValue = null;
+
+ // Tests for tapping across [ListTile]s exclusively
+ await tester.pumpWidget(buildFrame());
+ await tester.tap(find.text('1'));
+ log.add('-');
+ await tester.tap(find.text('2'));
+ expect(log, equals(<dynamic>[1, '-', 2]));
+ });
+
+ testWidgets('Selected RadioListTile should not trigger onChanged', (WidgetTester tester) async {
+ // Regression test for https://github.com/flutter/flutter/issues/30311
+ final List<int> values = <int>[0, 1, 2];
+ int selectedValue;
+ // Constructor parameters are required for [Radio], but they are irrelevant
+ // when searching with [find.byType].
+ final Type radioType = const Radio<int>(
+ value: 0,
+ groupValue: 0,
+ onChanged: null,
+ ).runtimeType;
+ final List<dynamic> log = <dynamic>[];
+
+ Widget buildFrame() {
+ return wrap(
+ child: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return Scaffold(
+ body: ListView.builder(
+ itemCount: values.length,
+ itemBuilder: (BuildContext context, int index) => RadioListTile<int>(
+ onChanged: (int value) {
+ log.add(value);
+ setState(() {
+ selectedValue = value;
+ });
+ },
+ value: values[index],
+ groupValue: selectedValue,
+ title: Text(values[index].toString()),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ await tester.pumpWidget(buildFrame());
+ await tester.tap(find.text('0'));
+ await tester.pump();
+ expect(log, equals(<int>[0]));
+
+ await tester.tap(find.text('0'));
+ await tester.pump();
+ expect(log, equals(<int>[0]));
+
+ await tester.tap(find.byType(radioType).at(0));
+ await tester.pump();
+ expect(log, equals(<int>[0]));
+ });
+
+ testWidgets('Selected RadioListTile should trigger onChanged when toggleable',
+ (WidgetTester tester) async {
+ final List<int> values = <int>[0, 1, 2];
+ int selectedValue;
+ // Constructor parameters are required for [Radio], but they are irrelevant
+ // when searching with [find.byType].
+ final Type radioType = const Radio<int>(
+ value: 0,
+ groupValue: 0,
+ onChanged: null,
+ ).runtimeType;
+ final List<dynamic> log = <dynamic>[];
+
+ Widget buildFrame() {
+ return wrap(
+ child: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return Scaffold(
+ body: ListView.builder(
+ itemCount: values.length,
+ itemBuilder: (BuildContext context, int index) {
+ return RadioListTile<int>(
+ onChanged: (int value) {
+ log.add(value);
+ setState(() {
+ selectedValue = value;
+ });
+ },
+ toggleable: true,
+ value: values[index],
+ groupValue: selectedValue,
+ title: Text(values[index].toString()),
+ );
+ },
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ await tester.pumpWidget(buildFrame());
+ await tester.tap(find.text('0'));
+ await tester.pump();
+ expect(log, equals(<int>[0]));
+
+ await tester.tap(find.text('0'));
+ await tester.pump();
+ expect(log, equals(<int>[0, null]));
+
+ await tester.tap(find.byType(radioType).at(0));
+ await tester.pump();
+ expect(log, equals(<int>[0, null, 0]));
+ });
+
+ testWidgets('RadioListTile 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('RadioListTile semantics', (WidgetTester tester) async {
+ final SemanticsTester semantics = SemanticsTester(tester);
+
+ await tester.pumpWidget(
+ wrap(
+ child: RadioListTile<int>(
+ value: 1,
+ groupValue: 2,
+ onChanged: (int i) {},
+ title: const Text('Title'),
+ ),
+ ),
+ );
+
+ expect(
+ semantics,
+ hasSemantics(
+ TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics(
+ id: 1,
+ flags: <SemanticsFlag>[
+ SemanticsFlag.hasCheckedState,
+ SemanticsFlag.hasEnabledState,
+ SemanticsFlag.isEnabled,
+ SemanticsFlag.isInMutuallyExclusiveGroup,
+ SemanticsFlag.isFocusable,
+ ],
+ actions: <SemanticsAction>[SemanticsAction.tap],
+ label: 'Title',
+ textDirection: TextDirection.ltr,
+ ),
+ ],
+ ),
+ ignoreRect: true,
+ ignoreTransform: true,
+ ),
+ );
+
+ await tester.pumpWidget(
+ wrap(
+ child: RadioListTile<int>(
+ value: 2,
+ groupValue: 2,
+ onChanged: (int i) {},
+ title: const Text('Title'),
+ ),
+ ),
+ );
+
+ expect(
+ semantics,
+ hasSemantics(
+ TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics(
+ id: 1,
+ flags: <SemanticsFlag>[
+ SemanticsFlag.hasCheckedState,
+ SemanticsFlag.isChecked,
+ SemanticsFlag.hasEnabledState,
+ SemanticsFlag.isEnabled,
+ SemanticsFlag.isInMutuallyExclusiveGroup,
+ SemanticsFlag.isFocusable,
+ ],
+ actions: <SemanticsAction>[SemanticsAction.tap],
+ label: 'Title',
+ textDirection: TextDirection.ltr,
+ ),
+ ],
+ ),
+ ignoreRect: true,
+ ignoreTransform: true,
+ ),
+ );
+
+ await tester.pumpWidget(
+ wrap(
+ child: const RadioListTile<int>(
+ value: 1,
+ groupValue: 2,
+ onChanged: null,
+ title: Text('Title'),
+ ),
+ ),
+ );
+
+ expect(
+ semantics,
+ hasSemantics(
+ TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics(
+ id: 1,
+ flags: <SemanticsFlag>[
+ SemanticsFlag.hasCheckedState,
+ SemanticsFlag.hasEnabledState,
+ SemanticsFlag.isInMutuallyExclusiveGroup,
+ SemanticsFlag.isFocusable,
+ ],
+ label: 'Title',
+ textDirection: TextDirection.ltr,
+ ),
+ ],
+ ),
+ ignoreId: true,
+ ignoreRect: true,
+ ignoreTransform: true,
+ ),
+ );
+
+ await tester.pumpWidget(
+ wrap(
+ child: const RadioListTile<int>(
+ value: 2,
+ groupValue: 2,
+ onChanged: null,
+ title: Text('Title'),
+ ),
+ ),
+ );
+
+ expect(
+ semantics,
+ hasSemantics(
+ TestSemantics.root(
+ children: <TestSemantics>[
+ TestSemantics(
+ id: 1,
+ flags: <SemanticsFlag>[
+ SemanticsFlag.hasCheckedState,
+ SemanticsFlag.isChecked,
+ SemanticsFlag.hasEnabledState,
+ SemanticsFlag.isInMutuallyExclusiveGroup,
+ ],
+ label: 'Title',
+ textDirection: TextDirection.ltr,
+ ),
+ ],
+ ),
+ ignoreId: true,
+ ignoreRect: true,
+ ignoreTransform: true,
+ ),
+ );
+
+ semantics.dispose();
+ });
+
+ testWidgets('RadioListTile has semantic events', (WidgetTester tester) async {
+ final SemanticsTester semantics = SemanticsTester(tester);
+ final Key key = UniqueKey();
+ dynamic semanticEvent;
+ int radioValue = 2;
+ SystemChannels.accessibility.setMockMessageHandler((dynamic message) async {
+ semanticEvent = message;
+ });
+
+ await tester.pumpWidget(
+ wrap(
+ child: RadioListTile<int>(
+ key: key,
+ value: 1,
+ groupValue: radioValue,
+ onChanged: (int i) {
+ radioValue = i;
+ },
+ title: const Text('Title'),
+ ),
+ ),
+ );
+
+ await tester.tap(find.byKey(key));
+ await tester.pump();
+ final RenderObject object = tester.firstRenderObject(find.byKey(key));
+
+ expect(radioValue, 1);
+ expect(semanticEvent, <String, dynamic>{
+ 'type': 'tap',
+ 'nodeId': object.debugSemantics.id,
+ 'data': <String, dynamic>{},
+ });
+ expect(object.debugSemantics.getSemanticsData().hasAction(SemanticsAction.tap), true);
+
+ semantics.dispose();
+ SystemChannels.accessibility.setMockMessageHandler(null);
+ });
+}
diff --git a/packages/flutter/test/material/radio_test.dart b/packages/flutter/test/material/radio_test.dart
index 272512c..49f5e6d 100644
--- a/packages/flutter/test/material/radio_test.dart
+++ b/packages/flutter/test/material/radio_test.dart
@@ -66,6 +66,61 @@
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 @@
);
});
- 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');
diff --git a/packages/flutter/test/material/switch_list_tile_test.dart b/packages/flutter/test/material/switch_list_tile_test.dart
index a2c76c9..4769d6b 100644
--- a/packages/flutter/test/material/switch_list_tile_test.dart
+++ b/packages/flutter/test/material/switch_list_tile_test.dart
@@ -8,7 +8,113 @@
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(