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 e9f9ab67133..8c3278c77aa 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 @@ bool _isRadioSelected(int index) => List> get _radios => List>.from( _radioFinder.evaluate().map((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); diff --git a/packages/flutter/lib/src/material/radio.dart b/packages/flutter/lib/src/material/radio.dart index 5ebd28e0f3e..fe76b22259b 100644 --- a/packages/flutter/lib/src/material/radio.dart +++ b/packages/flutter/lib/src/material/radio.dart @@ -108,6 +108,7 @@ class Radio 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 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 extends StatefulWidget { /// ``` final ValueChanged 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 selections = [ + /// '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: [ + /// Radio( + /// 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 extends State> 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 extends State> 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 extends State> 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 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 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 6187cdae96b..6a56ad28dfd 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 @@ class RadioListTile 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 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 extends StatelessWidget { /// ``` final ValueChanged 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 selections = [ + /// '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( + /// 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 extends StatelessWidget { value: value, groupValue: groupValue, onChanged: onChanged, + toggleable: toggleable, activeColor: activeColor, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); @@ -442,7 +502,15 @@ class RadioListTile 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, ), ), 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 887ea6211b2..00000000000 --- 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 values = [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( - value: 0, - groupValue: 0, - onChanged: null, - ).runtimeType; - - List> generatedRadioListTiles; - List> findTiles() => find - .byType(radioListTileType) - .evaluate() - .map((Element element) => element.widget) - .cast>() - .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( - 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 values = [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( - value: 0, - groupValue: 0, - onChanged: null, - ).runtimeType; - final List log = []; - - 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( - 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([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([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([1, '-', 2])); - }); - - testWidgets('Selected RadioListTile should not trigger onChanged', (WidgetTester tester) async { - // Regression test for https://github.com/flutter/flutter/issues/30311 - final List values = [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( - value: 0, - groupValue: 0, - onChanged: null, - ).runtimeType; - final List log = []; - - 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( - 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([0])); - - await tester.tap(find.text('0')); - expect(log, equals([0])); - - await tester.tap(find.byType(radioType).at(0)); - await tester.pump(); - expect(log, equals([0])); - }); - - testWidgets('SwitchListTile control test', (WidgetTester tester) async { - final List log = []; - 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([false, '-', false])); - }); - - testWidgets('SwitchListTile control test', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget(wrap( - child: Column( - children: [ - 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( - 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.rootChild( - id: 1, - rect: const Rect.fromLTWH(0.0, 0.0, 800.0, 56.0), - transform: null, - flags: [ - 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.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.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 00000000000..fd76a03fe18 --- /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 values = [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( + value: 0, + groupValue: 0, + onChanged: null, + ).runtimeType; + + List> generatedRadioListTiles; + List> findTiles() => find + .byType(radioListTileType) + .evaluate() + .map((Element element) => element.widget) + .cast>() + .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( + 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 log = []; + + await tester.pumpWidget( + wrap( + child: RadioListTile( + key: key, + value: 1, + groupValue: 2, + onChanged: log.add, + title: Text('Title', key: titleKey), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals([1])); + log.clear(); + + await tester.pumpWidget( + wrap( + child: RadioListTile( + 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( + 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( + key: key, + value: 1, + groupValue: 2, + onChanged: log.add, + title: Text('Title', key: titleKey), + ), + ), + ); + + await tester.tap(find.byKey(titleKey)); + + expect(log, equals([1])); + }); + + testWidgets('RadioListTile control tests', (WidgetTester tester) async { + final List values = [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( + value: 0, + groupValue: 0, + onChanged: null, + ).runtimeType; + final List log = []; + + 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( + 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([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([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([1, '-', 2])); + }); + + testWidgets('Selected RadioListTile should not trigger onChanged', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/30311 + final List values = [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( + value: 0, + groupValue: 0, + onChanged: null, + ).runtimeType; + final List log = []; + + 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( + 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([0])); + + await tester.tap(find.text('0')); + await tester.pump(); + expect(log, equals([0])); + + await tester.tap(find.byType(radioType).at(0)); + await tester.pump(); + expect(log, equals([0])); + }); + + testWidgets('Selected RadioListTile should trigger onChanged when toggleable', + (WidgetTester tester) async { + final List values = [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( + value: 0, + groupValue: 0, + onChanged: null, + ).runtimeType; + final List log = []; + + 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( + 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([0])); + + await tester.tap(find.text('0')); + await tester.pump(); + expect(log, equals([0, null])); + + await tester.tap(find.byType(radioType).at(0)); + await tester.pump(); + expect(log, equals([0, null, 0])); + }); + + testWidgets('RadioListTile can be toggled when toggleable is set', (WidgetTester tester) async { + final Key key = UniqueKey(); + final List log = []; + + await tester.pumpWidget(Material( + child: Center( + child: Radio( + key: key, + value: 1, + groupValue: 2, + onChanged: log.add, + toggleable: true, + ), + ), + )); + + await tester.tap(find.byKey(key)); + + expect(log, equals([1])); + log.clear(); + + await tester.pumpWidget(Material( + child: Center( + child: Radio( + key: key, + value: 1, + groupValue: 1, + onChanged: log.add, + toggleable: true, + ), + ), + )); + + await tester.tap(find.byKey(key)); + + expect(log, equals([null])); + log.clear(); + + await tester.pumpWidget(Material( + child: Center( + child: Radio( + key: key, + value: 1, + groupValue: null, + onChanged: log.add, + toggleable: true, + ), + ), + )); + + await tester.tap(find.byKey(key)); + + expect(log, equals([1])); + }); + + testWidgets('RadioListTile semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + wrap( + child: RadioListTile( + value: 1, + groupValue: 2, + onChanged: (int i) {}, + title: const Text('Title'), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + flags: [ + SemanticsFlag.hasCheckedState, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.isFocusable, + ], + actions: [SemanticsAction.tap], + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.pumpWidget( + wrap( + child: RadioListTile( + value: 2, + groupValue: 2, + onChanged: (int i) {}, + title: const Text('Title'), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + flags: [ + SemanticsFlag.hasCheckedState, + SemanticsFlag.isChecked, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isInMutuallyExclusiveGroup, + SemanticsFlag.isFocusable, + ], + actions: [SemanticsAction.tap], + label: 'Title', + textDirection: TextDirection.ltr, + ), + ], + ), + ignoreRect: true, + ignoreTransform: true, + ), + ); + + await tester.pumpWidget( + wrap( + child: const RadioListTile( + value: 1, + groupValue: 2, + onChanged: null, + title: Text('Title'), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + flags: [ + 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( + value: 2, + groupValue: 2, + onChanged: null, + title: Text('Title'), + ), + ), + ); + + expect( + semantics, + hasSemantics( + TestSemantics.root( + children: [ + TestSemantics( + id: 1, + flags: [ + 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( + 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, { + 'type': 'tap', + 'nodeId': object.debugSemantics.id, + 'data': {}, + }); + 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 272512c0881..49f5e6de1c7 100644 --- a/packages/flutter/test/material/radio_test.dart +++ b/packages/flutter/test/material/radio_test.dart @@ -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 log = []; + + await tester.pumpWidget(Material( + child: Center( + child: Radio( + key: key, + value: 1, + groupValue: 2, + onChanged: log.add, + toggleable: true, + ), + ), + )); + + await tester.tap(find.byKey(key)); + + expect(log, equals([1])); + log.clear(); + + await tester.pumpWidget(Material( + child: Center( + child: Radio( + key: key, + value: 1, + groupValue: 1, + onChanged: log.add, + toggleable: true, + ), + ), + )); + + await tester.tap(find.byKey(key)); + + expect(log, equals([null])); + log.clear(); + + await tester.pumpWidget(Material( + child: Center( + child: Radio( + key: key, + value: 1, + groupValue: null, + onChanged: log.add, + toggleable: true, + ), + ), + )); + + await tester.tap(find.byKey(key)); + + expect(log, equals([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'); diff --git a/packages/flutter/test/material/switch_list_tile_test.dart b/packages/flutter/test/material/switch_list_tile_test.dart index a2c76c91f57..4769d6b8a4b 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/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 log = []; + 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([false, '-', false])); + }); + + testWidgets('SwitchListTile control test', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget(wrap( + child: Column( + children: [ + 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( + 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.rootChild( + id: 1, + rect: const Rect.fromLTWH(0.0, 0.0, 800.0, 56.0), + transform: null, + flags: [ + 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.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.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(