Skip to content

Commit

Permalink
Dismiss Autocomplete with ESC (#97790)
Browse files Browse the repository at this point in the history
  • Loading branch information
markusaksli-nc committed Feb 16, 2022
1 parent 7c3f79f commit 919d205
Show file tree
Hide file tree
Showing 2 changed files with 128 additions and 5 deletions.
43 changes: 38 additions & 5 deletions packages/flutter/lib/src/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,11 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
late final Map<Type, Action<Intent>> _actionMap;
late final _AutocompleteCallbackAction<AutocompletePreviousOptionIntent> _previousOptionAction;
late final _AutocompleteCallbackAction<AutocompleteNextOptionIntent> _nextOptionAction;
late final _AutocompleteCallbackAction<DismissIntent> _hideOptionsAction;
Iterable<T> _options = Iterable<T>.empty();
T? _selection;
bool _userHidOptions = false;
String _lastFieldText = '';
final ValueNotifier<int> _highlightedOptionIndex = ValueNotifier<int>(0);

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

// 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<void> _onChangedField() async {
final TextEditingValue value = _textEditingController.value;
final Iterable<T> 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));
Expand All @@ -340,25 +353,43 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
}

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.
// They should only be enabled when the options are showing so shortcuts
// 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(
Expand Down Expand Up @@ -434,9 +465,11 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
_focusNode.addListener(_onChangedFocus);
_previousOptionAction = _AutocompleteCallbackAction<AutocompletePreviousOptionIntent>(onInvoke: _highlightPreviousOption);
_nextOptionAction = _AutocompleteCallbackAction<AutocompleteNextOptionIntent>(onInvoke: _highlightNextOption);
_hideOptionsAction = _AutocompleteCallbackAction<DismissIntent>(onInvoke: _hideOptions);
_actionMap = <Type, Action<Intent>> {
AutocompletePreviousOptionIntent: _previousOptionAction,
AutocompleteNextOptionIntent: _nextOptionAction,
DismissIntent: _hideOptionsAction,
};
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
_updateOverlay();
Expand Down
90 changes: 90 additions & 0 deletions packages/flutter/test/widgets/autocomplete_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> lastOptions;
late FocusNode focusNode;
late TextEditingController textEditingController;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: RawAutocomplete<String>(
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<String> onSelected, Iterable<String> 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();
Expand Down

0 comments on commit 919d205

Please sign in to comment.