Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call saving this value locally, I guess it could change after the await.

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);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is super thorough and easy to follow 👍


testWidgets('optionsViewBuilders can use AutocompleteHighlightedOption to highlight selected option', (WidgetTester tester) async {
final GlobalKey fieldKey = GlobalKey();
final GlobalKey optionsKey = GlobalKey();
Expand Down