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));
   });
 }