Support for keyboard navigation of Autocomplete options. (#83696)
Support for keyboard navigation of Autocomplete options.
This commit is contained in:
parent
09e5c4050e
commit
3bb4a34a0b
@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart';
|
|||||||
import 'ink_well.dart';
|
import 'ink_well.dart';
|
||||||
import 'material.dart';
|
import 'material.dart';
|
||||||
import 'text_form_field.dart';
|
import 'text_form_field.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
/// {@macro flutter.widgets.RawAutocomplete.RawAutocomplete}
|
/// {@macro flutter.widgets.RawAutocomplete.RawAutocomplete}
|
||||||
///
|
///
|
||||||
@ -291,11 +292,13 @@ class _AutocompleteOptions<T extends Object> extends StatelessWidget {
|
|||||||
itemCount: options.length,
|
itemCount: options.length,
|
||||||
itemBuilder: (BuildContext context, int index) {
|
itemBuilder: (BuildContext context, int index) {
|
||||||
final T option = options.elementAt(index);
|
final T option = options.elementAt(index);
|
||||||
|
final bool highlight = AutocompleteHighlightedOption.of(context) == index;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
onSelected(option);
|
onSelected(option);
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Container(
|
||||||
|
color: highlight ? Theme.of(context).focusColor : null,
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(displayStringForOption(option)),
|
child: Text(displayStringForOption(option)),
|
||||||
),
|
),
|
||||||
|
@ -3,16 +3,20 @@
|
|||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'actions.dart';
|
||||||
import 'basic.dart';
|
import 'basic.dart';
|
||||||
import 'container.dart';
|
import 'container.dart';
|
||||||
import 'editable_text.dart';
|
import 'editable_text.dart';
|
||||||
import 'focus_manager.dart';
|
import 'focus_manager.dart';
|
||||||
import 'framework.dart';
|
import 'framework.dart';
|
||||||
|
import 'inherited_notifier.dart';
|
||||||
import 'overlay.dart';
|
import 'overlay.dart';
|
||||||
|
import 'shortcuts.dart';
|
||||||
|
|
||||||
/// The type of the [RawAutocomplete] callback which computes the list of
|
/// 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.
|
/// entered so far.
|
||||||
///
|
///
|
||||||
/// See also:
|
/// 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
|
/// displays the specified [options] and calls [onSelected] if the user
|
||||||
/// selects an option.
|
/// 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:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * [RawAutocomplete.optionsViewBuilder], which is of this type.
|
/// * [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
|
/// The options are displayed floating below the field using a
|
||||||
/// [CompositedTransformFollower] inside of an [Overlay], not at the same
|
/// [CompositedTransformFollower] inside of an [Overlay], not at the same
|
||||||
/// place in the widget tree as [RawAutocomplete].
|
/// 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}
|
/// {@endtemplate}
|
||||||
final AutocompleteOptionsViewBuilder<T> optionsViewBuilder;
|
final AutocompleteOptionsViewBuilder<T> optionsViewBuilder;
|
||||||
|
|
||||||
@ -711,8 +729,17 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
|
|||||||
final LayerLink _optionsLayerLink = LayerLink();
|
final LayerLink _optionsLayerLink = LayerLink();
|
||||||
late TextEditingController _textEditingController;
|
late TextEditingController _textEditingController;
|
||||||
late FocusNode _focusNode;
|
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();
|
Iterable<T> _options = Iterable<T>.empty();
|
||||||
T? _selection;
|
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.
|
// The OverlayEntry containing the options.
|
||||||
OverlayEntry? _floatingOptions;
|
OverlayEntry? _floatingOptions;
|
||||||
@ -728,6 +755,7 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
|
|||||||
_textEditingController.value,
|
_textEditingController.value,
|
||||||
);
|
);
|
||||||
_options = options;
|
_options = options;
|
||||||
|
_updateHighlight(_highlightedOptionIndex.value);
|
||||||
if (_selection != null
|
if (_selection != null
|
||||||
&& _textEditingController.text != widget.displayStringForOption(_selection!)) {
|
&& _textEditingController.text != widget.displayStringForOption(_selection!)) {
|
||||||
_selection = null;
|
_selection = null;
|
||||||
@ -745,7 +773,7 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
|
|||||||
if (_options.isEmpty) {
|
if (_options.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_select(_options.first);
|
_select(_options.elementAt(_highlightedOptionIndex.value));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the given option and update the widget.
|
// 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!);
|
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.
|
// Hide or show the options overlay, if needed.
|
||||||
void _updateOverlay() {
|
void _updateOverlay() {
|
||||||
|
_setActionsEnabled(_shouldShowOptions);
|
||||||
if (_shouldShowOptions) {
|
if (_shouldShowOptions) {
|
||||||
_floatingOptions?.remove();
|
_floatingOptions?.remove();
|
||||||
_floatingOptions = OverlayEntry(
|
_floatingOptions = OverlayEntry(
|
||||||
@ -772,7 +822,14 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
|
|||||||
link: _optionsLayerLink,
|
link: _optionsLayerLink,
|
||||||
showWhenUnlinked: false,
|
showWhenUnlinked: false,
|
||||||
targetAnchor: Alignment.bottomLeft,
|
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);
|
_textEditingController.addListener(_onChangedField);
|
||||||
_focusNode = widget.focusNode ?? FocusNode();
|
_focusNode = widget.focusNode ?? FocusNode();
|
||||||
_focusNode.addListener(_onChangedFocus);
|
_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 _) {
|
SchedulerBinding.instance!.addPostFrameCallback((Duration _) {
|
||||||
_updateOverlay();
|
_updateOverlay();
|
||||||
});
|
});
|
||||||
@ -867,17 +930,93 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
key: _fieldKey,
|
key: _fieldKey,
|
||||||
child: CompositedTransformTarget(
|
child: Shortcuts(
|
||||||
link: _optionsLayerLink,
|
shortcuts: _shortcuts,
|
||||||
child: widget.fieldViewBuilder == null
|
child: Actions(
|
||||||
? const SizedBox.shrink()
|
actions: _actionMap,
|
||||||
: widget.fieldViewBuilder!(
|
child: CompositedTransformTarget(
|
||||||
context,
|
link: _optionsLayerLink,
|
||||||
_textEditingController,
|
child: widget.fieldViewBuilder == null
|
||||||
_focusNode,
|
? const SizedBox.shrink()
|
||||||
_onFieldSubmitted,
|
: 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.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import '../rendering/mock_canvas.dart';
|
||||||
|
|
||||||
class User {
|
class User {
|
||||||
const User({
|
const User({
|
||||||
required this.email,
|
required this.email,
|
||||||
@ -336,18 +339,18 @@ void main() {
|
|||||||
final Finder listFinder = find.byType(ListView);
|
final Finder listFinder = find.byType(ListView);
|
||||||
expect(listFinder, findsNothing);
|
expect(listFinder, findsNothing);
|
||||||
|
|
||||||
/// entering `a` returns 9 items(height > `maxOptionsHeight`) from the kOptions
|
// Entering `a` returns 9 items(height > `maxOptionsHeight`) from the kOptions
|
||||||
/// so height gets restricted to `maxOptionsHeight =250`
|
// so height gets restricted to `maxOptionsHeight =250`.
|
||||||
final double nineItemsHeight = await _getDefaultOptionsHeight(tester, 'a');
|
final double nineItemsHeight = await _getDefaultOptionsHeight(tester, 'a');
|
||||||
expect(nineItemsHeight, equals(maxOptionsHeight));
|
expect(nineItemsHeight, equals(maxOptionsHeight));
|
||||||
|
|
||||||
/// returns 2 Items (height < `maxOptionsHeight`)
|
// Returns 2 Items (height < `maxOptionsHeight`)
|
||||||
/// so options height shrinks to 2 Items combined height
|
// so options height shrinks to 2 Items combined height.
|
||||||
final double twoItemsHeight = await _getDefaultOptionsHeight(tester, 'el');
|
final double twoItemsHeight = await _getDefaultOptionsHeight(tester, 'el');
|
||||||
expect(twoItemsHeight, lessThan(maxOptionsHeight));
|
expect(twoItemsHeight, lessThan(maxOptionsHeight));
|
||||||
|
|
||||||
/// returns 1 item (height < `maxOptionsHeight`) from `kOptions`
|
// Returns 1 item (height < `maxOptionsHeight`) from `kOptions`
|
||||||
/// so options height shrinks to 1 items height
|
// so options height shrinks to 1 items height.
|
||||||
final double oneItemsHeight = await _getDefaultOptionsHeight(tester, 'elep');
|
final double oneItemsHeight = await _getDefaultOptionsHeight(tester, 'elep');
|
||||||
expect(oneItemsHeight, lessThan(twoItemsHeight));
|
expect(oneItemsHeight, lessThan(twoItemsHeight));
|
||||||
});
|
});
|
||||||
@ -398,4 +401,56 @@ void main() {
|
|||||||
expect(field.controller!.text, 'lemur');
|
expect(field.controller!.text, 'lemur');
|
||||||
expect(lastSelection, '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.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
class User {
|
class User {
|
||||||
@ -645,4 +646,172 @@ void main() {
|
|||||||
throwsAssertionError,
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user