Skip to content

Commit 7438e54

Browse files
feat: Added FocusNode prop for DropdownMenu Trailing Icon Button (flutter#172753)
<!-- Thanks for filing a pull request! Reviewers are typically assigned within a week of filing a request. To learn more about code review, see our documentation on Tree Hygiene: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md --> Added an API to control the FocusNode of DropdownMenu Trailing IconButton. This is also an improvement to [DropdownMenu Focus](flutter#156412) since introducing the FocusNode to IconButton brings uniformity to the Focus Traversal. Previously there were two cases - If FocusNode is passed to DropdownMenu, 3 ```tab``` presses were required to focus the Trailing IconButton - If FocusNode is not passed then 3 ```tab``` presses were needed for MacOS, Windows & Linux Platforms and 2 for Android, iOS & Fuchsia. This PR allows Focusing the IconButton by single ```tab``` press Fixes flutter#172687 by passing a FocusNode with ```skipTraversal:true``` ### Before - MacOS - https://github.com/user-attachments/assets/d2f6f3dd-e37c-4293-8c0e-6b73650a830b - IOS without a FocusNode - https://github.com/user-attachments/assets/4a03bb98-faac-44b6-809d-9887941972c3 - IOS with a FocusNode - https://github.com/user-attachments/assets/9f0f5e0d-6f20-4d21-af9b-52e3cb0014e5 ### After - MacOS - https://github.com/user-attachments/assets/6d9d77be-760c-43f3-b23e-cef0dbdc3f47 - IOS without FocusNode - https://github.com/user-attachments/assets/2146dd72-9464-4af4-932d-f88463e6012c - IOS with a FocusNode - https://github.com/user-attachments/assets/035ce567-838d-4ed8-943c-5b508c1fd09f ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
1 parent e90bd46 commit 7438e54

File tree

2 files changed

+172
-24
lines changed

2 files changed

+172
-24
lines changed

packages/flutter/lib/src/material/dropdown_menu.dart

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ class DropdownMenu<T> extends StatefulWidget {
167167
this.leadingIcon,
168168
this.trailingIcon,
169169
this.showTrailingIcon = true,
170+
this.trailingIconFocusNode,
170171
this.label,
171172
this.hintText,
172173
this.helperText,
@@ -202,6 +203,7 @@ class DropdownMenu<T> extends StatefulWidget {
202203
(inputDecorationTheme is InputDecorationTheme ||
203204
inputDecorationTheme is InputDecorationThemeData),
204205
),
206+
assert(trailingIconFocusNode == null || showTrailingIcon),
205207
_inputDecorationTheme = inputDecorationTheme;
206208

207209
/// Determine if the [DropdownMenu] is enabled.
@@ -245,9 +247,37 @@ class DropdownMenu<T> extends StatefulWidget {
245247
/// If [trailingIcon] is set, [DropdownMenu] will use that trailing icon,
246248
/// otherwise a default trailing icon will be created.
247249
///
250+
/// If [showTrailingIcon] is false, [trailingIconFocusNode] must be null.
251+
///
248252
/// Defaults to true.
249253
final bool showTrailingIcon;
250254

255+
/// Defines the FocusNode for the trailing icon.
256+
///
257+
/// If [showTrailingIcon] is false, [trailingIconFocusNode] must be null.
258+
///
259+
/// The [focusNode] is a long-lived object that's typically managed by a
260+
/// [StatefulWidget] parent. See [FocusNode] for more information.
261+
///
262+
/// To give the keyboard focus to this widget, provide a [focusNode] and then
263+
/// use the current [FocusScope] to request the focus:
264+
///
265+
/// ```dart
266+
/// FocusScope.of(context).requestFocus(myFocusNode);
267+
/// ```
268+
///
269+
/// This happens automatically when the widget is tapped.
270+
///
271+
/// To be notified when the widget gains or loses the focus, add a listener
272+
/// to the [focusNode]:
273+
///
274+
/// ```dart
275+
/// myFocusNode.addListener(() { print(myFocusNode.hasFocus); });
276+
/// ```
277+
///
278+
/// If null, this widget will create its own [FocusNode].
279+
final FocusNode? trailingIconFocusNode;
280+
251281
/// Optional widget that describes the input field.
252282
///
253283
/// When the input field is empty and unfocused, the label is displayed on
@@ -588,6 +618,10 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
588618
int? _selectedEntryIndex;
589619
late final void Function() _clearSelectedEntryIndex;
590620

621+
FocusNode? _localTrailingIconButtonFocusNode;
622+
FocusNode get _trailingIconButtonFocusNode =>
623+
widget.trailingIconFocusNode ?? (_localTrailingIconButtonFocusNode ??= FocusNode());
624+
591625
@override
592626
void initState() {
593627
super.initState();
@@ -616,6 +650,8 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
616650
_localTextEditingController?.dispose();
617651
_localTextEditingController = null;
618652
_internalFocudeNode.dispose();
653+
_localTrailingIconButtonFocusNode?.dispose();
654+
_localTrailingIconButtonFocusNode = null;
619655
super.dispose();
620656
}
621657

@@ -1084,6 +1120,7 @@ class _DropdownMenuState<T> extends State<DropdownMenu<T>> {
10841120
? Padding(
10851121
padding: isCollapsed ? EdgeInsets.zero : const EdgeInsets.all(4.0),
10861122
child: IconButton(
1123+
focusNode: _trailingIconButtonFocusNode,
10871124
isSelected: controller.isOpen,
10881125
constraints: widget.inputDecorationTheme?.suffixIconConstraints,
10891126
padding: isCollapsed ? EdgeInsets.zero : null,

packages/flutter/test/material/dropdown_menu_test.dart

Lines changed: 135 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import 'dart:ui';
66

7-
import 'package:flutter/foundation.dart';
87
import 'package:flutter/material.dart';
98
import 'package:flutter/rendering.dart';
109
import 'package:flutter/services.dart';
@@ -3493,14 +3492,19 @@ void main() {
34933492
// Regression test for https://github.com/flutter/flutter/issues/131120.
34943493
testWidgets('Focus traversal ignores non visible entries', (WidgetTester tester) async {
34953494
final FocusNode buttonFocusNode = FocusNode();
3495+
final FocusNode textFieldFocusNode = FocusNode();
34963496
addTearDown(buttonFocusNode.dispose);
3497+
addTearDown(textFieldFocusNode.dispose);
34973498

34983499
await tester.pumpWidget(
34993500
MaterialApp(
35003501
home: Scaffold(
35013502
body: Column(
35023503
children: <Widget>[
3503-
DropdownMenu<TestMenu>(dropdownMenuEntries: menuChildren),
3504+
DropdownMenu<TestMenu>(
3505+
dropdownMenuEntries: menuChildren,
3506+
focusNode: textFieldFocusNode,
3507+
),
35043508
ElevatedButton(
35053509
focusNode: buttonFocusNode,
35063510
onPressed: () {},
@@ -3512,18 +3516,17 @@ void main() {
35123516
),
35133517
);
35143518

3515-
// Move the focus to the text field.
3516-
primaryFocus!.nextFocus();
3517-
await tester.pump();
3518-
final Element textField = tester.element(find.byType(TextField));
3519-
expect(Focus.of(textField).hasFocus, isTrue);
3520-
35213519
// Move the focus to the dropdown trailing icon.
35223520
primaryFocus!.nextFocus();
35233521
await tester.pump();
35243522
final Element iconButton = tester.firstElement(find.byIcon(Icons.arrow_drop_down));
35253523
expect(Focus.of(iconButton).hasFocus, isTrue);
35263524

3525+
// Move the focus to the text field.
3526+
primaryFocus!.nextFocus();
3527+
await tester.pump();
3528+
expect(textFieldFocusNode.hasFocus, isTrue);
3529+
35273530
// Move the focus to the elevated button.
35283531
primaryFocus!.nextFocus();
35293532
await tester.pump();
@@ -4110,11 +4113,11 @@ void main() {
41104113
),
41114114
),
41124115
);
4113-
// Pressing the tab key 3 times moves the focus to the icon button.
4114-
for (int i = 0; i < 3; i++) {
4115-
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
4116-
await tester.pump();
4117-
}
4116+
4117+
// Adding FocusNode to IconButton causes the IconButton to receive focus.
4118+
// Thus it does not matter if the TextField has a FocusNode or not.
4119+
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
4120+
await tester.pump();
41184121

41194122
// Now the focus is on the icon button.
41204123
final Element iconButton = tester.firstElement(find.byIcon(Icons.arrow_drop_down));
@@ -4151,17 +4154,11 @@ void main() {
41514154
),
41524155
),
41534156
);
4154-
// If there is no `FocusNode`, by default, `TextField` can receive focus
4155-
// on desktop platforms, but not on mobile platforms. Therefore, on desktop
4156-
// platforms, it takes 3 tabs to reach the icon button.
4157-
final int tabCount = switch (defaultTargetPlatform) {
4158-
TargetPlatform.iOS || TargetPlatform.android || TargetPlatform.fuchsia => 2,
4159-
TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => 3,
4160-
};
4161-
for (int i = 0; i < tabCount; i++) {
4162-
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
4163-
await tester.pump();
4164-
}
4157+
4158+
// Adding FocusNode to IconButton causes the IconButton to receive focus.
4159+
// Thus it does not matter if the TextField has a FocusNode or not.
4160+
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
4161+
await tester.pump();
41654162

41664163
// Now the focus is on the icon button.
41674164
final Element iconButton = tester.firstElement(find.byIcon(Icons.arrow_drop_down));
@@ -4588,6 +4585,120 @@ void main() {
45884585
},
45894586
);
45904587

4588+
testWidgets('DropdownMenu trailingIconFocusNode is created when not provided', (
4589+
WidgetTester tester,
4590+
) async {
4591+
final FocusNode textFieldFocusNode = FocusNode();
4592+
final FocusNode buttonFocusNode = FocusNode();
4593+
addTearDown(textFieldFocusNode.dispose);
4594+
addTearDown(buttonFocusNode.dispose);
4595+
4596+
await tester.pumpWidget(
4597+
MaterialApp(
4598+
home: Scaffold(
4599+
body: Column(
4600+
children: <Widget>[
4601+
DropdownMenu<TestMenu>(
4602+
dropdownMenuEntries: menuChildren,
4603+
focusNode: textFieldFocusNode,
4604+
),
4605+
ElevatedButton(
4606+
focusNode: buttonFocusNode,
4607+
onPressed: () {},
4608+
child: const Text('Button'),
4609+
),
4610+
],
4611+
),
4612+
),
4613+
),
4614+
);
4615+
4616+
primaryFocus!.nextFocus();
4617+
await tester.pump();
4618+
4619+
// Ensure the trailing icon does not have focus.
4620+
// If FocusNode is not created then the TextField will have focus.
4621+
final Element iconButton = tester.firstElement(find.byIcon(Icons.arrow_drop_down));
4622+
expect(Focus.of(iconButton).hasFocus, isTrue);
4623+
4624+
// Ensure the TextField has focus.
4625+
primaryFocus!.nextFocus();
4626+
await tester.pump();
4627+
expect(textFieldFocusNode.hasFocus, isTrue);
4628+
4629+
// Ensure the button has focus.
4630+
primaryFocus!.nextFocus();
4631+
await tester.pump();
4632+
expect(buttonFocusNode.hasFocus, isTrue);
4633+
});
4634+
4635+
testWidgets('DropdownMenu trailingIconFocusNode is used when provided', (
4636+
WidgetTester tester,
4637+
) async {
4638+
final FocusNode textFieldFocusNode = FocusNode();
4639+
final FocusNode trailingIconFocusNode = FocusNode();
4640+
final FocusNode buttonFocusNode = FocusNode();
4641+
addTearDown(textFieldFocusNode.dispose);
4642+
addTearDown(trailingIconFocusNode.dispose);
4643+
addTearDown(buttonFocusNode.dispose);
4644+
4645+
await tester.pumpWidget(
4646+
MaterialApp(
4647+
home: Scaffold(
4648+
body: Column(
4649+
children: <Widget>[
4650+
DropdownMenu<TestMenu>(
4651+
dropdownMenuEntries: menuChildren,
4652+
focusNode: textFieldFocusNode,
4653+
trailingIconFocusNode: trailingIconFocusNode,
4654+
),
4655+
ElevatedButton(
4656+
focusNode: buttonFocusNode,
4657+
onPressed: () {},
4658+
child: const Text('Button'),
4659+
),
4660+
],
4661+
),
4662+
),
4663+
),
4664+
);
4665+
4666+
primaryFocus!.nextFocus();
4667+
await tester.pump();
4668+
4669+
// Ensure the trailing icon has focus.
4670+
expect(trailingIconFocusNode.hasFocus, isTrue);
4671+
4672+
// Ensure the TextField has focus.
4673+
primaryFocus!.nextFocus();
4674+
await tester.pump();
4675+
expect(textFieldFocusNode.hasFocus, isTrue);
4676+
4677+
// Ensure the button has focus.
4678+
primaryFocus!.nextFocus();
4679+
await tester.pump();
4680+
expect(buttonFocusNode.hasFocus, isTrue);
4681+
});
4682+
4683+
testWidgets(
4684+
'Throw assertion error when showTrailingIcon is false and trailingIconFocusNode is provided',
4685+
(WidgetTester tester) async {
4686+
expect(() {
4687+
final FocusNode focusNode = FocusNode();
4688+
addTearDown(focusNode.dispose);
4689+
MaterialApp(
4690+
home: Scaffold(
4691+
body: DropdownMenu<TestMenu>(
4692+
showTrailingIcon: false,
4693+
trailingIconFocusNode: focusNode,
4694+
dropdownMenuEntries: menuChildren,
4695+
),
4696+
),
4697+
);
4698+
}, throwsAssertionError);
4699+
},
4700+
);
4701+
45914702
testWidgets('DropdownMenu can set cursorHeight', (WidgetTester tester) async {
45924703
const double cursorHeight = 4.0;
45934704
await tester.pumpWidget(

0 commit comments

Comments
 (0)