Autocomplete Split UI (#72553)
Allows passing in a TextEditingController and FocusNode to RawAutocomplete, which enables split UIs where the TextField is in another part of the tree from the results.
diff --git a/packages/flutter/lib/src/widgets/autocomplete.dart b/packages/flutter/lib/src/widgets/autocomplete.dart
index 739cb7f..8e91215 100644
--- a/packages/flutter/lib/src/widgets/autocomplete.dart
+++ b/packages/flutter/lib/src/widgets/autocomplete.dart
@@ -422,25 +422,135 @@
class RawAutocomplete<T extends Object> extends StatefulWidget {
/// Create an instance of RawAutocomplete.
///
- /// [fieldViewBuilder] and [optionsViewBuilder] must not be null.
+ /// [displayStringForOption], [optionsBuilder] and [optionsViewBuilder] must
+ /// not be null.
const RawAutocomplete({
Key? key,
- required this.fieldViewBuilder,
required this.optionsViewBuilder,
required this.optionsBuilder,
this.displayStringForOption = _defaultStringForOption,
+ this.fieldViewBuilder,
+ this.focusNode,
this.onSelected,
+ this.textEditingController,
}) : assert(displayStringForOption != null),
- assert(fieldViewBuilder != null),
+ assert(
+ fieldViewBuilder != null
+ || (key != null && focusNode != null && textEditingController != null),
+ 'Pass in a fieldViewBuilder, or otherwise create a separate field and pass in the FocusNode, TextEditingController, and a key. Use the key with RawAutocomplete.onFieldSubmitted.',
+ ),
assert(optionsBuilder != null),
assert(optionsViewBuilder != null),
+ assert((focusNode == null) == (textEditingController == null)),
super(key: key);
/// Builds the field whose input is used to get the options.
///
/// Pass the provided [TextEditingController] to the field built here so that
/// RawAutocomplete can listen for changes.
- final AutocompleteFieldViewBuilder fieldViewBuilder;
+ final AutocompleteFieldViewBuilder? fieldViewBuilder;
+
+ /// The [FocusNode] that is used for the text field.
+ ///
+ /// {@template flutter.widgets.RawAutocomplete.split}
+ /// The main purpose of this parameter is to allow the use of a separate text
+ /// field located in another part of the widget tree instead of the text
+ /// field built by [fieldViewBuilder]. For example, it may be desirable to
+ /// place the text field in the AppBar and the options below in the main body.
+ ///
+ /// When following this pattern, [fieldViewBuilder] can return
+ /// `SizedBox.shrink()` so that nothing is drawn where the text field would
+ /// normally be. A separate text field can be created elsewhere, and a
+ /// FocusNode and TextEditingController can be passed both to that text field
+ /// and to RawAutocomplete.
+ ///
+ /// {@tool dartpad --template=freeform}
+ /// This examples shows how to create an autocomplete widget with the text
+ /// field in the AppBar and the results in the main body of the app.
+ ///
+ /// ```dart imports
+ /// import 'package:flutter/widgets.dart';
+ /// import 'package:flutter/material.dart';
+ /// ```
+ ///
+ /// ```dart
+ /// final List<String> _options = <String>[
+ /// 'aardvark',
+ /// 'bobcat',
+ /// 'chameleon',
+ /// ];
+ ///
+ /// class RawAutocompleteSplitPage extends StatefulWidget {
+ /// RawAutocompleteSplitPage({Key? key}) : super(key: key);
+ ///
+ /// RawAutocompleteSplitPageState createState() => RawAutocompleteSplitPageState();
+ /// }
+ ///
+ /// class RawAutocompleteSplitPageState extends State<RawAutocompleteSplitPage> {
+ /// final TextEditingController _textEditingController = TextEditingController();
+ /// final FocusNode _focusNode = FocusNode();
+ /// final GlobalKey _autocompleteKey = GlobalKey();
+ ///
+ /// @override
+ /// Widget build(BuildContext context) {
+ /// return MaterialApp(
+ /// theme: ThemeData(
+ /// primarySwatch: Colors.blue,
+ /// ),
+ /// title: 'Split RawAutocomplete App',
+ /// home: Scaffold(
+ /// appBar: AppBar(
+ /// // This is where the real field is being built.
+ /// title: TextFormField(
+ /// controller: _textEditingController,
+ /// focusNode: _focusNode,
+ /// decoration: InputDecoration(
+ /// hintText: 'Split RawAutocomplete App',
+ /// ),
+ /// onFieldSubmitted: (String value) {
+ /// RawAutocomplete.onFieldSubmitted<String>(_autocompleteKey);
+ /// },
+ /// ),
+ /// ),
+ /// body: Align(
+ /// alignment: Alignment.topLeft,
+ /// child: RawAutocomplete<String>(
+ /// key: _autocompleteKey,
+ /// focusNode: _focusNode,
+ /// textEditingController: _textEditingController,
+ /// optionsBuilder: (TextEditingValue textEditingValue) {
+ /// return _options.where((String option) {
+ /// return option.contains(textEditingValue.text.toLowerCase());
+ /// }).toList();
+ /// },
+ /// optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
+ /// return Material(
+ /// elevation: 4.0,
+ /// child: ListView(
+ /// children: options.map((String option) => GestureDetector(
+ /// onTap: () {
+ /// onSelected(option);
+ /// },
+ /// child: ListTile(
+ /// title: Text(option),
+ /// ),
+ /// )).toList(),
+ /// ),
+ /// );
+ /// },
+ /// ),
+ /// ),
+ /// ),
+ /// );
+ /// }
+ /// }
+ /// ```
+ /// {@end-tool}
+ /// {@endtemplate}
+ ///
+ /// If this parameter is not null, then [textEditingController] must also be
+ /// not null.
+ final FocusNode? focusNode;
/// Builds the selectable options widgets from a list of options objects.
///
@@ -468,6 +578,31 @@
/// current TextEditingValue.
final AutocompleteOptionsBuilder<T> optionsBuilder;
+ /// The [TextEditingController] that is used for the text field.
+ ///
+ /// {@macro flutter.widgets.RawAutocomplete.split}
+ ///
+ /// If this parameter is not null, then [focusNode] must also be not null.
+ final TextEditingController? textEditingController;
+
+ /// Calls [AutocompleteFieldViewBuilder]'s onFieldSubmitted callback for the
+ /// RawAutocomplete widget indicated by the given [GlobalKey].
+ ///
+ /// This is not typically used unless a custom field is implemented instead of
+ /// using [fieldViewBuilder]. In the typical case, the onFieldSubmitted
+ /// callback is passed via the [AutocompleteFieldViewBuilder] signature. When
+ /// not using fieldViewBuilder, the same callback can be called by using this
+ /// static method.
+ ///
+ /// See also:
+ ///
+ /// * [focusNode] and [textEditingController], which contain a code example
+ /// showing how to create a separate field outside of fieldViewBuilder.
+ static void onFieldSubmitted<T extends Object>(GlobalKey key) {
+ final _RawAutocompleteState<T> rawAutocomplete = key.currentState! as _RawAutocompleteState<T>;
+ rawAutocomplete._onFieldSubmitted();
+ }
+
// The default way to convert an option to a string.
static String _defaultStringForOption(dynamic option) {
return option.toString();
@@ -480,8 +615,8 @@
class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> {
final GlobalKey _fieldKey = GlobalKey();
final LayerLink _optionsLayerLink = LayerLink();
- final TextEditingController _textEditingController = TextEditingController();
- final FocusNode _focusNode = FocusNode();
+ late TextEditingController _textEditingController;
+ late FocusNode _focusNode;
Iterable<T> _options = Iterable<T>.empty();
T? _selection;
@@ -554,10 +689,52 @@
}
}
+ // Handle a potential change in textEditingController by properly disposing of
+ // the old one and setting up the new one, if needed.
+ void _updateTextEditingController(TextEditingController? old, TextEditingController? current) {
+ if ((old == null && current == null) || old == current) {
+ return;
+ }
+ if (old == null) {
+ _textEditingController.removeListener(_onChangedField);
+ _textEditingController.dispose();
+ _textEditingController = current!;
+ } else if (current == null) {
+ _textEditingController.removeListener(_onChangedField);
+ _textEditingController = TextEditingController();
+ } else {
+ _textEditingController.removeListener(_onChangedField);
+ _textEditingController = current;
+ }
+ _textEditingController.addListener(_onChangedField);
+ }
+
+ // Handle a potential change in focusNode by properly disposing of the old one
+ // and setting up the new one, if needed.
+ void _updateFocusNode(FocusNode? old, FocusNode? current) {
+ if ((old == null && current == null) || old == current) {
+ return;
+ }
+ if (old == null) {
+ _focusNode.removeListener(_onChangedFocus);
+ _focusNode.dispose();
+ _focusNode = current!;
+ } else if (current == null) {
+ _focusNode.removeListener(_onChangedFocus);
+ _focusNode = FocusNode();
+ } else {
+ _focusNode.removeListener(_onChangedFocus);
+ _focusNode = current;
+ }
+ _focusNode.addListener(_onChangedFocus);
+ }
+
@override
void initState() {
super.initState();
+ _textEditingController = widget.textEditingController ?? TextEditingController();
_textEditingController.addListener(_onChangedField);
+ _focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onChangedFocus);
SchedulerBinding.instance!.addPostFrameCallback((Duration _) {
_updateOverlay();
@@ -567,6 +744,11 @@
@override
void didUpdateWidget(RawAutocomplete<T> oldWidget) {
super.didUpdateWidget(oldWidget);
+ _updateTextEditingController(
+ oldWidget.textEditingController,
+ widget.textEditingController,
+ );
+ _updateFocusNode(oldWidget.focusNode, widget.focusNode);
SchedulerBinding.instance!.addPostFrameCallback((Duration _) {
_updateOverlay();
});
@@ -575,7 +757,13 @@
@override
void dispose() {
_textEditingController.removeListener(_onChangedField);
+ if (widget.textEditingController == null) {
+ _textEditingController.dispose();
+ }
_focusNode.removeListener(_onChangedFocus);
+ if (widget.focusNode == null) {
+ _focusNode.dispose();
+ }
_floatingOptions?.remove();
_floatingOptions = null;
super.dispose();
@@ -587,12 +775,14 @@
key: _fieldKey,
child: CompositedTransformTarget(
link: _optionsLayerLink,
- child: widget.fieldViewBuilder(
- context,
- _textEditingController,
- _focusNode,
- _onFieldSubmitted,
- ),
+ child: widget.fieldViewBuilder == null
+ ? const SizedBox.shrink()
+ : widget.fieldViewBuilder!(
+ context,
+ _textEditingController,
+ _focusNode,
+ _onFieldSubmitted,
+ ),
),
);
}
diff --git a/packages/flutter/test/widgets/autocomplete_test.dart b/packages/flutter/test/widgets/autocomplete_test.dart
index 8d7d44b..472db19 100644
--- a/packages/flutter/test/widgets/autocomplete_test.dart
+++ b/packages/flutter/test/widgets/autocomplete_test.dart
@@ -45,446 +45,513 @@
User(name: 'Charlie', email: 'charlie123@gmail.com'),
];
- group('RawAutocomplete', () {
- testWidgets('can filter and select a list of string options', (WidgetTester tester) async {
- final GlobalKey fieldKey = GlobalKey();
- final GlobalKey optionsKey = GlobalKey();
- late Iterable<String> lastOptions;
- late AutocompleteOnSelected<String> lastOnSelected;
- late FocusNode focusNode;
- late TextEditingController textEditingController;
+ testWidgets('can filter and select a list of string options', (WidgetTester tester) async {
+ final GlobalKey fieldKey = GlobalKey();
+ final GlobalKey optionsKey = GlobalKey();
+ late Iterable<String> lastOptions;
+ late AutocompleteOnSelected<String> lastOnSelected;
+ 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 TextField(
- key: fieldKey,
- focusNode: focusNode,
- controller: textEditingController,
- );
- },
- optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
- lastOptions = options;
- lastOnSelected = onSelected;
- return Container(key: optionsKey);
+ 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 TextField(
+ key: fieldKey,
+ focusNode: focusNode,
+ controller: textEditingController,
+ );
+ },
+ optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
+ lastOptions = options;
+ lastOnSelected = onSelected;
+ return Container(key: optionsKey);
+ },
+ ),
+ ),
+ ),
+ );
+
+ // The field is always rendered, but the options are not unless needed.
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsNothing);
+
+ // Focus the empty field. All the options are displayed.
+ focusNode.requestFocus();
+ await tester.pump();
+ expect(find.byKey(optionsKey), findsOneWidget);
+ expect(lastOptions.length, kOptions.length);
+
+ // Enter text. The options are filtered by the text.
+ textEditingController.value = const TextEditingValue(
+ text: 'ele',
+ selection: TextSelection(baseOffset: 3, extentOffset: 3),
+ );
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsOneWidget);
+ expect(lastOptions.length, 2);
+ expect(lastOptions.elementAt(0), 'chameleon');
+ expect(lastOptions.elementAt(1), 'elephant');
+
+ // Select a option. The options hide and the field updates to show the
+ // selection.
+ final String selection = lastOptions.elementAt(1);
+ lastOnSelected(selection);
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsNothing);
+ expect(textEditingController.text, selection);
+
+ // Modify the field text. The options appear again and are filtered.
+ textEditingController.value = const TextEditingValue(
+ text: 'e',
+ selection: TextSelection(baseOffset: 1, extentOffset: 1),
+ );
+ 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 rhinocerous');
+ });
+
+ testWidgets('can filter and select a list of custom User options', (WidgetTester tester) async {
+ final GlobalKey fieldKey = GlobalKey();
+ final GlobalKey optionsKey = GlobalKey();
+ late Iterable<User> lastOptions;
+ late AutocompleteOnSelected<User> lastOnSelected;
+ late User lastUserSelected;
+ late FocusNode focusNode;
+ late TextEditingController textEditingController;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: RawAutocomplete<User>(
+ optionsBuilder: (TextEditingValue textEditingValue) {
+ return kOptionsUsers.where((User option) {
+ return option.toString().contains(textEditingValue.text.toLowerCase());
+ });
+ },
+ onSelected: (User selected) {
+ lastUserSelected = selected;
+ },
+ fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
+ focusNode = fieldFocusNode;
+ textEditingController = fieldTextEditingController;
+ return TextField(
+ key: fieldKey,
+ focusNode: focusNode,
+ controller: fieldTextEditingController,
+ );
+ },
+ optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) {
+ lastOptions = options;
+ lastOnSelected = onSelected;
+ return Container(key: optionsKey);
+ },
+ ),
+ ),
+ ),
+ );
+
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsNothing);
+
+ // Enter text. The options are filtered by the text.
+ focusNode.requestFocus();
+ textEditingController.value = const TextEditingValue(
+ text: 'example',
+ selection: TextSelection(baseOffset: 7, extentOffset: 7),
+ );
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsOneWidget);
+ expect(lastOptions.length, 2);
+ expect(lastOptions.elementAt(0), kOptionsUsers[0]);
+ expect(lastOptions.elementAt(1), kOptionsUsers[1]);
+
+ // Select a option. The options hide and onSelected is called.
+ final User selection = lastOptions.elementAt(1);
+ lastOnSelected(selection);
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsNothing);
+ expect(lastUserSelected, selection);
+ expect(textEditingController.text, selection.toString());
+
+ // Modify the field text. The options appear again and are filtered, this
+ // time by name instead of email.
+ textEditingController.value = const TextEditingValue(
+ text: 'B',
+ selection: TextSelection(baseOffset: 1, extentOffset: 1),
+ );
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsOneWidget);
+ expect(lastOptions.length, 1);
+ expect(lastOptions.elementAt(0), kOptionsUsers[1]);
+ });
+
+ testWidgets('can specify a custom display string for a list of custom User options', (WidgetTester tester) async {
+ final GlobalKey fieldKey = GlobalKey();
+ final GlobalKey optionsKey = GlobalKey();
+ late Iterable<User> lastOptions;
+ late AutocompleteOnSelected<User> lastOnSelected;
+ late User lastUserSelected;
+ late final AutocompleteOptionToString<User> displayStringForOption = (User option) => option.name;
+ late FocusNode focusNode;
+ late TextEditingController textEditingController;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: RawAutocomplete<User>(
+ optionsBuilder: (TextEditingValue textEditingValue) {
+ return kOptionsUsers.where((User option) {
+ return option
+ .toString()
+ .contains(textEditingValue.text.toLowerCase());
+ });
+ },
+ displayStringForOption: displayStringForOption,
+ onSelected: (User selected) {
+ lastUserSelected = selected;
+ },
+ fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
+ textEditingController = fieldTextEditingController;
+ focusNode = fieldFocusNode;
+ return TextField(
+ key: fieldKey,
+ focusNode: focusNode,
+ controller: fieldTextEditingController,
+ );
+ },
+ optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) {
+ lastOptions = options;
+ lastOnSelected = onSelected;
+ return Container(key: optionsKey);
+ },
+ ),
+ ),
+ ),
+ );
+
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsNothing);
+
+ // Enter text. The options are filtered by the text.
+ focusNode.requestFocus();
+ textEditingController.value = const TextEditingValue(
+ text: 'example',
+ selection: TextSelection(baseOffset: 7, extentOffset: 7),
+ );
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsOneWidget);
+ expect(lastOptions.length, 2);
+ expect(lastOptions.elementAt(0), kOptionsUsers[0]);
+ expect(lastOptions.elementAt(1), kOptionsUsers[1]);
+
+ // Select a option. The options hide and onSelected is called. The field
+ // has its text set to the selection's display string.
+ final User selection = lastOptions.elementAt(1);
+ lastOnSelected(selection);
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsNothing);
+ expect(lastUserSelected, selection);
+ expect(textEditingController.text, selection.name);
+
+ // Modify the field text. The options appear again and are filtered, this
+ // time by name instead of email.
+ textEditingController.value = const TextEditingValue(
+ text: 'B',
+ selection: TextSelection(baseOffset: 1, extentOffset: 1),
+ );
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsOneWidget);
+ expect(lastOptions.length, 1);
+ expect(lastOptions.elementAt(0), kOptionsUsers[1]);
+ });
+
+ testWidgets('onFieldSubmitted selects the first option', (WidgetTester tester) async {
+ final GlobalKey fieldKey = GlobalKey();
+ final GlobalKey optionsKey = GlobalKey();
+ late Iterable<String> lastOptions;
+ late VoidCallback lastOnFieldSubmitted;
+ 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) {
+ textEditingController = fieldTextEditingController;
+ focusNode = fieldFocusNode;
+ lastOnFieldSubmitted = onFieldSubmitted;
+ return TextField(
+ key: fieldKey,
+ focusNode: focusNode,
+ controller: fieldTextEditingController,
+ );
+ },
+ optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
+ lastOptions = options;
+ return Container(key: optionsKey);
+ },
+ ),
+ ),
+ ),
+ );
+
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsNothing);
+
+ // Enter text. The options are filtered by the text.
+ focusNode.requestFocus();
+ textEditingController.value = const TextEditingValue(
+ text: 'ele',
+ selection: TextSelection(baseOffset: 3, extentOffset: 3),
+ );
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsOneWidget);
+ expect(lastOptions.length, 2);
+ expect(lastOptions.elementAt(0), 'chameleon');
+ expect(lastOptions.elementAt(1), 'elephant');
+
+ // Select the current string, as if the field was submitted. The options
+ // hide and the field updates to show the selection.
+ lastOnFieldSubmitted();
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsNothing);
+ expect(textEditingController.text, lastOptions.elementAt(0));
+ });
+
+ testWidgets('options follow field when it moves', (WidgetTester tester) async {
+ final GlobalKey fieldKey = GlobalKey();
+ final GlobalKey optionsKey = GlobalKey();
+ late StateSetter setState;
+ Alignment alignment = Alignment.center;
+ late FocusNode focusNode;
+ late TextEditingController textEditingController;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setter) {
+ setState = setter;
+ return Align(
+ alignment: alignment,
+ child: 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(
+ controller: fieldTextEditingController,
+ focusNode: focusNode,
+ key: fieldKey,
+ );
+ },
+ optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
+ return Container(key: optionsKey);
+ },
+ ),
+ );
+ },
+ ),
+ ),
+ ),
+ );
+
+ // Field is shown but not options.
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsNothing);
+
+ // Enter text to show the options.
+ focusNode.requestFocus();
+ textEditingController.value = const TextEditingValue(
+ text: 'ele',
+ selection: TextSelection(baseOffset: 3, extentOffset: 3),
+ );
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsOneWidget);
+
+ // Options are just below the field.
+ final Offset optionsOffset = tester.getTopLeft(find.byKey(optionsKey));
+ Offset fieldOffset = tester.getTopLeft(find.byKey(fieldKey));
+ final Size fieldSize = tester.getSize(find.byKey(fieldKey));
+ expect(optionsOffset.dy, fieldOffset.dy + fieldSize.height);
+
+ // Move the field (similar to as if the keyboard opened). The options move
+ // to follow the field.
+ setState(() {
+ alignment = Alignment.topCenter;
+ });
+ await tester.pump();
+ fieldOffset = tester.getTopLeft(find.byKey(fieldKey));
+ final Offset optionsOffsetOpen = tester.getTopLeft(find.byKey(optionsKey));
+ expect(optionsOffsetOpen.dy, isNot(equals(optionsOffset.dy)));
+ expect(optionsOffsetOpen.dy, fieldOffset.dy + fieldSize.height);
+ });
+
+ testWidgets('can prevent options from showing by returning an empty iterable', (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) {
+ if (textEditingValue.text == null || textEditingValue.text == '') {
+ return const Iterable<String>.empty();
+ }
+ 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 TextField(
+ key: fieldKey,
+ focusNode: focusNode,
+ controller: fieldTextEditingController,
+ );
+ },
+ optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
+ lastOptions = options;
+ return Container(key: optionsKey);
+ },
+ ),
+ ),
+ ),
+ );
+
+ // The field is always rendered, but the options are not unless needed.
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsNothing);
+
+ // Focus the empty field. The options are not displayed because
+ // optionsBuilder returns nothing for an empty field query.
+ focusNode.requestFocus();
+ textEditingController.value = const TextEditingValue(
+ text: '',
+ selection: TextSelection(baseOffset: 0, extentOffset: 0),
+ );
+ await tester.pump();
+ expect(find.byKey(optionsKey), findsNothing);
+
+ // Enter text. Now the options appear, filtered by the text.
+ textEditingController.value = const TextEditingValue(
+ text: 'ele',
+ selection: TextSelection(baseOffset: 3, extentOffset: 3),
+ );
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsOneWidget);
+ expect(lastOptions.length, 2);
+ expect(lastOptions.elementAt(0), 'chameleon');
+ expect(lastOptions.elementAt(1), 'elephant');
+ });
+
+ testWidgets('can create a field outside of fieldViewBuilder', (WidgetTester tester) async {
+ final GlobalKey fieldKey = GlobalKey();
+ final GlobalKey optionsKey = GlobalKey();
+ final GlobalKey autocompleteKey = GlobalKey();
+ late Iterable<String> lastOptions;
+ final FocusNode focusNode = FocusNode();
+ final TextEditingController textEditingController = TextEditingController();
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ appBar: AppBar(
+ // This is where the real field is being built.
+ title: TextFormField(
+ key: fieldKey,
+ controller: textEditingController,
+ focusNode: focusNode,
+ onFieldSubmitted: (String value) {
+ RawAutocomplete.onFieldSubmitted(autocompleteKey);
},
),
),
- ),
- );
-
- // The field is always rendered, but the options are not unless needed.
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsNothing);
-
- // Focus the empty field. All the options are displayed.
- focusNode.requestFocus();
- await tester.pump();
- expect(find.byKey(optionsKey), findsOneWidget);
- expect(lastOptions.length, kOptions.length);
-
- // Enter text. The options are filtered by the text.
- textEditingController.value = const TextEditingValue(
- text: 'ele',
- selection: TextSelection(baseOffset: 3, extentOffset: 3),
- );
- await tester.pump();
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsOneWidget);
- expect(lastOptions.length, 2);
- expect(lastOptions.elementAt(0), 'chameleon');
- expect(lastOptions.elementAt(1), 'elephant');
-
- // Select a option. The options hide and the field updates to show the
- // selection.
- final String selection = lastOptions.elementAt(1);
- lastOnSelected(selection);
- await tester.pump();
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsNothing);
- expect(textEditingController.text, selection);
-
- // Modify the field text. The options appear again and are filtered.
- textEditingController.value = const TextEditingValue(
- text: 'e',
- selection: TextSelection(baseOffset: 1, extentOffset: 1),
- );
- 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 rhinocerous');
- });
-
- testWidgets('can filter and select a list of custom User options', (WidgetTester tester) async {
- final GlobalKey fieldKey = GlobalKey();
- final GlobalKey optionsKey = GlobalKey();
- late Iterable<User> lastOptions;
- late AutocompleteOnSelected<User> lastOnSelected;
- late User lastUserSelected;
- late FocusNode focusNode;
- late TextEditingController textEditingController;
-
- await tester.pumpWidget(
- MaterialApp(
- home: Scaffold(
- body: RawAutocomplete<User>(
- optionsBuilder: (TextEditingValue textEditingValue) {
- return kOptionsUsers.where((User option) {
- return option.toString().contains(textEditingValue.text.toLowerCase());
- });
- },
- onSelected: (User selected) {
- lastUserSelected = selected;
- },
- fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
- focusNode = fieldFocusNode;
- textEditingController = fieldTextEditingController;
- return TextField(
- key: fieldKey,
- focusNode: focusNode,
- controller: fieldTextEditingController,
- );
- },
- optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) {
- lastOptions = options;
- lastOnSelected = onSelected;
- return Container(key: optionsKey);
- },
- ),
+ body: RawAutocomplete<String>(
+ key: autocompleteKey,
+ focusNode: focusNode,
+ textEditingController: textEditingController,
+ optionsBuilder: (TextEditingValue textEditingValue) {
+ return kOptions.where((String option) {
+ return option.contains(textEditingValue.text.toLowerCase());
+ });
+ },
+ optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
+ lastOptions = options;
+ return Container(key: optionsKey);
+ },
),
),
- );
+ ),
+ );
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsNothing);
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsNothing);
- // Enter text. The options are filtered by the text.
- focusNode.requestFocus();
- textEditingController.value = const TextEditingValue(
- text: 'example',
- selection: TextSelection(baseOffset: 7, extentOffset: 7),
- );
- await tester.pump();
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsOneWidget);
- expect(lastOptions.length, 2);
- expect(lastOptions.elementAt(0), kOptionsUsers[0]);
- expect(lastOptions.elementAt(1), kOptionsUsers[1]);
+ // Enter text. The options are filtered by the text.
+ focusNode.requestFocus();
+ textEditingController.value = const TextEditingValue(
+ text: 'ele',
+ selection: TextSelection(baseOffset: 3, extentOffset: 3),
+ );
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsOneWidget);
+ expect(lastOptions.length, 2);
+ expect(lastOptions.elementAt(0), 'chameleon');
+ expect(lastOptions.elementAt(1), 'elephant');
- // Select a option. The options hide and onSelected is called.
- final User selection = lastOptions.elementAt(1);
- lastOnSelected(selection);
- await tester.pump();
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsNothing);
- expect(lastUserSelected, selection);
- expect(textEditingController.text, selection.toString());
-
- // Modify the field text. The options appear again and are filtered, this
- // time by name instead of email.
- textEditingController.value = const TextEditingValue(
- text: 'B',
- selection: TextSelection(baseOffset: 1, extentOffset: 1),
- );
- await tester.pump();
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsOneWidget);
- expect(lastOptions.length, 1);
- expect(lastOptions.elementAt(0), kOptionsUsers[1]);
- });
-
- testWidgets('can specify a custom display string for a list of custom User options', (WidgetTester tester) async {
- final GlobalKey fieldKey = GlobalKey();
- final GlobalKey optionsKey = GlobalKey();
- late Iterable<User> lastOptions;
- late AutocompleteOnSelected<User> lastOnSelected;
- late User lastUserSelected;
- late final AutocompleteOptionToString<User> displayStringForOption = (User option) => option.name;
- late FocusNode focusNode;
- late TextEditingController textEditingController;
-
- await tester.pumpWidget(
- MaterialApp(
- home: Scaffold(
- body: RawAutocomplete<User>(
- optionsBuilder: (TextEditingValue textEditingValue) {
- return kOptionsUsers.where((User option) {
- return option
- .toString()
- .contains(textEditingValue.text.toLowerCase());
- });
- },
- displayStringForOption: displayStringForOption,
- onSelected: (User selected) {
- lastUserSelected = selected;
- },
- fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
- textEditingController = fieldTextEditingController;
- focusNode = fieldFocusNode;
- return TextField(
- key: fieldKey,
- focusNode: focusNode,
- controller: fieldTextEditingController,
- );
- },
- optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) {
- lastOptions = options;
- lastOnSelected = onSelected;
- return Container(key: optionsKey);
- },
- ),
- ),
- ),
- );
-
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsNothing);
-
- // Enter text. The options are filtered by the text.
- focusNode.requestFocus();
- textEditingController.value = const TextEditingValue(
- text: 'example',
- selection: TextSelection(baseOffset: 7, extentOffset: 7),
- );
- await tester.pump();
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsOneWidget);
- expect(lastOptions.length, 2);
- expect(lastOptions.elementAt(0), kOptionsUsers[0]);
- expect(lastOptions.elementAt(1), kOptionsUsers[1]);
-
- // Select a option. The options hide and onSelected is called. The field
- // has its text set to the selection's display string.
- final User selection = lastOptions.elementAt(1);
- lastOnSelected(selection);
- await tester.pump();
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsNothing);
- expect(lastUserSelected, selection);
- expect(textEditingController.text, selection.name);
-
- // Modify the field text. The options appear again and are filtered, this
- // time by name instead of email.
- textEditingController.value = const TextEditingValue(
- text: 'B',
- selection: TextSelection(baseOffset: 1, extentOffset: 1),
- );
- await tester.pump();
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsOneWidget);
- expect(lastOptions.length, 1);
- expect(lastOptions.elementAt(0), kOptionsUsers[1]);
- });
-
- testWidgets('onFieldSubmitted selects the first option', (WidgetTester tester) async {
- final GlobalKey fieldKey = GlobalKey();
- final GlobalKey optionsKey = GlobalKey();
- late Iterable<String> lastOptions;
- late VoidCallback lastOnFieldSubmitted;
- 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) {
- textEditingController = fieldTextEditingController;
- focusNode = fieldFocusNode;
- lastOnFieldSubmitted = onFieldSubmitted;
- return TextField(
- key: fieldKey,
- focusNode: focusNode,
- controller: fieldTextEditingController,
- );
- },
- 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();
- textEditingController.value = const TextEditingValue(
- text: 'ele',
- selection: TextSelection(baseOffset: 3, extentOffset: 3),
- );
- await tester.pump();
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsOneWidget);
- expect(lastOptions.length, 2);
- expect(lastOptions.elementAt(0), 'chameleon');
- expect(lastOptions.elementAt(1), 'elephant');
-
- // Select the current string, as if the field was submitted. The options
- // hide and the field updates to show the selection.
- lastOnFieldSubmitted();
- await tester.pump();
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsNothing);
- expect(textEditingController.text, lastOptions.elementAt(0));
- });
-
- testWidgets('options follow field when it moves', (WidgetTester tester) async {
- final GlobalKey fieldKey = GlobalKey();
- final GlobalKey optionsKey = GlobalKey();
- late StateSetter setState;
- Alignment alignment = Alignment.center;
- late FocusNode focusNode;
- late TextEditingController textEditingController;
-
- await tester.pumpWidget(
- MaterialApp(
- home: Scaffold(
- body: StatefulBuilder(
- builder: (BuildContext context, StateSetter setter) {
- setState = setter;
- return Align(
- alignment: alignment,
- child: 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(
- controller: fieldTextEditingController,
- focusNode: focusNode,
- key: fieldKey,
- );
- },
- optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
- return Container(key: optionsKey);
- },
- ),
- );
- },
- ),
- ),
- ),
- );
-
- // Field is shown but not options.
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsNothing);
-
- // Enter text to show the options.
- focusNode.requestFocus();
- textEditingController.value = const TextEditingValue(
- text: 'ele',
- selection: TextSelection(baseOffset: 3, extentOffset: 3),
- );
- await tester.pump();
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsOneWidget);
-
- // Options are just below the field.
- final Offset optionsOffset = tester.getTopLeft(find.byKey(optionsKey));
- Offset fieldOffset = tester.getTopLeft(find.byKey(fieldKey));
- final Size fieldSize = tester.getSize(find.byKey(fieldKey));
- expect(optionsOffset.dy, fieldOffset.dy + fieldSize.height);
-
- // Move the field (similar to as if the keyboard opened). The options move
- // to follow the field.
- setState(() {
- alignment = Alignment.topCenter;
- });
- await tester.pump();
- fieldOffset = tester.getTopLeft(find.byKey(fieldKey));
- final Offset optionsOffsetOpen = tester.getTopLeft(find.byKey(optionsKey));
- expect(optionsOffsetOpen.dy, isNot(equals(optionsOffset.dy)));
- expect(optionsOffsetOpen.dy, fieldOffset.dy + fieldSize.height);
- });
-
- testWidgets('can prevent options from showing by returning an empty iterable', (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) {
- if (textEditingValue.text == null || textEditingValue.text == '') {
- return const Iterable<String>.empty();
- }
- 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 TextField(
- key: fieldKey,
- focusNode: focusNode,
- controller: fieldTextEditingController,
- );
- },
- optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
- lastOptions = options;
- return Container(key: optionsKey);
- },
- ),
- ),
- ),
- );
-
- // The field is always rendered, but the options are not unless needed.
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsNothing);
-
- // Focus the empty field. The options are not displayed because
- // optionsBuilder returns nothing for an empty field query.
- focusNode.requestFocus();
- textEditingController.value = const TextEditingValue(
- text: '',
- selection: TextSelection(baseOffset: 0, extentOffset: 0),
- );
- await tester.pump();
- expect(find.byKey(optionsKey), findsNothing);
-
- // Enter text. Now the options appear, filtered by the text.
- textEditingController.value = const TextEditingValue(
- text: 'ele',
- selection: TextSelection(baseOffset: 3, extentOffset: 3),
- );
- await tester.pump();
- expect(find.byKey(fieldKey), findsOneWidget);
- expect(find.byKey(optionsKey), findsOneWidget);
- expect(lastOptions.length, 2);
- expect(lastOptions.elementAt(0), 'chameleon');
- expect(lastOptions.elementAt(1), 'elephant');
- });
+ // Submit the field. The options hide and the field updates to show the
+ // selection.
+ await tester.showKeyboard(find.byType(TextFormField));
+ await tester.testTextInput.receiveAction(TextInputAction.done);
+ await tester.pump();
+ expect(find.byKey(fieldKey), findsOneWidget);
+ expect(find.byKey(optionsKey), findsNothing);
+ expect(textEditingController.text, lastOptions.elementAt(0));
});
}