diff --git a/packages/flutter/lib/src/widgets/autocomplete.dart b/packages/flutter/lib/src/widgets/autocomplete.dart index 1aeac638b4..dc5b7c3537 100644 --- a/packages/flutter/lib/src/widgets/autocomplete.dart +++ b/packages/flutter/lib/src/widgets/autocomplete.dart @@ -277,8 +277,11 @@ class _RawAutocompleteState extends State> late final Map> _actionMap; late final _AutocompleteCallbackAction _previousOptionAction; late final _AutocompleteCallbackAction _nextOptionAction; + late final _AutocompleteCallbackAction _hideOptionsAction; Iterable _options = Iterable.empty(); T? _selection; + bool _userHidOptions = false; + String _lastFieldText = ''; final ValueNotifier _highlightedOptionIndex = ValueNotifier(0); static const Map _shortcuts = { @@ -291,31 +294,41 @@ class _RawAutocompleteState extends State> // True iff the state indicates that the options should be visible. bool get _shouldShowOptions { - return _focusNode.hasFocus && _selection == null && _options.isNotEmpty; + return !_userHidOptions && _focusNode.hasFocus && _selection == null && _options.isNotEmpty; } // Called when _textEditingController changes. Future _onChangedField() async { + final TextEditingValue value = _textEditingController.value; final Iterable options = await widget.optionsBuilder( - _textEditingController.value, + value, ); _options = options; _updateHighlight(_highlightedOptionIndex.value); if (_selection != null - && _textEditingController.text != widget.displayStringForOption(_selection!)) { + && value.text != widget.displayStringForOption(_selection!)) { _selection = null; } + + // Make sure the options are no longer hidden if the content of the field + // changes (ignore selection changes). + if (value.text != _lastFieldText) { + _userHidOptions = false; + _lastFieldText = value.text; + } _updateOverlay(); } // Called when the field's FocusNode changes. void _onChangedFocus() { + // Options should no longer be hidden when the field is re-focused. + _userHidOptions = !_focusNode.hasFocus; _updateOverlay(); } // Called from fieldViewBuilder when the user submits the field. void _onFieldSubmitted() { - if (_options.isEmpty) { + if (_options.isEmpty || _userHidOptions) { return; } _select(_options.elementAt(_highlightedOptionIndex.value)); @@ -340,13 +353,30 @@ class _RawAutocompleteState extends State> } void _highlightPreviousOption(AutocompletePreviousOptionIntent intent) { + if (_userHidOptions) { + _userHidOptions = false; + _updateOverlay(); + return; + } _updateHighlight(_highlightedOptionIndex.value - 1); } void _highlightNextOption(AutocompleteNextOptionIntent intent) { + if (_userHidOptions) { + _userHidOptions = false; + _updateOverlay(); + return; + } _updateHighlight(_highlightedOptionIndex.value + 1); } + void _hideOptions(DismissIntent intent) { + if (!_userHidOptions) { + _userHidOptions = true; + _updateOverlay(); + } + } + void _setActionsEnabled(bool enabled) { // The enabled state determines whether the action will consume the // key shortcut or let it continue on to the underlying text field. @@ -354,11 +384,12 @@ class _RawAutocompleteState extends State> // can be used to navigate them. _previousOptionAction.enabled = enabled; _nextOptionAction.enabled = enabled; + _hideOptionsAction.enabled = enabled; } // Hide or show the options overlay, if needed. void _updateOverlay() { - _setActionsEnabled(_shouldShowOptions); + _setActionsEnabled(_focusNode.hasFocus && _selection == null && _options.isNotEmpty); if (_shouldShowOptions) { _floatingOptions?.remove(); _floatingOptions = OverlayEntry( @@ -434,9 +465,11 @@ class _RawAutocompleteState extends State> _focusNode.addListener(_onChangedFocus); _previousOptionAction = _AutocompleteCallbackAction(onInvoke: _highlightPreviousOption); _nextOptionAction = _AutocompleteCallbackAction(onInvoke: _highlightNextOption); + _hideOptionsAction = _AutocompleteCallbackAction(onInvoke: _hideOptions); _actionMap = > { AutocompletePreviousOptionIntent: _previousOptionAction, AutocompleteNextOptionIntent: _nextOptionAction, + DismissIntent: _hideOptionsAction, }; SchedulerBinding.instance.addPostFrameCallback((Duration _) { _updateOverlay(); diff --git a/packages/flutter/test/widgets/autocomplete_test.dart b/packages/flutter/test/widgets/autocomplete_test.dart index fdcee8e7d2..e95b66da36 100644 --- a/packages/flutter/test/widgets/autocomplete_test.dart +++ b/packages/flutter/test/widgets/autocomplete_test.dart @@ -793,6 +793,96 @@ void main() { expect(textEditingController.text, 'goose'); }); + testWidgets('can hide and show options with the keyboard', (WidgetTester tester) async { + final GlobalKey fieldKey = GlobalKey(); + final GlobalKey optionsKey = GlobalKey(); + late Iterable lastOptions; + late FocusNode focusNode; + late TextEditingController textEditingController; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RawAutocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { + focusNode = fieldFocusNode; + textEditingController = fieldTextEditingController; + return TextFormField( + key: fieldKey, + focusNode: focusNode, + controller: textEditingController, + onFieldSubmitted: (String value) { + onFieldSubmitted(); + }, + ); + }, + optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + lastOptions = options; + return Container(key: optionsKey); + }, + ), + ), + ), + ); + + // Enter text. The options are filtered by the text. + focusNode.requestFocus(); + await tester.enterText(find.byKey(fieldKey), 'ele'); + await tester.pumpAndSettle(); + expect(find.byKey(fieldKey), findsOneWidget); + expect(find.byKey(optionsKey), findsOneWidget); + expect(lastOptions.length, 2); + expect(lastOptions.elementAt(0), 'chameleon'); + expect(lastOptions.elementAt(1), 'elephant'); + + // Hide the options. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.byKey(fieldKey), findsOneWidget); + expect(find.byKey(optionsKey), findsNothing); + + // Show the options again by pressing arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(find.byKey(optionsKey), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.byKey(optionsKey), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(find.byKey(optionsKey), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.byKey(optionsKey), findsNothing); + + // Show the options again by re-focusing the field. + focusNode.unfocus(); + await tester.pump(); + expect(find.byKey(optionsKey), findsNothing); + focusNode.requestFocus(); + await tester.pump(); + expect(find.byKey(optionsKey), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.byKey(optionsKey), findsNothing); + + // Show the options again by editing the text (but not when selecting text + // or moving the caret). + await tester.enterText(find.byKey(fieldKey), 'elep'); + await tester.pump(); + expect(find.byKey(optionsKey), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.byKey(optionsKey), findsNothing); + textEditingController.selection = TextSelection.fromPosition(const TextPosition(offset: 3)); + await tester.pump(); + expect(find.byKey(optionsKey), findsNothing); + }); + testWidgets('optionsViewBuilders can use AutocompleteHighlightedOption to highlight selected option', (WidgetTester tester) async { final GlobalKey fieldKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey();