diff --git a/packages/flutter/lib/src/material/autocomplete.dart b/packages/flutter/lib/src/material/autocomplete.dart index 261e7a59c2..c4e5d5397a 100644 --- a/packages/flutter/lib/src/material/autocomplete.dart +++ b/packages/flutter/lib/src/material/autocomplete.dart @@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart'; import 'ink_well.dart'; import 'material.dart'; import 'text_form_field.dart'; +import 'theme.dart'; /// {@macro flutter.widgets.RawAutocomplete.RawAutocomplete} /// @@ -291,11 +292,13 @@ class _AutocompleteOptions extends StatelessWidget { itemCount: options.length, itemBuilder: (BuildContext context, int index) { final T option = options.elementAt(index); + final bool highlight = AutocompleteHighlightedOption.of(context) == index; return InkWell( onTap: () { onSelected(option); }, - child: Padding( + child: Container( + color: highlight ? Theme.of(context).focusColor : null, padding: const EdgeInsets.all(16.0), child: Text(displayStringForOption(option)), ), diff --git a/packages/flutter/lib/src/widgets/autocomplete.dart b/packages/flutter/lib/src/widgets/autocomplete.dart index fb0fa5e67f..9684fcdb28 100644 --- a/packages/flutter/lib/src/widgets/autocomplete.dart +++ b/packages/flutter/lib/src/widgets/autocomplete.dart @@ -3,16 +3,20 @@ // found in the LICENSE file. import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'actions.dart'; import 'basic.dart'; import 'container.dart'; import 'editable_text.dart'; import 'focus_manager.dart'; import 'framework.dart'; +import 'inherited_notifier.dart'; import 'overlay.dart'; +import 'shortcuts.dart'; /// The type of the [RawAutocomplete] callback which computes the list of -/// optional completions for the widget's field based on the text the user has +/// optional completions for the widget's field, based on the text the user has /// entered so far. /// /// See also: @@ -32,6 +36,11 @@ typedef AutocompleteOnSelected = void Function(T option); /// displays the specified [options] and calls [onSelected] if the user /// selects an option. /// +/// The returned widget from this callback will be wrapped in an +/// [AutocompleteHighlightedOption] inherited widget. This will allow +/// this callback to determine which option is currently highlighted for +/// keyboard navigation. +/// /// See also: /// /// * [RawAutocomplete.optionsViewBuilder], which is of this type. @@ -631,6 +640,15 @@ class RawAutocomplete extends StatefulWidget { /// The options are displayed floating below the field using a /// [CompositedTransformFollower] inside of an [Overlay], not at the same /// place in the widget tree as [RawAutocomplete]. + /// + /// In order to track which item is highlighted by keyboard navigation, the + /// resulting options will be wrapped in an inherited + /// [AutocompleteHighlightedOption] widget. + /// Inside this callback, the index of the highlighted option can be obtained + /// from [AutocompleteHighlightedOption.of] to display the highlighted option + /// with a visual highlight to indicate it will be the option selected from + /// the keyboard. + /// /// {@endtemplate} final AutocompleteOptionsViewBuilder optionsViewBuilder; @@ -711,8 +729,17 @@ class _RawAutocompleteState extends State> final LayerLink _optionsLayerLink = LayerLink(); late TextEditingController _textEditingController; late FocusNode _focusNode; + late final Map> _actionMap; + late final _AutocompleteCallbackAction _previousOptionAction; + late final _AutocompleteCallbackAction _nextOptionAction; Iterable _options = Iterable.empty(); T? _selection; + final ValueNotifier _highlightedOptionIndex = ValueNotifier(0); + + static const Map _shortcuts = { + SingleActivator(LogicalKeyboardKey.arrowUp): AutocompletePreviousOptionIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): AutocompleteNextOptionIntent(), + }; // The OverlayEntry containing the options. OverlayEntry? _floatingOptions; @@ -728,6 +755,7 @@ class _RawAutocompleteState extends State> _textEditingController.value, ); _options = options; + _updateHighlight(_highlightedOptionIndex.value); if (_selection != null && _textEditingController.text != widget.displayStringForOption(_selection!)) { _selection = null; @@ -745,7 +773,7 @@ class _RawAutocompleteState extends State> if (_options.isEmpty) { return; } - _select(_options.first); + _select(_options.elementAt(_highlightedOptionIndex.value)); } // Select the given option and update the widget. @@ -762,8 +790,30 @@ class _RawAutocompleteState extends State> widget.onSelected?.call(_selection!); } + void _updateHighlight(int newIndex) { + _highlightedOptionIndex.value = _options.isEmpty ? 0 : newIndex % _options.length; + } + + void _highlightPreviousOption(AutocompletePreviousOptionIntent intent) { + _updateHighlight(_highlightedOptionIndex.value - 1); + } + + void _highlightNextOption(AutocompleteNextOptionIntent intent) { + _updateHighlight(_highlightedOptionIndex.value + 1); + } + + 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; + } + // Hide or show the options overlay, if needed. void _updateOverlay() { + _setActionsEnabled(_shouldShowOptions); if (_shouldShowOptions) { _floatingOptions?.remove(); _floatingOptions = OverlayEntry( @@ -772,7 +822,14 @@ class _RawAutocompleteState extends State> link: _optionsLayerLink, showWhenUnlinked: false, targetAnchor: Alignment.bottomLeft, - child: widget.optionsViewBuilder(context, _select, _options), + child: AutocompleteHighlightedOption( + highlightIndexNotifier: _highlightedOptionIndex, + child: Builder( + builder: (BuildContext context) { + return widget.optionsViewBuilder(context, _select, _options); + } + ) + ), ); }, ); @@ -830,6 +887,12 @@ class _RawAutocompleteState extends State> _textEditingController.addListener(_onChangedField); _focusNode = widget.focusNode ?? FocusNode(); _focusNode.addListener(_onChangedFocus); + _previousOptionAction = _AutocompleteCallbackAction(onInvoke: _highlightPreviousOption); + _nextOptionAction = _AutocompleteCallbackAction(onInvoke: _highlightNextOption); + _actionMap = > { + AutocompletePreviousOptionIntent: _previousOptionAction, + AutocompleteNextOptionIntent: _nextOptionAction, + }; SchedulerBinding.instance!.addPostFrameCallback((Duration _) { _updateOverlay(); }); @@ -867,17 +930,93 @@ class _RawAutocompleteState extends State> Widget build(BuildContext context) { return Container( key: _fieldKey, - child: CompositedTransformTarget( - link: _optionsLayerLink, - child: widget.fieldViewBuilder == null - ? const SizedBox.shrink() - : widget.fieldViewBuilder!( - context, - _textEditingController, - _focusNode, - _onFieldSubmitted, - ), + child: Shortcuts( + shortcuts: _shortcuts, + child: Actions( + actions: _actionMap, + child: CompositedTransformTarget( + link: _optionsLayerLink, + child: widget.fieldViewBuilder == null + ? const SizedBox.shrink() + : widget.fieldViewBuilder!( + context, + _textEditingController, + _focusNode, + _onFieldSubmitted, + ), + ), + ), ), ); } } + +class _AutocompleteCallbackAction extends CallbackAction { + _AutocompleteCallbackAction({ + required OnInvokeCallback onInvoke, + this.enabled = true, + }) : super(onInvoke: onInvoke); + + bool enabled; + + @override + bool isEnabled(covariant T intent) => enabled; + + @override + bool consumesKey(covariant T intent) => enabled; +} + +/// An [Intent] to highlight the previous option in the autocomplete list. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class AutocompletePreviousOptionIntent extends Intent { + /// Creates an instance of AutocompletePreviousOptionIntent. + const AutocompletePreviousOptionIntent(); +} + +/// An [Intent] to highlight the next option in the autocomplete list. +/// +/// {@macro flutter.widgets.TextEditingIntents.seeAlso} +class AutocompleteNextOptionIntent extends Intent { + /// Creates an instance of AutocompleteNextOptionIntent. + const AutocompleteNextOptionIntent(); +} + +/// An inherited widget used to indicate which autocomplete option should be +/// highlighted for keyboard navigation. +/// +/// The `RawAutoComplete` widget will wrap the options view generated by the +/// `optionsViewBuilder` with this widget to provide the highlighted option's +/// index to the builder. +/// +/// In the builder callback the index of the highlighted option can be obtained +/// by using the static [of] method: +/// +/// ```dart +/// final highlightedIndex = AutocompleteHighlightedOption.of(context); +/// ``` +/// +/// which can then be used to tell which option should be given a visual +/// indication that will be the option selected with the keyboard. +class AutocompleteHighlightedOption extends InheritedNotifier> { + /// Create an instance of AutocompleteHighlightedOption inherited widget. + const AutocompleteHighlightedOption({ + Key? key, + required ValueNotifier highlightIndexNotifier, + required Widget child, + }) : super(key: key, notifier: highlightIndexNotifier, child: child); + + /// Returns the index of the highlighted option from the closest + /// [AutocompleteHighlightedOption] ancestor. + /// + /// If there is no ancestor, it returns 0. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// final highlightedIndex = AutocompleteHighlightedOption.of(context); + /// ``` + static int of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.notifier?.value ?? 0; + } +} diff --git a/packages/flutter/test/material/autocomplete_test.dart b/packages/flutter/test/material/autocomplete_test.dart index 3f78baaa09..4f12097b3f 100644 --- a/packages/flutter/test/material/autocomplete_test.dart +++ b/packages/flutter/test/material/autocomplete_test.dart @@ -3,8 +3,11 @@ // found in the LICENSE file. import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../rendering/mock_canvas.dart'; + class User { const User({ required this.email, @@ -336,18 +339,18 @@ void main() { final Finder listFinder = find.byType(ListView); expect(listFinder, findsNothing); - /// entering `a` returns 9 items(height > `maxOptionsHeight`) from the kOptions - /// so height gets restricted to `maxOptionsHeight =250` + // Entering `a` returns 9 items(height > `maxOptionsHeight`) from the kOptions + // so height gets restricted to `maxOptionsHeight =250`. final double nineItemsHeight = await _getDefaultOptionsHeight(tester, 'a'); expect(nineItemsHeight, equals(maxOptionsHeight)); - /// returns 2 Items (height < `maxOptionsHeight`) - /// so options height shrinks to 2 Items combined height + // Returns 2 Items (height < `maxOptionsHeight`) + // so options height shrinks to 2 Items combined height. final double twoItemsHeight = await _getDefaultOptionsHeight(tester, 'el'); expect(twoItemsHeight, lessThan(maxOptionsHeight)); - /// returns 1 item (height < `maxOptionsHeight`) from `kOptions` - /// so options height shrinks to 1 items height + // Returns 1 item (height < `maxOptionsHeight`) from `kOptions` + // so options height shrinks to 1 items height. final double oneItemsHeight = await _getDefaultOptionsHeight(tester, 'elep'); expect(oneItemsHeight, lessThan(twoItemsHeight)); }); @@ -398,4 +401,56 @@ void main() { expect(field.controller!.text, 'lemur'); expect(lastSelection, 'lemur'); }); + + testWidgets('keyboard navigation of the options properly highlights the option', (WidgetTester tester) async { + + void checkOptionHighlight(String label, Color? color) { + final RenderBox renderBox = tester.renderObject(find.ancestor(matching: find.byType(Container), of: find.text(label))); + if (color != null) { + // Check to see that the container is painted with the highlighted background color. + expect(renderBox, paints..rect(color: color)); + } else { + // There should only be a paragraph painted. + expect(renderBox, paintsExactlyCountTimes(const Symbol('drawRect'), 0)); + expect(renderBox, paints..paragraph()); + } + } + + const Color highlightColor = Color(0xFF112233); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light().copyWith( + focusColor: highlightColor, + ), + home: Scaffold( + body: Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + ), + ), + ), + ); + + await tester.tap(find.byType(TextFormField)); + await tester.enterText(find.byType(TextFormField), 'el'); + await tester.pump(); + expect(find.byType(ListView), findsOneWidget); + final ListView list = find.byType(ListView).evaluate().first.widget as ListView; + expect(list.semanticChildCount, 2); + + // Initially the first option should be highlighted + checkOptionHighlight('chameleon', highlightColor); + checkOptionHighlight('elephant', null); + + // Move the selection down + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + // Highlight should be moved to the second item + checkOptionHighlight('chameleon', null); + checkOptionHighlight('elephant', highlightColor); + }); } diff --git a/packages/flutter/test/widgets/autocomplete_test.dart b/packages/flutter/test/widgets/autocomplete_test.dart index e1339db4c5..9ab8bd7d06 100644 --- a/packages/flutter/test/widgets/autocomplete_test.dart +++ b/packages/flutter/test/widgets/autocomplete_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; class User { @@ -645,4 +646,172 @@ void main() { throwsAssertionError, ); }); + + testWidgets('can navigate 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'); + + // Move the highlighted option to the second item 'elephant' and select it + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + // Can't use the key event for enter to submit to the text field using + // the test framework, so this appears to be the equivalent. + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(find.byKey(fieldKey), findsOneWidget); + expect(find.byKey(optionsKey), findsNothing); + expect(textEditingController.text, 'elephant'); + + // Modify the field text. The options appear again and are filtered. + focusNode.requestFocus(); + textEditingController.clear(); + await tester.enterText(find.byKey(fieldKey), 'e'); + await tester.pump(); + expect(find.byKey(fieldKey), findsOneWidget); + expect(find.byKey(optionsKey), findsOneWidget); + expect(lastOptions.length, 6); + expect(lastOptions.elementAt(0), 'chameleon'); + expect(lastOptions.elementAt(1), 'elephant'); + expect(lastOptions.elementAt(2), 'goose'); + expect(lastOptions.elementAt(3), 'lemur'); + expect(lastOptions.elementAt(4), 'mouse'); + expect(lastOptions.elementAt(5), 'northern white rhinoceros'); + + // The selection should wrap at the top and bottom. Move up to 'mouse' + // and then back down to 'goose' and select it. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(find.byKey(fieldKey), findsOneWidget); + expect(find.byKey(optionsKey), findsNothing); + expect(textEditingController.text, 'goose'); + }); + + testWidgets('optionsViewBuilders can use AutocompleteHighlightedOption to highlight selected option', (WidgetTester tester) async { + final GlobalKey fieldKey = GlobalKey(); + final GlobalKey optionsKey = GlobalKey(); + late Iterable lastOptions; + late int lastHighlighted; + 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; + lastHighlighted = AutocompleteHighlightedOption.of(context); + return Container(key: optionsKey); + }, + ), + ), + ), + ); + + // Enter text. The options are filtered by the text. + focusNode.requestFocus(); + await tester.enterText(find.byKey(fieldKey), 'e'); + await tester.pump(); + expect(find.byKey(fieldKey), findsOneWidget); + expect(find.byKey(optionsKey), findsOneWidget); + expect(lastOptions.length, 6); + expect(lastOptions.elementAt(0), 'chameleon'); + expect(lastOptions.elementAt(1), 'elephant'); + expect(lastOptions.elementAt(2), 'goose'); + expect(lastOptions.elementAt(3), 'lemur'); + expect(lastOptions.elementAt(4), 'mouse'); + expect(lastOptions.elementAt(5), 'northern white rhinoceros'); + + // Move the highlighted option down and check the highlighted index + expect(lastHighlighted, 0); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(lastHighlighted, 1); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(lastHighlighted, 2); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(lastHighlighted, 3); + + // And move it back up + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(lastHighlighted, 2); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(lastHighlighted, 1); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(lastHighlighted, 0); + + // Going back up should wrap around + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(lastHighlighted, 5); + }); + }