Unverified Commit ad1a44d0 authored by Qun Cheng's avatar Qun Cheng Committed by GitHub

Add `requestFocusOnTap` to `DropdownMenu` (#117504)

* Add canRequestFocus to TextField and requestFocusOnTap to DropdownMenu

* Address comments

* Address comments

---------
Co-authored-by: 's avatarQun Cheng <quncheng@google.com>
parent fc3e8243
......@@ -67,7 +67,9 @@ class _DropdownMenuExampleState extends State<DropdownMenuExample> {
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;
......
......@@ -135,6 +135,7 @@ class DropdownMenu<T> extends StatefulWidget {
this.controller,
this.initialSelection,
this.onSelected,
this.requestFocusOnTap,
required this.dropdownMenuEntries,
});
......@@ -228,6 +229,19 @@ class DropdownMenu<T> extends StatefulWidget {
/// 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 DropdownMenu<T> extends StatefulWidget {
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 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
}
}
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<T> extends State<DropdownMenu<T>> {
@override
void dispose() {
_textEditingController.dispose();
super.dispose();
}
......@@ -489,13 +518,12 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
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<T> extends State<DropdownMenu<T>> {
width: widget.width,
children: <Widget>[
TextField(
focusNode: _textFocusNode,
canRequestFocus: canRequestFocus(),
enableInteractiveSelection: canRequestFocus(),
textAlignVertical: TextAlignVertical.center,
style: effectiveTextStyle,
controller: _textEditingController,
onEditingComplete: () {
......
......@@ -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<TextField> 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<TextField> 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;
}
......
......@@ -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<TestMenu>));
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<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 @@ void main() {
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 @@ 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<DropdownMenuEntry<TestMenu>> menuWithDisabledItems = <DropdownMenuEntry<TestMenu>>[
const DropdownMenuEntry<TestMenu>(value: TestMenu.mainMenu0, label: 'Item 0'),
......@@ -614,9 +616,32 @@ void main() {
);
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 @@ void main() {
);
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 @@ void main() {
);
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 @@ void main() {
theme: themeData,
home: Scaffold(
body: DropdownMenu<TestMenu>(
requestFocusOnTap: true,
dropdownMenuEntries: menuChildren,
),
),
......@@ -715,6 +741,7 @@ void main() {
theme: themeData,
home: Scaffold(
body: DropdownMenu<TestMenu>(
requestFocusOnTap: true,
enableFilter: true,
dropdownMenuEntries: menuChildren,
),
......@@ -748,6 +775,7 @@ void main() {
builder: (BuildContext context, StateSetter setState) {
return Scaffold(
body: DropdownMenu<TestMenu>(
requestFocusOnTap: true,
enableFilter: true,
dropdownMenuEntries: menuChildren,
controller: controller,
......@@ -804,29 +832,47 @@ void main() {
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 @@ 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<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 {
......
......@@ -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>{ 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', () {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment