Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

Commit 919d205

Browse files
Dismiss Autocomplete with ESC (#97790)
1 parent 7c3f79f commit 919d205

File tree

2 files changed

+128
-5
lines changed

2 files changed

+128
-5
lines changed

packages/flutter/lib/src/widgets/autocomplete.dart

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,11 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
277277
late final Map<Type, Action<Intent>> _actionMap;
278278
late final _AutocompleteCallbackAction<AutocompletePreviousOptionIntent> _previousOptionAction;
279279
late final _AutocompleteCallbackAction<AutocompleteNextOptionIntent> _nextOptionAction;
280+
late final _AutocompleteCallbackAction<DismissIntent> _hideOptionsAction;
280281
Iterable<T> _options = Iterable<T>.empty();
281282
T? _selection;
283+
bool _userHidOptions = false;
284+
String _lastFieldText = '';
282285
final ValueNotifier<int> _highlightedOptionIndex = ValueNotifier<int>(0);
283286

284287
static const Map<ShortcutActivator, Intent> _shortcuts = <ShortcutActivator, Intent>{
@@ -291,31 +294,41 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
291294

292295
// True iff the state indicates that the options should be visible.
293296
bool get _shouldShowOptions {
294-
return _focusNode.hasFocus && _selection == null && _options.isNotEmpty;
297+
return !_userHidOptions && _focusNode.hasFocus && _selection == null && _options.isNotEmpty;
295298
}
296299

297300
// Called when _textEditingController changes.
298301
Future<void> _onChangedField() async {
302+
final TextEditingValue value = _textEditingController.value;
299303
final Iterable<T> options = await widget.optionsBuilder(
300-
_textEditingController.value,
304+
value,
301305
);
302306
_options = options;
303307
_updateHighlight(_highlightedOptionIndex.value);
304308
if (_selection != null
305-
&& _textEditingController.text != widget.displayStringForOption(_selection!)) {
309+
&& value.text != widget.displayStringForOption(_selection!)) {
306310
_selection = null;
307311
}
312+
313+
// Make sure the options are no longer hidden if the content of the field
314+
// changes (ignore selection changes).
315+
if (value.text != _lastFieldText) {
316+
_userHidOptions = false;
317+
_lastFieldText = value.text;
318+
}
308319
_updateOverlay();
309320
}
310321

311322
// Called when the field's FocusNode changes.
312323
void _onChangedFocus() {
324+
// Options should no longer be hidden when the field is re-focused.
325+
_userHidOptions = !_focusNode.hasFocus;
313326
_updateOverlay();
314327
}
315328

316329
// Called from fieldViewBuilder when the user submits the field.
317330
void _onFieldSubmitted() {
318-
if (_options.isEmpty) {
331+
if (_options.isEmpty || _userHidOptions) {
319332
return;
320333
}
321334
_select(_options.elementAt(_highlightedOptionIndex.value));
@@ -340,25 +353,43 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
340353
}
341354

342355
void _highlightPreviousOption(AutocompletePreviousOptionIntent intent) {
356+
if (_userHidOptions) {
357+
_userHidOptions = false;
358+
_updateOverlay();
359+
return;
360+
}
343361
_updateHighlight(_highlightedOptionIndex.value - 1);
344362
}
345363

346364
void _highlightNextOption(AutocompleteNextOptionIntent intent) {
365+
if (_userHidOptions) {
366+
_userHidOptions = false;
367+
_updateOverlay();
368+
return;
369+
}
347370
_updateHighlight(_highlightedOptionIndex.value + 1);
348371
}
349372

373+
void _hideOptions(DismissIntent intent) {
374+
if (!_userHidOptions) {
375+
_userHidOptions = true;
376+
_updateOverlay();
377+
}
378+
}
379+
350380
void _setActionsEnabled(bool enabled) {
351381
// The enabled state determines whether the action will consume the
352382
// key shortcut or let it continue on to the underlying text field.
353383
// They should only be enabled when the options are showing so shortcuts
354384
// can be used to navigate them.
355385
_previousOptionAction.enabled = enabled;
356386
_nextOptionAction.enabled = enabled;
387+
_hideOptionsAction.enabled = enabled;
357388
}
358389

359390
// Hide or show the options overlay, if needed.
360391
void _updateOverlay() {
361-
_setActionsEnabled(_shouldShowOptions);
392+
_setActionsEnabled(_focusNode.hasFocus && _selection == null && _options.isNotEmpty);
362393
if (_shouldShowOptions) {
363394
_floatingOptions?.remove();
364395
_floatingOptions = OverlayEntry(
@@ -434,9 +465,11 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
434465
_focusNode.addListener(_onChangedFocus);
435466
_previousOptionAction = _AutocompleteCallbackAction<AutocompletePreviousOptionIntent>(onInvoke: _highlightPreviousOption);
436467
_nextOptionAction = _AutocompleteCallbackAction<AutocompleteNextOptionIntent>(onInvoke: _highlightNextOption);
468+
_hideOptionsAction = _AutocompleteCallbackAction<DismissIntent>(onInvoke: _hideOptions);
437469
_actionMap = <Type, Action<Intent>> {
438470
AutocompletePreviousOptionIntent: _previousOptionAction,
439471
AutocompleteNextOptionIntent: _nextOptionAction,
472+
DismissIntent: _hideOptionsAction,
440473
};
441474
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
442475
_updateOverlay();

packages/flutter/test/widgets/autocomplete_test.dart

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,96 @@ void main() {
793793
expect(textEditingController.text, 'goose');
794794
});
795795

796+
testWidgets('can hide and show options with the keyboard', (WidgetTester tester) async {
797+
final GlobalKey fieldKey = GlobalKey();
798+
final GlobalKey optionsKey = GlobalKey();
799+
late Iterable<String> lastOptions;
800+
late FocusNode focusNode;
801+
late TextEditingController textEditingController;
802+
await tester.pumpWidget(
803+
MaterialApp(
804+
home: Scaffold(
805+
body: RawAutocomplete<String>(
806+
optionsBuilder: (TextEditingValue textEditingValue) {
807+
return kOptions.where((String option) {
808+
return option.contains(textEditingValue.text.toLowerCase());
809+
});
810+
},
811+
fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
812+
focusNode = fieldFocusNode;
813+
textEditingController = fieldTextEditingController;
814+
return TextFormField(
815+
key: fieldKey,
816+
focusNode: focusNode,
817+
controller: textEditingController,
818+
onFieldSubmitted: (String value) {
819+
onFieldSubmitted();
820+
},
821+
);
822+
},
823+
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
824+
lastOptions = options;
825+
return Container(key: optionsKey);
826+
},
827+
),
828+
),
829+
),
830+
);
831+
832+
// Enter text. The options are filtered by the text.
833+
focusNode.requestFocus();
834+
await tester.enterText(find.byKey(fieldKey), 'ele');
835+
await tester.pumpAndSettle();
836+
expect(find.byKey(fieldKey), findsOneWidget);
837+
expect(find.byKey(optionsKey), findsOneWidget);
838+
expect(lastOptions.length, 2);
839+
expect(lastOptions.elementAt(0), 'chameleon');
840+
expect(lastOptions.elementAt(1), 'elephant');
841+
842+
// Hide the options.
843+
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
844+
await tester.pump();
845+
expect(find.byKey(fieldKey), findsOneWidget);
846+
expect(find.byKey(optionsKey), findsNothing);
847+
848+
// Show the options again by pressing arrow keys
849+
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
850+
await tester.pump();
851+
expect(find.byKey(optionsKey), findsOneWidget);
852+
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
853+
await tester.pump();
854+
expect(find.byKey(optionsKey), findsNothing);
855+
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
856+
await tester.pump();
857+
expect(find.byKey(optionsKey), findsOneWidget);
858+
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
859+
await tester.pump();
860+
expect(find.byKey(optionsKey), findsNothing);
861+
862+
// Show the options again by re-focusing the field.
863+
focusNode.unfocus();
864+
await tester.pump();
865+
expect(find.byKey(optionsKey), findsNothing);
866+
focusNode.requestFocus();
867+
await tester.pump();
868+
expect(find.byKey(optionsKey), findsOneWidget);
869+
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
870+
await tester.pump();
871+
expect(find.byKey(optionsKey), findsNothing);
872+
873+
// Show the options again by editing the text (but not when selecting text
874+
// or moving the caret).
875+
await tester.enterText(find.byKey(fieldKey), 'elep');
876+
await tester.pump();
877+
expect(find.byKey(optionsKey), findsOneWidget);
878+
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
879+
await tester.pump();
880+
expect(find.byKey(optionsKey), findsNothing);
881+
textEditingController.selection = TextSelection.fromPosition(const TextPosition(offset: 3));
882+
await tester.pump();
883+
expect(find.byKey(optionsKey), findsNothing);
884+
});
885+
796886
testWidgets('optionsViewBuilders can use AutocompleteHighlightedOption to highlight selected option', (WidgetTester tester) async {
797887
final GlobalKey fieldKey = GlobalKey();
798888
final GlobalKey optionsKey = GlobalKey();

0 commit comments

Comments
 (0)