Add `requestFocusOnTap` to `DropdownMenu` (#117504)
* Add canRequestFocus to TextField and requestFocusOnTap to DropdownMenu
* Address comments
* Address comments
---------
Co-authored-by: Qun Cheng <quncheng@google.com>
diff --git a/examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart b/examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart
index 44fcbe8..d1aa12b 100644
--- a/examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart
+++ b/examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart
@@ -67,7 +67,9 @@
leadingIcon: const Icon(Icons.search),
label: const Text('Icon'),
dropdownMenuEntries: iconEntries,
- inputDecorationTheme: const InputDecorationTheme(filled: true),
+ inputDecorationTheme: const InputDecorationTheme(
+ filled: true,
+ contentPadding: EdgeInsets.symmetric(vertical: 5.0)),
onSelected: (IconLabel? icon) {
setState(() {
selectedIcon = icon;
diff --git a/packages/flutter/lib/src/material/dropdown_menu.dart b/packages/flutter/lib/src/material/dropdown_menu.dart
index 097fdef..2555c54 100644
--- a/packages/flutter/lib/src/material/dropdown_menu.dart
+++ b/packages/flutter/lib/src/material/dropdown_menu.dart
@@ -135,6 +135,7 @@
this.controller,
this.initialSelection,
this.onSelected,
+ this.requestFocusOnTap,
required this.dropdownMenuEntries,
});
@@ -228,6 +229,19 @@
/// Defaults to null. If null, only the text field is updated.
final ValueChanged<T?>? onSelected;
+ /// Determine if the dropdown button requests focus and the on-screen virtual
+ /// keyboard is shown in response to a touch event.
+ ///
+ /// By default, on mobile platforms, tapping on the text field and opening
+ /// the menu will not cause a focus request and the virtual keyboard will not
+ /// appear. The default behavior for desktop platforms is for the dropdown to
+ /// take the focus.
+ ///
+ /// Defaults to null. Setting this field to true or false, rather than allowing
+ /// the implementation to choose based on the platform, can be useful for
+ /// applications that want to override the default behavior.
+ final bool? requestFocusOnTap;
+
/// Descriptions of the menu items in the [DropdownMenu].
///
/// This is a required parameter. It is recommended that at least one [DropdownMenuEntry]
@@ -242,7 +256,6 @@
class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
final GlobalKey _anchorKey = GlobalKey();
final GlobalKey _leadingKey = GlobalKey();
- final FocusNode _textFocusNode = FocusNode();
final MenuController _controller = MenuController();
late final TextEditingController _textEditingController;
late bool _enableFilter;
@@ -288,6 +301,23 @@
}
}
+ bool canRequestFocus() {
+ if (widget.requestFocusOnTap != null) {
+ return widget.requestFocusOnTap!;
+ }
+
+ switch (Theme.of(context).platform) {
+ case TargetPlatform.iOS:
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ return false;
+ case TargetPlatform.macOS:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ return true;
+ }
+ }
+
void refreshLeadingPadding() {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
@@ -428,7 +458,6 @@
@override
void dispose() {
- _textEditingController.dispose();
super.dispose();
}
@@ -489,13 +518,12 @@
builder: (BuildContext context, MenuController controller, Widget? child) {
assert(_initialMenu != null);
final Widget trailingButton = Padding(
- padding: const EdgeInsets.symmetric(horizontal: 4.0),
+ padding: const EdgeInsets.all(4.0),
child: IconButton(
isSelected: controller.isOpen,
icon: widget.trailingIcon ?? const Icon(Icons.arrow_drop_down),
selectedIcon: widget.selectedTrailingIcon ?? const Icon(Icons.arrow_drop_up),
onPressed: () {
- _textFocusNode.requestFocus();
handlePressed(controller);
},
),
@@ -511,7 +539,9 @@
width: widget.width,
children: <Widget>[
TextField(
- focusNode: _textFocusNode,
+ canRequestFocus: canRequestFocus(),
+ enableInteractiveSelection: canRequestFocus(),
+ textAlignVertical: TextAlignVertical.center,
style: effectiveTextStyle,
controller: _textEditingController,
onEditingComplete: () {
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart
index 74695c7..ac527f2 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -312,6 +312,7 @@
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
this.contextMenuBuilder = _defaultContextMenuBuilder,
+ this.canRequestFocus = true,
this.spellCheckConfiguration,
this.magnifierConfiguration,
}) : assert(obscuringCharacter.length == 1),
@@ -762,6 +763,13 @@
/// * [AdaptiveTextSelectionToolbar], which is built by default.
final EditableTextContextMenuBuilder? contextMenuBuilder;
+ /// Determine whether this text field can request the primary focus.
+ ///
+ /// Defaults to true. If false, the text field will not request focus
+ /// when tapped, or when its context menu is displayed. If false it will not
+ /// be possible to move the focus to the text field with tab key.
+ final bool canRequestFocus;
+
static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
@@ -976,7 +984,7 @@
if (widget.controller == null) {
_createLocalController();
}
- _effectiveFocusNode.canRequestFocus = _isEnabled;
+ _effectiveFocusNode.canRequestFocus = widget.canRequestFocus && _isEnabled;
_effectiveFocusNode.addListener(_handleFocusChanged);
}
@@ -984,7 +992,7 @@
final NavigationMode mode = MediaQuery.maybeNavigationModeOf(context) ?? NavigationMode.traditional;
switch (mode) {
case NavigationMode.traditional:
- return _isEnabled;
+ return widget.canRequestFocus && _isEnabled;
case NavigationMode.directional:
return true;
}
diff --git a/packages/flutter/test/material/dropdown_menu_test.dart b/packages/flutter/test/material/dropdown_menu_test.dart
index bb314a4..d1fbdb2 100644
--- a/packages/flutter/test/material/dropdown_menu_test.dart
+++ b/packages/flutter/test/material/dropdown_menu_test.dart
@@ -125,7 +125,7 @@
final Finder textField = find.byType(TextField);
final Size anchorSize = tester.getSize(textField);
- expect(anchorSize, const Size(180.0, 54.0));
+ expect(anchorSize, const Size(180.0, 56.0));
await tester.tap(find.byType(DropdownMenu<TestMenu>));
await tester.pumpAndSettle();
@@ -143,7 +143,7 @@
final Finder anchor = find.byType(TextField);
final Size size = tester.getSize(anchor);
- expect(size, const Size(200.0, 54.0));
+ expect(size, const Size(200.0, 56.0));
await tester.tap(anchor);
await tester.pumpAndSettle();
@@ -428,7 +428,7 @@
expect(menuMaterial, findsOneWidget);
});
- testWidgets('Down key can highlight the menu item', (WidgetTester tester) async {
+ testWidgets('Down key can highlight the menu item on desktop platforms', (WidgetTester tester) async {
final ThemeData themeData = ThemeData();
await tester.pumpWidget(MaterialApp(
theme: themeData,
@@ -468,9 +468,9 @@
);
item0material = tester.widget<Material>(button0Material);
expect(item0material.color, Colors.transparent); // the previous item should not be highlighted.
- });
+ }, variant: TargetPlatformVariant.desktop());
- testWidgets('Up key can highlight the menu item', (WidgetTester tester) async {
+ testWidgets('Up key can highlight the menu item on desktop platforms', (WidgetTester tester) async {
final ThemeData themeData = ThemeData();
await tester.pumpWidget(MaterialApp(
theme: themeData,
@@ -510,9 +510,10 @@
item5material = tester.widget<Material>(button5Material);
expect(item5material.color, Colors.transparent); // the previous item should not be highlighted.
- });
+ }, variant: TargetPlatformVariant.desktop());
- testWidgets('The text input should match the label of the menu item while pressing down key', (WidgetTester tester) async {
+ testWidgets('The text input should match the label of the menu item '
+ 'while pressing down key on desktop platforms', (WidgetTester tester) async {
final ThemeData themeData = ThemeData();
await tester.pumpWidget(MaterialApp(
theme: themeData,
@@ -540,9 +541,10 @@
await simulateKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(find.widgetWithText(TextField, 'Item 2'), findsOneWidget);
- });
+ }, variant: TargetPlatformVariant.desktop());
- testWidgets('The text input should match the label of the menu item while pressing up key', (WidgetTester tester) async {
+ testWidgets('The text input should match the label of the menu item '
+ 'while pressing up key on desktop platforms', (WidgetTester tester) async {
final ThemeData themeData = ThemeData();
await tester.pumpWidget(MaterialApp(
theme: themeData,
@@ -570,9 +572,9 @@
await simulateKeyDownEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
expect(find.widgetWithText(TextField, 'Item 3'), findsOneWidget);
- });
+ }, variant: TargetPlatformVariant.desktop());
- testWidgets('Disabled button will be skipped while pressing up/down key', (WidgetTester tester) async {
+ testWidgets('Disabled button will be skipped while pressing up/down key on desktop platforms', (WidgetTester tester) async {
final ThemeData themeData = ThemeData();
final List<DropdownMenuEntry<TestMenu>> menuWithDisabledItems = <DropdownMenuEntry<TestMenu>>[
const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu0, label: 'Item 0'),
@@ -614,9 +616,32 @@
);
final Material item3Material = tester.widget<Material>(button3Material);
expect(item3Material.color, themeData.colorScheme.onSurface.withOpacity(0.12));
- });
+ }, variant: TargetPlatformVariant.desktop());
- testWidgets('Searching is enabled by default', (WidgetTester tester) async {
+ testWidgets('Searching is enabled by default on mobile platforms if initialSelection is non null', (WidgetTester tester) async {
+ final ThemeData themeData = ThemeData();
+ await tester.pumpWidget(MaterialApp(
+ theme: themeData,
+ home: Scaffold(
+ body: DropdownMenu<TestMenu>(
+ initialSelection: TestMenu.mainMenu1,
+ dropdownMenuEntries: menuChildren,
+ ),
+ ),
+ ));
+
+ // Open the menu
+ await tester.tap(find.byType(DropdownMenu<TestMenu>));
+ await tester.pump();
+ final Finder buttonMaterial = find.descendant(
+ of: find.widgetWithText(MenuItemButton, 'Menu 1').last,
+ matching: find.byType(Material),
+ );
+ final Material itemMaterial = tester.widget<Material>(buttonMaterial);
+ expect(itemMaterial.color, themeData.colorScheme.onSurface.withOpacity(0.12)); // Menu 1 button is highlighted.
+ }, variant: TargetPlatformVariant.mobile());
+
+ testWidgets('Searching is enabled by default on desktop platform', (WidgetTester tester) async {
final ThemeData themeData = ThemeData();
await tester.pumpWidget(MaterialApp(
theme: themeData,
@@ -638,9 +663,9 @@
);
final Material itemMaterial = tester.widget<Material>(buttonMaterial);
expect(itemMaterial.color, themeData.colorScheme.onSurface.withOpacity(0.12)); // Menu 1 button is highlighted.
- });
+ }, variant: TargetPlatformVariant.desktop());
- testWidgets('Highlight can move up/down from the searching result', (WidgetTester tester) async {
+ testWidgets('Highlight can move up/down starting from the searching result on desktop platforms', (WidgetTester tester) async {
final ThemeData themeData = ThemeData();
await tester.pumpWidget(MaterialApp(
theme: themeData,
@@ -684,7 +709,7 @@
);
final Material item5Material = tester.widget<Material>(button5Material);
expect(item5Material.color, themeData.colorScheme.onSurface.withOpacity(0.12));
- });
+ }, variant: TargetPlatformVariant.desktop());
testWidgets('Filtering is disabled by default', (WidgetTester tester) async {
final ThemeData themeData = ThemeData();
@@ -692,6 +717,7 @@
theme: themeData,
home: Scaffold(
body: DropdownMenu<TestMenu>(
+ requestFocusOnTap: true,
dropdownMenuEntries: menuChildren,
),
),
@@ -715,6 +741,7 @@
theme: themeData,
home: Scaffold(
body: DropdownMenu<TestMenu>(
+ requestFocusOnTap: true,
enableFilter: true,
dropdownMenuEntries: menuChildren,
),
@@ -748,6 +775,7 @@
builder: (BuildContext context, StateSetter setState) {
return Scaffold(
body: DropdownMenu<TestMenu>(
+ requestFocusOnTap: true,
enableFilter: true,
dropdownMenuEntries: menuChildren,
controller: controller,
@@ -804,29 +832,47 @@
await tester.tap(find.byType(DropdownMenu<TestMenu>));
await tester.pump();
+ late final bool isMobile;
+ switch (themeData.platform) {
+ case TargetPlatform.android:
+ case TargetPlatform.iOS:
+ case TargetPlatform.fuchsia:
+ isMobile = true;
+ break;
+ case TargetPlatform.macOS:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ isMobile = false;
+ break;
+ }
+ int expectedCount = isMobile ? 0 : 1;
+
// Test onSelected on key press
await simulateKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
- expect(selectionCount, 1);
+ expect(selectionCount, expectedCount);
+ // The desktop platform closed the menu when a completion action is pressed. So we need to reopen it.
+ if (!isMobile) {
+ await tester.tap(find.byType(DropdownMenu<TestMenu>));
+ await tester.pump();
+ }
// Disabled item doesn't trigger onSelected callback.
- await tester.tap(find.byType(DropdownMenu<TestMenu>));
- await tester.pump();
final Finder item1 = find.widgetWithText(MenuItemButton, 'Item 1').last;
await tester.tap(item1);
await tester.pumpAndSettle();
- expect(controller.text, 'Item 0');
- expect(selectionCount, 1);
+ expect(controller.text, isMobile ? '' : 'Item 0');
+ expect(selectionCount, expectedCount);
final Finder item2 = find.widgetWithText(MenuItemButton, 'Item 2').last;
await tester.tap(item2);
await tester.pumpAndSettle();
expect(controller.text, 'Item 2');
- expect(selectionCount, 2);
+ expect(selectionCount, ++expectedCount);
await tester.tap(find.byType(DropdownMenu<TestMenu>));
await tester.pump();
@@ -835,18 +881,20 @@
await tester.pumpAndSettle();
expect(controller.text, 'Item 3');
- expect(selectionCount, 3);
+ expect(selectionCount, ++expectedCount);
- // When typing something in the text field without selecting any of the options,
+ // On desktop platforms, when typing something in the text field without selecting any of the options,
// the onSelected should not be called.
- await tester.enterText(find.byType(TextField).first, 'New Item');
- expect(controller.text, 'New Item');
- expect(selectionCount, 3);
- expect(find.widgetWithText(TextField, 'New Item'), findsOneWidget);
- await tester.enterText(find.byType(TextField).first, '');
- expect(selectionCount, 3);
- expect(controller.text.isEmpty, true);
- });
+ if (!isMobile) {
+ await tester.enterText(find.byType(TextField).first, 'New Item');
+ expect(controller.text, 'New Item');
+ expect(selectionCount, expectedCount);
+ expect(find.widgetWithText(TextField, 'New Item'), findsOneWidget);
+ await tester.enterText(find.byType(TextField).first, '');
+ expect(selectionCount, expectedCount);
+ expect(controller.text.isEmpty, true);
+ }
+ }, variant: TargetPlatformVariant.all());
testWidgets('The selectedValue gives an initial text and highlights the according item', (WidgetTester tester) async {
@@ -882,6 +930,107 @@
final Material itemMaterial = tester.widget<Material>(buttonMaterial);
expect(itemMaterial.color, themeData.colorScheme.onSurface.withOpacity(0.12));
});
+
+ testWidgets('The default text input field should not be focused on mobile platforms '
+ 'when it is tapped', (WidgetTester tester) async {
+ final ThemeData themeData = ThemeData();
+
+ Widget buildDropdownMenu() => MaterialApp(
+ theme: themeData,
+ home: Scaffold(
+ body: Column(
+ children: <Widget>[
+ DropdownMenu<TestMenu>(
+ dropdownMenuEntries: menuChildren,
+ ),
+ ],
+ ),
+ ),
+ );
+
+ // Test default condition.
+ await tester.pumpWidget(buildDropdownMenu());
+ await tester.pump();
+
+ final Finder textFieldFinder = find.byType(TextField);
+ final TextField result = tester.widget<TextField>(textFieldFinder);
+ expect(result.canRequestFocus, false);
+ }, variant: TargetPlatformVariant.mobile());
+
+ testWidgets('The text input field should be focused on desktop platforms '
+ 'when it is tapped', (WidgetTester tester) async {
+ final ThemeData themeData = ThemeData();
+
+ Widget buildDropdownMenu() => MaterialApp(
+ theme: themeData,
+ home: Scaffold(
+ body: Column(
+ children: <Widget>[
+ DropdownMenu<TestMenu>(
+ dropdownMenuEntries: menuChildren,
+ ),
+ ],
+ ),
+ ),
+ );
+
+ await tester.pumpWidget(buildDropdownMenu());
+ await tester.pump();
+
+ final Finder textFieldFinder = find.byType(TextField);
+ final TextField result = tester.widget<TextField>(textFieldFinder);
+ expect(result.canRequestFocus, true);
+ }, variant: TargetPlatformVariant.desktop());
+
+ testWidgets('If requestFocusOnTap is true, the text input field can request focus, '
+ 'otherwise it cannot request focus', (WidgetTester tester) async {
+ final ThemeData themeData = ThemeData();
+
+ Widget buildDropdownMenu({required bool requestFocusOnTap}) => MaterialApp(
+ theme: themeData,
+ home: Scaffold(
+ body: Column(
+ children: <Widget>[
+ DropdownMenu<TestMenu>(
+ requestFocusOnTap: requestFocusOnTap,
+ dropdownMenuEntries: menuChildren,
+ ),
+ ],
+ ),
+ ),
+ );
+
+ // Set requestFocusOnTap to true.
+ await tester.pumpWidget(buildDropdownMenu(requestFocusOnTap: true));
+ await tester.pump();
+
+ final Finder textFieldFinder = find.byType(TextField);
+ final TextField textField = tester.widget<TextField>(textFieldFinder);
+ expect(textField.canRequestFocus, true);
+ // Open the dropdown menu.
+ await tester.tap(textFieldFinder);
+ await tester.pump();
+ // Make a selection.
+ await tester.tap(find.widgetWithText(MenuItemButton, 'Item 0').last);
+ await tester.pump();
+ expect(find.widgetWithText(TextField, 'Item 0'), findsOneWidget);
+
+ // Set requestFocusOnTap to false.
+ await tester.pumpWidget(Container());
+ await tester.pumpWidget(buildDropdownMenu(requestFocusOnTap: false));
+ await tester.pumpAndSettle();
+
+ final Finder textFieldFinder1 = find.byType(TextField);
+ final TextField textField1 = tester.widget<TextField>(textFieldFinder1);
+ expect(textField1.canRequestFocus, false);
+ // Open the dropdown menu.
+ await tester.tap(textFieldFinder1);
+ await tester.pump();
+ // Make a selection.
+ await tester.tap(find.widgetWithText(MenuItemButton, 'Item 0').last);
+ await tester.pump();
+ expect(find.widgetWithText(TextField, 'Item 0'), findsOneWidget);
+ }, variant: TargetPlatformVariant.all());
}
enum TestMenu {
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index b6efeb2..10f2adf 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -13364,6 +13364,48 @@
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
+ testWidgets('Cannot request focus when canRequestFocus is false', (WidgetTester tester) async {
+ final FocusNode focusNode = FocusNode();
+
+ // Default test. The canRequestFocus is true by default and the text field can be focused
+ await tester.pumpWidget(
+ boilerplate(
+ child: TextField(
+ focusNode: focusNode,
+ ),
+ ),
+ );
+ expect(focusNode.hasFocus, isFalse);
+ focusNode.requestFocus();
+ await tester.pump();
+ expect(focusNode.hasFocus, isTrue);
+
+ // Set canRequestFocus to false: the text field cannot be focused when it is tapped/long pressed.
+ await tester.pumpWidget(
+ boilerplate(
+ child: TextField(
+ focusNode: focusNode,
+ canRequestFocus: false,
+ ),
+ ),
+ );
+
+ expect(focusNode.hasFocus, isFalse);
+ focusNode.requestFocus();
+ await tester.pump();
+ expect(focusNode.hasFocus, isFalse);
+
+ // The text field cannot be focused if it is tapped.
+ await tester.tap(find.byType(TextField));
+ await tester.pump();
+ expect(focusNode.hasFocus, isFalse);
+
+ // The text field cannot be focused if it is long pressed.
+ await tester.longPress(find.byType(TextField));
+ await tester.pump();
+ expect(focusNode.hasFocus, isFalse);
+ });
+
group('Right click focus', () {
testWidgets('Can right click to focus multiple times', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/pull/103228
@@ -13518,6 +13560,34 @@
expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, 5);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
+
+ testWidgets('Right clicking cannot request focus if canRequestFocus is false', (WidgetTester tester) async {
+ final FocusNode focusNode = FocusNode();
+ final UniqueKey key = UniqueKey();
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Material(
+ child: Column(
+ children: <Widget>[
+ TextField(
+ key: key,
+ focusNode: focusNode,
+ canRequestFocus: false,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ await tester.tapAt(
+ tester.getCenter(find.byKey(key)),
+ buttons: kSecondaryButton,
+ );
+ await tester.pump();
+
+ expect(focusNode.hasFocus, isFalse);
+ });
});
group('context menu', () {