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(