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 44fcbe8de786..d1aa12b22efc 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 @@ class _DropdownMenuExampleState extends State { 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 097fdef45fd2..2555c54c4c7a 100644 --- a/packages/flutter/lib/src/material/dropdown_menu.dart +++ b/packages/flutter/lib/src/material/dropdown_menu.dart @@ -135,6 +135,7 @@ class DropdownMenu extends StatefulWidget { this.controller, this.initialSelection, this.onSelected, + this.requestFocusOnTap, required this.dropdownMenuEntries, }); @@ -228,6 +229,19 @@ class DropdownMenu extends StatefulWidget { /// Defaults to null. If null, only the text field is updated. final ValueChanged? 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 DropdownMenu extends StatefulWidget { class _DropdownMenuState extends State> { 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 @@ class _DropdownMenuState extends State> { } } + 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 @@ class _DropdownMenuState extends State> { @override void dispose() { - _textEditingController.dispose(); super.dispose(); } @@ -489,13 +518,12 @@ class _DropdownMenuState extends State> { 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 @@ class _DropdownMenuState extends State> { width: widget.width, children: [ 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 74695c71dc4f..ac527f286764 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -312,6 +312,7 @@ class TextField extends StatefulWidget { this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, this.contextMenuBuilder = _defaultContextMenuBuilder, + this.canRequestFocus = true, this.spellCheckConfiguration, this.magnifierConfiguration, }) : assert(obscuringCharacter.length == 1), @@ -762,6 +763,13 @@ class TextField extends StatefulWidget { /// * [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 @@ class _TextFieldState extends State with RestorationMixin implements if (widget.controller == null) { _createLocalController(); } - _effectiveFocusNode.canRequestFocus = _isEnabled; + _effectiveFocusNode.canRequestFocus = widget.canRequestFocus && _isEnabled; _effectiveFocusNode.addListener(_handleFocusChanged); } @@ -984,7 +992,7 @@ class _TextFieldState extends State with RestorationMixin implements 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 bb314a43f967..d1fbdb2b3463 100644 --- a/packages/flutter/test/material/dropdown_menu_test.dart +++ b/packages/flutter/test/material/dropdown_menu_test.dart @@ -125,7 +125,7 @@ void main() { 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)); await tester.pumpAndSettle(); @@ -143,7 +143,7 @@ void main() { 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 @@ void main() { 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 @@ void main() { ); item0material = tester.widget(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 @@ void main() { item5material = tester.widget(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 @@ void main() { 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 @@ void main() { 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> menuWithDisabledItems = >[ const DropdownMenuEntry(value: TestMenu.mainMenu0, label: 'Item 0'), @@ -614,9 +616,32 @@ void main() { ); final Material item3Material = tester.widget(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( + initialSelection: TestMenu.mainMenu1, + dropdownMenuEntries: menuChildren, + ), + ), + )); + + // Open the menu + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + final Finder buttonMaterial = find.descendant( + of: find.widgetWithText(MenuItemButton, 'Menu 1').last, + matching: find.byType(Material), + ); + final Material itemMaterial = tester.widget(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 @@ void main() { ); final Material itemMaterial = tester.widget(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 @@ void main() { ); final Material item5Material = tester.widget(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 @@ void main() { theme: themeData, home: Scaffold( body: DropdownMenu( + requestFocusOnTap: true, dropdownMenuEntries: menuChildren, ), ), @@ -715,6 +741,7 @@ void main() { theme: themeData, home: Scaffold( body: DropdownMenu( + requestFocusOnTap: true, enableFilter: true, dropdownMenuEntries: menuChildren, ), @@ -748,6 +775,7 @@ void main() { builder: (BuildContext context, StateSetter setState) { return Scaffold( body: DropdownMenu( + requestFocusOnTap: true, enableFilter: true, dropdownMenuEntries: menuChildren, controller: controller, @@ -804,29 +832,47 @@ void main() { await tester.tap(find.byType(DropdownMenu)); 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)); + await tester.pump(); + } // Disabled item doesn't trigger onSelected callback. - await tester.tap(find.byType(DropdownMenu)); - 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)); await tester.pump(); @@ -835,18 +881,20 @@ void main() { 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 @@ void main() { final Material itemMaterial = tester.widget(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: [ + DropdownMenu( + dropdownMenuEntries: menuChildren, + ), + ], + ), + ), + ); + + // Test default condition. + await tester.pumpWidget(buildDropdownMenu()); + await tester.pump(); + + final Finder textFieldFinder = find.byType(TextField); + final TextField result = tester.widget(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: [ + DropdownMenu( + dropdownMenuEntries: menuChildren, + ), + ], + ), + ), + ); + + await tester.pumpWidget(buildDropdownMenu()); + await tester.pump(); + + final Finder textFieldFinder = find.byType(TextField); + final TextField result = tester.widget(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: [ + DropdownMenu( + 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(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(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 b6efeb2495a3..10f2adf5e8b7 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -13364,6 +13364,48 @@ void main() { 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 @@ void main() { expect(controller.selection.baseOffset, 0); expect(controller.selection.extentOffset, 5); }, variant: const TargetPlatformVariant({ 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: [ + 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', () {