Unverified Commit 3bb4a34a authored by Darren Austin's avatar Darren Austin Committed by GitHub

Support for keyboard navigation of Autocomplete options. (#83696)

Support for keyboard navigation of Autocomplete options.
parent 09e5c405
......@@ -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<T extends Object> 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)),
),
......
......@@ -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<T extends Object> = 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<T extends Object> 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<T> optionsViewBuilder;
......@@ -711,8 +729,17 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
final LayerLink _optionsLayerLink = LayerLink();
late TextEditingController _textEditingController;
late FocusNode _focusNode;
late final Map<Type, Action<Intent>> _actionMap;
late final _AutocompleteCallbackAction<AutocompletePreviousOptionIntent> _previousOptionAction;
late final _AutocompleteCallbackAction<AutocompleteNextOptionIntent> _nextOptionAction;
Iterable<T> _options = Iterable<T>.empty();
T? _selection;
final ValueNotifier<int> _highlightedOptionIndex = ValueNotifier<int>(0);
static const Map<ShortcutActivator, Intent> _shortcuts = <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowUp): AutocompletePreviousOptionIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): AutocompleteNextOptionIntent(),
};
// The OverlayEntry containing the options.
OverlayEntry? _floatingOptions;
......@@ -728,6 +755,7 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
_textEditingController.value,
);
_options = options;
_updateHighlight(_highlightedOptionIndex.value);
if (_selection != null
&& _textEditingController.text != widget.displayStringForOption(_selection!)) {
_selection = null;
......@@ -745,7 +773,7 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
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<T extends Object> extends State<RawAutocomplete<T>>
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<T extends Object> extends State<RawAutocomplete<T>>
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<T extends Object> extends State<RawAutocomplete<T>>
_textEditingController.addListener(_onChangedField);
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onChangedFocus);
_previousOptionAction = _AutocompleteCallbackAction<AutocompletePreviousOptionIntent>(onInvoke: _highlightPreviousOption);
_nextOptionAction = _AutocompleteCallbackAction<AutocompleteNextOptionIntent>(onInvoke: _highlightNextOption);
_actionMap = <Type, Action<Intent>> {
AutocompletePreviousOptionIntent: _previousOptionAction,
AutocompleteNextOptionIntent: _nextOptionAction,
};
SchedulerBinding.instance!.addPostFrameCallback((Duration _) {
_updateOverlay();
});
......@@ -867,17 +930,93 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
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<T extends Intent> extends CallbackAction<T> {
_AutocompleteCallbackAction({
required OnInvokeCallback<T> 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<ValueNotifier<int>> {
/// Create an instance of AutocompleteHighlightedOption inherited widget.
const AutocompleteHighlightedOption({
Key? key,
required ValueNotifier<int> 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<AutocompleteHighlightedOption>()?.notifier?.value ?? 0;
}
}
......@@ -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<RenderBox>(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<String>(
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);
});
}
......@@ -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<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');
// 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<String> lastOptions;
late int lastHighlighted;
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;
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);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment