| // 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/material.dart'; |
| import 'package:flutter/semantics.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:flutter_test/flutter_test.dart'; |
| |
| void main() { |
| testWidgets('Radio group control test', (WidgetTester tester) async { |
| final key0 = UniqueKey(); |
| final key1 = UniqueKey(); |
| |
| await tester.pumpWidget( |
| Material( |
| child: TestRadioGroup<int>( |
| child: Column( |
| children: <Widget>[ |
| Radio<int>(key: key0, value: 0), |
| Radio<int>(key: key1, value: 1), |
| ], |
| ), |
| ), |
| ), |
| ); |
| expect( |
| tester.getSemantics(find.byKey(key0)), |
| isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), |
| ); |
| expect( |
| tester.getSemantics(find.byKey(key1)), |
| isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), |
| ); |
| |
| await tester.tap(find.byKey(key0)); |
| await tester.pumpAndSettle(); |
| expect( |
| tester.getSemantics(find.byKey(key0)), |
| isSemantics(isInMutuallyExclusiveGroup: true, isChecked: true, isEnabled: true), |
| ); |
| expect( |
| tester.getSemantics(find.byKey(key1)), |
| isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), |
| ); |
| |
| await tester.tap(find.byKey(key1)); |
| await tester.pumpAndSettle(); |
| expect( |
| tester.getSemantics(find.byKey(key0)), |
| isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), |
| ); |
| expect( |
| tester.getSemantics(find.byKey(key1)), |
| isSemantics(isInMutuallyExclusiveGroup: true, isChecked: true, isEnabled: true), |
| ); |
| }); |
| |
| testWidgets('Radio group can have disabled radio', (WidgetTester tester) async { |
| final key0 = UniqueKey(); |
| final key1 = UniqueKey(); |
| |
| await tester.pumpWidget( |
| Material( |
| child: TestRadioGroup<int>( |
| child: Column( |
| children: <Widget>[ |
| Radio<int>(key: key0, value: 0, enabled: false), |
| Radio<int>(key: key1, value: 1), |
| ], |
| ), |
| ), |
| ), |
| ); |
| expect( |
| tester.getSemantics(find.byKey(key0)), |
| isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: false), |
| ); |
| expect( |
| tester.getSemantics(find.byKey(key1)), |
| isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), |
| ); |
| |
| await tester.tap(find.byKey(key0)); |
| await tester.pumpAndSettle(); |
| // Can't be select because the radio is disabled. |
| expect( |
| tester.getSemantics(find.byKey(key0)), |
| isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: false), |
| ); |
| expect( |
| tester.getSemantics(find.byKey(key1)), |
| isSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), |
| ); |
| }); |
| |
| testWidgets('Radio group will not merge up', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| Material( |
| child: Semantics( |
| container: true, |
| child: Column( |
| children: <Widget>[ |
| Checkbox(value: true, onChanged: (bool? value) {}), |
| const TestRadioGroup<int>( |
| child: Column(children: <Widget>[Radio<int>(value: 0), Radio<int>(value: 1)]), |
| ), |
| Checkbox(value: true, onChanged: (bool? value) {}), |
| ], |
| ), |
| ), |
| ), |
| ); |
| final SemanticsNode radioGroup = tester.getSemantics(find.byType(RadioGroup<int>)); |
| expect(radioGroup.childrenCount, 2); |
| }); |
| |
| testWidgets('Radio group can use arrow key', (WidgetTester tester) async { |
| final key0 = UniqueKey(); |
| final key1 = UniqueKey(); |
| final key2 = UniqueKey(); |
| final focusNode = FocusNode(); |
| addTearDown(focusNode.dispose); |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: TestRadioGroup<int>( |
| child: Column( |
| children: <Widget>[ |
| Radio<int>(key: key0, focusNode: focusNode, value: 0), |
| Radio<int>(key: key1, value: 1), |
| Radio<int>(key: key2, value: 2), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| final TestRadioGroupState<int> state = tester.state<TestRadioGroupState<int>>( |
| find.byType(TestRadioGroup<int>), |
| ); |
| |
| await tester.tap(find.byKey(key0)); |
| focusNode.requestFocus(); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 0); |
| expect(focusNode.hasFocus, isTrue); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 1); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 2); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); |
| await tester.pumpAndSettle(); |
| // Wrap around |
| expect(state.groupValue, 0); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); |
| await tester.pumpAndSettle(); |
| // Wrap around |
| expect(state.groupValue, 2); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); |
| await tester.pumpAndSettle(); |
| // Wrap around |
| expect(state.groupValue, 1); |
| }); |
| |
| testWidgets('Radio group arrow key skips disabled radio', (WidgetTester tester) async { |
| final key0 = UniqueKey(); |
| final key1 = UniqueKey(); |
| final key2 = UniqueKey(); |
| final focusNode = FocusNode(); |
| addTearDown(focusNode.dispose); |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: TestRadioGroup<int>( |
| child: Column( |
| children: <Widget>[ |
| Radio<int>(key: key0, focusNode: focusNode, value: 0), |
| Radio<int>(key: key1, enabled: false, value: 1), |
| Radio<int>(key: key2, value: 2), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| final TestRadioGroupState<int> state = tester.state<TestRadioGroupState<int>>( |
| find.byType(TestRadioGroup<int>), |
| ); |
| |
| await tester.tap(find.byKey(key0)); |
| focusNode.requestFocus(); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 0); |
| expect(focusNode.hasFocus, isTrue); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 2); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); |
| await tester.pumpAndSettle(); |
| // Wrap around |
| expect(state.groupValue, 0); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 2); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 0); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); |
| await tester.pumpAndSettle(); |
| // Wrap around |
| expect(state.groupValue, 2); |
| }); |
| |
| testWidgets('Radio group can tab in and out', (WidgetTester tester) async { |
| final key0 = UniqueKey(); |
| final key1 = UniqueKey(); |
| final key2 = UniqueKey(); |
| final radio0 = FocusNode(); |
| addTearDown(radio0.dispose); |
| final radio1 = FocusNode(); |
| addTearDown(radio1.dispose); |
| final textFieldBefore = FocusNode(); |
| addTearDown(textFieldBefore.dispose); |
| final textFieldAfter = FocusNode(); |
| addTearDown(textFieldAfter.dispose); |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: Column( |
| children: <Widget>[ |
| TextField(focusNode: textFieldBefore), |
| TestRadioGroup<int>( |
| child: Column( |
| children: <Widget>[ |
| Radio<int>(key: key0, focusNode: radio0, value: 0), |
| Radio<int>(key: key1, focusNode: radio1, value: 1), |
| Radio<int>(key: key2, value: 2), |
| ], |
| ), |
| ), |
| TextField(focusNode: textFieldAfter), |
| ], |
| ), |
| ), |
| ), |
| ); |
| |
| textFieldBefore.requestFocus(); |
| await tester.pump(); |
| expect(textFieldBefore.hasFocus, isTrue); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.tab); |
| await tester.pump(); |
| // If no selected radio, focus the first. |
| expect(textFieldBefore.hasFocus, isFalse); |
| expect(radio0.hasFocus, isTrue); |
| |
| // tab out the radio group. |
| await tester.sendKeyEvent(LogicalKeyboardKey.tab); |
| await tester.pump(); |
| expect(radio0.hasFocus, isFalse); |
| expect(radio1.hasFocus, isFalse); |
| expect(textFieldAfter.hasFocus, isTrue); |
| |
| // Select the radio 1 |
| await tester.tap(find.byKey(key1)); |
| await tester.pump(); |
| final TestRadioGroupState<int> state = tester.state<TestRadioGroupState<int>>( |
| find.byType(TestRadioGroup<int>), |
| ); |
| expect(state.groupValue, 1); |
| // focus textFieldAfter again. |
| textFieldAfter.requestFocus(); |
| await tester.pump(); |
| expect(textFieldAfter.hasFocus, isTrue); |
| |
| // shift+tab in the radio again. |
| await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); |
| await tester.sendKeyEvent(LogicalKeyboardKey.tab); |
| await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); |
| await tester.pump(); |
| // Should focus selected radio |
| expect(radio0.hasFocus, isFalse); |
| expect(radio1.hasFocus, isTrue); |
| expect(textFieldAfter.hasFocus, isFalse); |
| }); |
| |
| // Regression test for https://github.com/flutter/flutter/issues/175258. |
| testWidgets('Radio group throws on multiple selection', (WidgetTester tester) async { |
| final key1 = UniqueKey(); |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: TestRadioGroup<int>( |
| child: Column( |
| children: <Widget>[ |
| const Radio<int>(value: 0), |
| Radio<int>(key: key1, value: 1), |
| const Radio<int>(value: 1), |
| const Radio<int>(value: 2), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| expect(tester.takeException(), isNull); |
| |
| await tester.tap(find.byKey(key1)); |
| await tester.pump(); |
| |
| expect( |
| tester.takeException(), |
| isA<FlutterError>().having( |
| (FlutterError e) => e.message, |
| 'message', |
| "RadioGroupPolicy can't be used for a radio group that allows multiple selection.", |
| ), |
| ); |
| }); |
| |
| // Regression test for https://github.com/flutter/flutter/issues/175258. |
| testWidgets('Radio group does not throw when number of children decreases', ( |
| WidgetTester tester, |
| ) async { |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: RadioGroup<int>( |
| onChanged: (_) {}, |
| groupValue: 4, |
| child: const Column( |
| children: <Widget>[ |
| Radio<int>(value: 0), |
| Radio<int>(value: 1), |
| Radio<int>(value: 2), |
| Radio<int>(value: 3), |
| Radio<int>(value: 4), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| expect(tester.takeException(), isNull); |
| |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: RadioGroup<int>( |
| onChanged: (_) {}, |
| groupValue: 4, |
| child: const Column( |
| children: <Widget>[ |
| Radio<int>(value: 1), |
| Radio<int>(value: 2), |
| Radio<int>(value: 3), |
| Radio<int>(value: 4), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| expect(tester.takeException(), isNull); |
| }); |
| |
| // Regression test for https://github.com/flutter/flutter/issues/175511. |
| testWidgets('Radio group does not intercept key events when no radio is focused', ( |
| WidgetTester tester, |
| ) async { |
| final log = <String>[]; |
| late final shortcuts = <ShortcutActivator, Intent>{ |
| const SingleActivator(LogicalKeyboardKey.arrowLeft): VoidCallbackIntent(() => log.add('←')), |
| const SingleActivator(LogicalKeyboardKey.arrowRight): VoidCallbackIntent(() => log.add('→')), |
| const SingleActivator(LogicalKeyboardKey.arrowDown): VoidCallbackIntent(() => log.add('↓')), |
| const SingleActivator(LogicalKeyboardKey.arrowUp): VoidCallbackIntent(() => log.add('↑')), |
| const SingleActivator(LogicalKeyboardKey.space): VoidCallbackIntent(() => log.add('_')), |
| }; |
| |
| final firstRadioFocusNode = FocusNode(); |
| addTearDown(firstRadioFocusNode.dispose); |
| final textFieldFocusNode = FocusNode(); |
| addTearDown(textFieldFocusNode.dispose); |
| |
| await tester.pumpWidget( |
| MaterialApp( |
| home: Material( |
| child: Shortcuts( |
| shortcuts: shortcuts, |
| child: TestRadioGroup<int>( |
| child: Column( |
| children: <Widget>[ |
| Radio<int>(focusNode: firstRadioFocusNode, value: 0), |
| const RadioListTile<int>(value: 1), |
| const Radio<int>(value: 2), |
| TextField(focusNode: textFieldFocusNode), |
| ], |
| ), |
| ), |
| ), |
| ), |
| ), |
| ); |
| |
| final TestRadioGroupState<int> state = tester.state<TestRadioGroupState<int>>( |
| find.byType(TestRadioGroup<int>), |
| ); |
| |
| // Focus on the first radio and toggle it. |
| firstRadioFocusNode.requestFocus(); |
| await tester.pump(); |
| await tester.sendKeyEvent(LogicalKeyboardKey.space); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 0); |
| expect(firstRadioFocusNode.hasFocus, isTrue); |
| |
| // Toggle the second radio with shortcut. |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 1); |
| // Log is empty because radio group handles shortcuts. |
| expect(log, isEmpty); |
| |
| // Toggle the first radio with shortcut. |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 0); |
| expect(log, isEmpty); |
| |
| // Move focus to the text field. |
| // Now radio group will ignore shortcuts as there are no focused radios. |
| textFieldFocusNode.requestFocus(); |
| await tester.pumpAndSettle(); |
| |
| // Verify that shortcuts are not intercepted by the radio group. |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 0); |
| expect(log, <String>['←']); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 0); |
| expect(log, <String>['←', '→']); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 0); |
| expect(log, <String>['←', '→', '↓']); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 0); |
| expect(log, <String>['←', '→', '↓', '↑']); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.space); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 0); |
| expect(log, <String>['←', '→', '↓', '↑', '_']); |
| |
| log.clear(); |
| expect(log, isEmpty); |
| |
| // Focus on the first radio. |
| firstRadioFocusNode.requestFocus(); |
| await tester.pump(); |
| expect(state.groupValue, 0); |
| expect(firstRadioFocusNode.hasFocus, isTrue); |
| |
| // Verify that radio group handles shortcuts again. |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 1); |
| expect(log, isEmpty); |
| |
| await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); |
| await tester.pumpAndSettle(); |
| expect(state.groupValue, 0); |
| expect(log, isEmpty); |
| }); |
| } |
| |
| class TestRadioGroup<T> extends StatefulWidget { |
| const TestRadioGroup({super.key, required this.child}); |
| |
| final Widget child; |
| |
| @override |
| State<StatefulWidget> createState() => TestRadioGroupState<T>(); |
| } |
| |
| class TestRadioGroupState<T> extends State<TestRadioGroup<T>> { |
| T? groupValue; |
| |
| @override |
| Widget build(BuildContext context) { |
| return RadioGroup<T>( |
| onChanged: (T? newValue) { |
| setState(() { |
| groupValue = newValue; |
| }); |
| }, |
| groupValue: groupValue, |
| child: widget.child, |
| ); |
| } |
| } |