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', () {