Reland "#143249 Autocomplete options width" (#161695)

Original PR: https://github.com/flutter/flutter/pull/143249
Revert PR: https://github.com/flutter/flutter/pull/161666

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
diff --git a/packages/flutter/lib/src/material/autocomplete.dart b/packages/flutter/lib/src/material/autocomplete.dart
index 6f3ca62..d917393 100644
--- a/packages/flutter/lib/src/material/autocomplete.dart
+++ b/packages/flutter/lib/src/material/autocomplete.dart
@@ -134,7 +134,7 @@
               onSelected: onSelected,
               options: options,
               openDirection: optionsViewOpenDirection,
-              maxOptionsHeight: optionsMaxHeight,
+              optionsMaxHeight: optionsMaxHeight,
             );
           },
       onSelected: onSelected,
@@ -176,7 +176,7 @@
     required this.onSelected,
     required this.openDirection,
     required this.options,
-    required this.maxOptionsHeight,
+    required this.optionsMaxHeight,
   });
 
   final AutocompleteOptionToString<T> displayStringForOption;
@@ -185,7 +185,7 @@
   final OptionsViewOpenDirection openDirection;
 
   final Iterable<T> options;
-  final double maxOptionsHeight;
+  final double optionsMaxHeight;
 
   @override
   Widget build(BuildContext context) {
@@ -198,7 +198,7 @@
       child: Material(
         elevation: 4.0,
         child: ConstrainedBox(
-          constraints: BoxConstraints(maxHeight: maxOptionsHeight),
+          constraints: BoxConstraints(maxHeight: optionsMaxHeight),
           child: ListView.builder(
             padding: EdgeInsets.zero,
             shrinkWrap: true,
diff --git a/packages/flutter/lib/src/widgets/autocomplete.dart b/packages/flutter/lib/src/widgets/autocomplete.dart
index 8cd23f5..958ce9e 100644
--- a/packages/flutter/lib/src/widgets/autocomplete.dart
+++ b/packages/flutter/lib/src/widgets/autocomplete.dart
@@ -6,18 +6,24 @@
 library;
 
 import 'dart:async';
+import 'dart:math' show max, min;
 
+import 'package:flutter/rendering.dart';
+import 'package:flutter/scheduler.dart';
 import 'package:flutter/services.dart';
 
 import 'actions.dart';
 import 'basic.dart';
+import 'constants.dart';
 import 'editable_text.dart';
 import 'focus_manager.dart';
 import 'framework.dart';
 import 'inherited_notifier.dart';
+import 'layout_builder.dart';
 import 'overlay.dart';
 import 'shortcuts.dart';
 import 'tap_region.dart';
+import 'value_listenable_builder.dart';
 
 // Examples can assume:
 // late BuildContext context;
@@ -213,10 +219,10 @@
   /// {@template flutter.widgets.RawAutocomplete.optionsViewBuilder}
   /// Builds the selectable options widgets from a list of options objects.
   ///
-  /// The options are displayed floating below or above the field using a
-  /// [CompositedTransformFollower] inside of an [Overlay], not at the same
-  /// place in the widget tree as [RawAutocomplete]. To control whether it opens
-  /// upward or downward, use [optionsViewOpenDirection].
+  /// The options are displayed floating below or above the field inside of an
+  /// [Overlay], not at the same place in the widget tree as [RawAutocomplete].
+  /// To control whether it opens upward or downward, use
+  /// [optionsViewOpenDirection].
   ///
   /// In order to track which item is highlighted by keyboard navigation, the
   /// resulting options will be wrapped in an inherited
@@ -307,6 +313,10 @@
 class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> {
   final GlobalKey _fieldKey = GlobalKey();
   final LayerLink _optionsLayerLink = LayerLink();
+
+  /// The box constraints that the field was last built with.
+  final ValueNotifier<BoxConstraints?> _fieldBoxConstraints = ValueNotifier<BoxConstraints?>(null);
+
   final OverlayPortalController _optionsViewController = OverlayPortalController(
     debugLabel: '_RawAutocompleteState',
   );
@@ -439,30 +449,22 @@
   }
 
   Widget _buildOptionsView(BuildContext context) {
-    final TextDirection textDirection = Directionality.of(context);
-    final Alignment followerAlignment = switch (widget.optionsViewOpenDirection) {
-      OptionsViewOpenDirection.up => AlignmentDirectional.bottomStart,
-      OptionsViewOpenDirection.down => AlignmentDirectional.topStart,
-    }.resolve(textDirection);
-    final Alignment targetAnchor = switch (widget.optionsViewOpenDirection) {
-      OptionsViewOpenDirection.up => AlignmentDirectional.topStart,
-      OptionsViewOpenDirection.down => AlignmentDirectional.bottomStart,
-    }.resolve(textDirection);
-
-    return CompositedTransformFollower(
-      link: _optionsLayerLink,
-      showWhenUnlinked: false,
-      targetAnchor: targetAnchor,
-      followerAnchor: followerAlignment,
-      child: TextFieldTapRegion(
-        child: AutocompleteHighlightedOption(
+    return ValueListenableBuilder<BoxConstraints?>(
+      valueListenable: _fieldBoxConstraints,
+      builder: (BuildContext context, BoxConstraints? constraints, Widget? child) {
+        return _RawAutocompleteOptions(
+          fieldKey: _fieldKey,
+          optionsLayerLink: _optionsLayerLink,
+          optionsViewOpenDirection: widget.optionsViewOpenDirection,
+          overlayContext: context,
+          textDirection: Directionality.maybeOf(context),
           highlightIndexNotifier: _highlightedOptionIndex,
-          child: Builder(
-            builder:
-                (BuildContext context) => widget.optionsViewBuilder(context, _select, _options),
-          ),
-        ),
-      ),
+          fieldConstraints: _fieldBoxConstraints.value!,
+          builder: (BuildContext context) {
+            return widget.optionsViewBuilder(context, _select, _options);
+          },
+        );
+      },
     );
   }
 
@@ -504,6 +506,7 @@
     widget.focusNode?.removeListener(_updateOptionsViewVisibility);
     _internalFocusNode?.dispose();
     _highlightedOptionIndex.dispose();
+    _fieldBoxConstraints.dispose();
     super.dispose();
   }
 
@@ -517,25 +520,224 @@
           _onFieldSubmitted,
         ) ??
         const SizedBox.shrink();
-    return OverlayPortal.targetsRootOverlay(
-      controller: _optionsViewController,
-      overlayChildBuilder: _buildOptionsView,
-      child: TextFieldTapRegion(
-        child: SizedBox(
+    return LayoutBuilder(
+      builder: (BuildContext context, BoxConstraints constraints) {
+        // TODO(victorsanni): Also track the width of the field box so that the
+        // options view maintains the same width as the field if its width
+        // changes but its constraints remain unchanged.
+        _fieldBoxConstraints.value = constraints;
+        return OverlayPortal.targetsRootOverlay(
           key: _fieldKey,
-          child: Shortcuts(
-            shortcuts: _shortcuts,
-            child: Actions(
-              actions: _actionMap,
-              child: CompositedTransformTarget(link: _optionsLayerLink, child: fieldView),
+          controller: _optionsViewController,
+          overlayChildBuilder: _buildOptionsView,
+          child: TextFieldTapRegion(
+            child: Shortcuts(
+              shortcuts: _shortcuts,
+              child: Actions(
+                actions: _actionMap,
+                child: CompositedTransformTarget(link: _optionsLayerLink, child: fieldView),
+              ),
             ),
           ),
+        );
+      },
+    );
+  }
+}
+
+class _RawAutocompleteOptions extends StatefulWidget {
+  const _RawAutocompleteOptions({
+    required this.fieldKey,
+    required this.optionsLayerLink,
+    required this.optionsViewOpenDirection,
+    required this.overlayContext,
+    required this.textDirection,
+    required this.highlightIndexNotifier,
+    required this.builder,
+    required this.fieldConstraints,
+  });
+
+  final WidgetBuilder builder;
+  final GlobalKey fieldKey;
+
+  final LayerLink optionsLayerLink;
+  final OptionsViewOpenDirection optionsViewOpenDirection;
+  final BuildContext overlayContext;
+  final TextDirection? textDirection;
+  final ValueNotifier<int> highlightIndexNotifier;
+  final BoxConstraints fieldConstraints;
+
+  @override
+  State<_RawAutocompleteOptions> createState() => _RawAutocompleteOptionsState();
+}
+
+class _RawAutocompleteOptionsState extends State<_RawAutocompleteOptions> {
+  VoidCallback? removeCompositionCallback;
+  Offset fieldOffset = Offset.zero;
+
+  // Get the field offset if the field's position changes when its layer tree
+  // is composited, which occurs for example if the field is in a scroll view.
+  Offset _getFieldOffset() {
+    final RenderBox? fieldRenderBox =
+        widget.fieldKey.currentContext?.findRenderObject() as RenderBox?;
+    final RenderBox? overlay =
+        Overlay.of(widget.overlayContext).context.findRenderObject() as RenderBox?;
+    return fieldRenderBox?.localToGlobal(Offset.zero, ancestor: overlay) ?? Offset.zero;
+  }
+
+  void _onLeaderComposition(Layer leaderLayer) {
+    SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
+      if (!mounted) {
+        return;
+      }
+      final Offset nextFieldOffset = _getFieldOffset();
+      if (nextFieldOffset != fieldOffset) {
+        setState(() {
+          fieldOffset = nextFieldOffset;
+        });
+      }
+    });
+  }
+
+  @override
+  void initState() {
+    super.initState();
+    removeCompositionCallback = widget.optionsLayerLink.leader?.addCompositionCallback(
+      _onLeaderComposition,
+    );
+  }
+
+  @override
+  void didUpdateWidget(_RawAutocompleteOptions oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.optionsLayerLink.leader != oldWidget.optionsLayerLink.leader) {
+      removeCompositionCallback?.call();
+      removeCompositionCallback = widget.optionsLayerLink.leader?.addCompositionCallback(
+        _onLeaderComposition,
+      );
+    }
+  }
+
+  @override
+  void dispose() {
+    removeCompositionCallback?.call();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return CompositedTransformFollower(
+      link: widget.optionsLayerLink,
+      followerAnchor: switch (widget.optionsViewOpenDirection) {
+        OptionsViewOpenDirection.up => Alignment.bottomLeft,
+        OptionsViewOpenDirection.down => Alignment.topLeft,
+      },
+      // When the field goes offscreen, don't show the options.
+      showWhenUnlinked: false,
+      child: CustomSingleChildLayout(
+        delegate: _RawAutocompleteOptionsLayoutDelegate(
+          layerLink: widget.optionsLayerLink,
+          fieldOffset: fieldOffset,
+          optionsViewOpenDirection: widget.optionsViewOpenDirection,
+          textDirection: Directionality.of(context),
+          fieldConstraints: widget.fieldConstraints,
+        ),
+        child: TextFieldTapRegion(
+          child: AutocompleteHighlightedOption(
+            highlightIndexNotifier: widget.highlightIndexNotifier,
+            // optionsViewBuilder must be able to look up
+            // AutocompleteHighlightedOption in its context.
+            child: Builder(builder: widget.builder),
+          ),
         ),
       ),
     );
   }
 }
 
+/// Positions the options view.
+class _RawAutocompleteOptionsLayoutDelegate extends SingleChildLayoutDelegate {
+  _RawAutocompleteOptionsLayoutDelegate({
+    required this.layerLink,
+    required this.fieldOffset,
+    required this.optionsViewOpenDirection,
+    required this.textDirection,
+    required this.fieldConstraints,
+  }) : assert(layerLink.leaderSize != null);
+
+  /// Links the options in [RawAutocomplete.optionsViewBuilder] to the field in
+  /// [RawAutocomplete.fieldViewBuilder].
+  final LayerLink layerLink;
+
+  /// The position of the field in [RawAutocomplete.fieldViewBuilder].
+  final Offset fieldOffset;
+
+  /// A direction in which to open the options view overlay.
+  final OptionsViewOpenDirection optionsViewOpenDirection;
+
+  /// The [TextDirection] of this part of the widget tree.
+  final TextDirection textDirection;
+
+  /// The [BoxConstraints] for the field in [RawAutocomplete.fieldViewBuilder].
+  final BoxConstraints fieldConstraints;
+
+  // A big enough height for about one item in the default
+  // Autocomplete.optionsViewBuilder. The assumption is that the user likely
+  // wants the list of options to move to stay on the screen rather than get any
+  // smaller than this. Allows Autocomplete to work when it has very little
+  // screen height available (as in b/317115348) by positioning itself on top of
+  // the field, while in other cases to size itself based on the height under
+  // the field.
+  static const double _kMinUsableHeight = kMinInteractiveDimension;
+
+  // Limits the child to the space above/below the field, with a minimum, and
+  // with the same maxWidth constraint as the field has.
+  @override
+  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
+    final Size fieldSize = layerLink.leaderSize!;
+    return BoxConstraints(
+      // The field width may be zero if this is a split RawAutocomplete with no
+      // field of its own. In that case, don't change the constraints width.
+      maxWidth: fieldSize.width == 0.0 ? constraints.maxWidth : fieldSize.width,
+      maxHeight: max(_kMinUsableHeight, switch (optionsViewOpenDirection) {
+        OptionsViewOpenDirection.down => constraints.maxHeight - fieldOffset.dy - fieldSize.height,
+        OptionsViewOpenDirection.up => fieldOffset.dy,
+      }),
+    );
+  }
+
+  // Positions the child above/below the field and aligned with the left/right
+  // side based on text direction.
+  @override
+  Offset getPositionForChild(Size size, Size childSize) {
+    final Size fieldSize = layerLink.leaderSize!;
+    final double dx = switch (textDirection) {
+      TextDirection.ltr => 0.0,
+      TextDirection.rtl => fieldSize.width - childSize.width,
+    };
+    final double dy = switch (optionsViewOpenDirection) {
+      OptionsViewOpenDirection.down => min(
+        fieldSize.height,
+        size.height - childSize.height - fieldOffset.dy,
+      ),
+      OptionsViewOpenDirection.up => size.height - min(childSize.height, fieldOffset.dy),
+    };
+    return Offset(dx, dy);
+  }
+
+  @override
+  bool shouldRelayout(_RawAutocompleteOptionsLayoutDelegate oldDelegate) {
+    if (!fieldOffset.isFinite || !layerLink.leaderSize!.isFinite) {
+      return false;
+    }
+    return layerLink != oldDelegate.layerLink ||
+        fieldOffset != oldDelegate.fieldOffset ||
+        optionsViewOpenDirection != oldDelegate.optionsViewOpenDirection ||
+        textDirection != oldDelegate.textDirection ||
+        fieldConstraints != oldDelegate.fieldConstraints;
+  }
+}
+
 class _AutocompleteCallbackAction<T extends Intent> extends CallbackAction<T> {
   _AutocompleteCallbackAction({required super.onInvoke, required this.isEnabledCallback});
 
diff --git a/packages/flutter/test/material/autocomplete_test.dart b/packages/flutter/test/material/autocomplete_test.dart
index a085b64..3b7c72f 100644
--- a/packages/flutter/test/material/autocomplete_test.dart
+++ b/packages/flutter/test/material/autocomplete_test.dart
@@ -592,6 +592,7 @@
 
       await tester.tap(find.byType(RawAutocomplete<String>));
       await tester.enterText(find.byType(RawAutocomplete<String>), 'a');
+      await tester.pump();
       expect(find.text('aa').hitTestable(), findsOneWidget);
     });
   });
diff --git a/packages/flutter/test/widgets/autocomplete_test.dart b/packages/flutter/test/widgets/autocomplete_test.dart
index 0333d8b..c32b120 100644
--- a/packages/flutter/test/widgets/autocomplete_test.dart
+++ b/packages/flutter/test/widgets/autocomplete_test.dart
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
@@ -135,60 +136,70 @@
     expect(lastOptions.elementAt(5), 'northern white rhinoceros');
   });
 
-  testWidgets('tapping on an option selects it', (WidgetTester tester) async {
+  testWidgets('can split the field and options', (WidgetTester tester) async {
     final GlobalKey fieldKey = GlobalKey();
     final GlobalKey optionsKey = GlobalKey();
     late Iterable<String> lastOptions;
-    late FocusNode focusNode;
-    late TextEditingController textEditingController;
+    late AutocompleteOnSelected<String> lastOnSelected;
+
+    final GlobalKey autocompleteKey = GlobalKey();
+    final TextEditingController textEditingController = TextEditingController();
+    final FocusNode focusNode = FocusNode();
+    addTearDown(textEditingController.dispose);
+    addTearDown(focusNode.dispose);
 
     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;
-              return Material(
-                elevation: 4.0,
-                child: ListView.builder(
+          appBar: AppBar(
+            // The field is in the AppBar, not actually a child of RawAutocomplete.
+            title: TextFormField(
+              key: fieldKey,
+              controller: textEditingController,
+              focusNode: focusNode,
+              decoration: const 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 kOptions.where((String option) {
+                  return option.contains(textEditingValue.text.toLowerCase());
+                }).toList();
+              },
+              optionsViewBuilder: (
+                BuildContext context,
+                AutocompleteOnSelected<String> onSelected,
+                Iterable<String> options,
+              ) {
+                lastOptions = options;
+                lastOnSelected = onSelected;
+                return Material(
                   key: optionsKey,
-                  padding: const EdgeInsets.all(8.0),
-                  itemCount: options.length,
-                  itemBuilder: (BuildContext context, int index) {
-                    final String option = options.elementAt(index);
-                    return GestureDetector(
-                      onTap: () {
-                        onSelected(option);
-                      },
-                      child: ListTile(title: Text(option)),
-                    );
-                  },
-                ),
-              );
-            },
+                  elevation: 4.0,
+                  child: ListView(
+                    children:
+                        options
+                            .map(
+                              (String option) => GestureDetector(
+                                onTap: () {
+                                  onSelected(option);
+                                },
+                                child: ListTile(title: Text(option)),
+                              ),
+                            )
+                            .toList(),
+                  ),
+                );
+              },
+            ),
           ),
         ),
       ),
@@ -198,20 +209,412 @@
     expect(find.byKey(fieldKey), findsOneWidget);
     expect(find.byKey(optionsKey), findsNothing);
 
-    // Tap on the text field to open the options.
-    await tester.tap(find.byKey(fieldKey));
+    // Focus the empty field. All the options are displayed.
+    focusNode.requestFocus();
     await tester.pump();
     expect(find.byKey(optionsKey), findsOneWidget);
     expect(lastOptions.length, kOptions.length);
+    expect(tester.getSize(find.byKey(optionsKey)).width, greaterThan(0.0));
 
-    await tester.tap(find.text(kOptions[2]));
+    // 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 an 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);
 
-    expect(textEditingController.text, equals(kOptions[2]));
+    // 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 rhinoceros');
   });
 
+  for (final OptionsViewOpenDirection openDirection in OptionsViewOpenDirection.values) {
+    testWidgets('tapping on an option selects it ($openDirection)', (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: Column(
+              children: <Widget>[
+                const SizedBox(height: 200),
+                RawAutocomplete<String>(
+                  optionsViewOpenDirection: openDirection,
+                  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;
+                    return Material(
+                      elevation: 4.0,
+                      child: ListView.builder(
+                        key: optionsKey,
+                        padding: const EdgeInsets.all(8.0),
+                        itemCount: options.length,
+                        itemBuilder: (BuildContext context, int index) {
+                          final String option = options.elementAt(index);
+                          return GestureDetector(
+                            onTap: () {
+                              onSelected(option);
+                            },
+                            child: ListTile(title: Text(option)),
+                          );
+                        },
+                      ),
+                    );
+                  },
+                ),
+              ],
+            ),
+          ),
+        ),
+      );
+
+      // The field is always rendered, but the options are not unless needed.
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      // Tap on the text field to open the options.
+      await tester.tap(find.byKey(fieldKey));
+      // Two pumps required due to post frame callback.
+      await tester.pump();
+      await tester.pump();
+      expect(find.byKey(optionsKey), findsOneWidget);
+      expect(lastOptions.length, kOptions.length);
+
+      await tester.tap(find.text(kOptions[2]));
+      await tester.pump();
+
+      expect(find.byKey(optionsKey), findsNothing);
+
+      expect(textEditingController.text, equals(kOptions[2]));
+    });
+
+    testWidgets('when not enough room for options, options cover field ($openDirection)', (
+      WidgetTester tester,
+    ) async {
+      const double padding = 32.0;
+      final GlobalKey fieldKey = GlobalKey();
+      final GlobalKey optionsKey = GlobalKey();
+      late StateSetter setState;
+      Alignment alignment = Alignment.bottomCenter;
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Scaffold(
+            body: StatefulBuilder(
+              builder: (BuildContext context, StateSetter setter) {
+                setState = setter;
+                return Padding(
+                  padding: const EdgeInsets.symmetric(horizontal: padding),
+                  child: Align(
+                    alignment: alignment,
+                    child: RawAutocomplete<String>(
+                      optionsViewOpenDirection: openDirection,
+                      optionsBuilder: (TextEditingValue textEditingValue) {
+                        return kOptions.where((String option) {
+                          return option.contains(textEditingValue.text.toLowerCase());
+                        });
+                      },
+                      optionsViewBuilder: (
+                        BuildContext context,
+                        AutocompleteOnSelected<String> onSelected,
+                        Iterable<String> options,
+                      ) {
+                        return ListView.builder(
+                          key: optionsKey,
+                          padding: EdgeInsets.zero,
+                          shrinkWrap: true,
+                          itemCount: options.length,
+                          itemBuilder: (BuildContext context, int index) {
+                            final String option = options.elementAt(index);
+                            return InkWell(
+                              onTap: () {
+                                onSelected(option);
+                              },
+                              child: Padding(
+                                padding: const EdgeInsets.all(16.0),
+                                child: Text(option),
+                              ),
+                            );
+                          },
+                        );
+                      },
+                      fieldViewBuilder: (
+                        BuildContext context,
+                        TextEditingController textEditingController,
+                        FocusNode focusNode,
+                        VoidCallback onSubmitted,
+                      ) {
+                        return TextField(
+                          key: fieldKey,
+                          focusNode: focusNode,
+                          controller: textEditingController,
+                        );
+                      },
+                    ),
+                  ),
+                );
+              },
+            ),
+          ),
+        ),
+      );
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      await tester.tap(find.byKey(fieldKey));
+      await tester.pump();
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsOneWidget);
+
+      await tester.enterText(find.byKey(fieldKey), 'go'); // 3 results.
+      await tester.pump();
+
+      switch (openDirection) {
+        case OptionsViewOpenDirection.up:
+          // Options are positioned and sized like normal.
+          expect(find.byType(InkWell), findsNWidgets(3));
+          final double optionHeight = tester.getSize(find.byType(InkWell).first).height;
+          final double topOfField = tester.getTopLeft(find.byKey(fieldKey)).dy;
+          expect(
+            tester.getTopLeft(find.byType(InkWell).first),
+            Offset(padding, topOfField - 3 * optionHeight),
+          );
+          expect(tester.getBottomLeft(find.byType(InkWell).at(2)), Offset(padding, topOfField));
+        case OptionsViewOpenDirection.down:
+          expect(find.byType(InkWell), findsNWidgets(1));
+          final Size optionsSize = tester.getSize(find.byKey(optionsKey));
+          expect(optionsSize.height, kMinInteractiveDimension);
+          // Options are positioned as low as possible while still fitting on screen.
+          final double bottomOfField = tester.getBottomLeft(find.byKey(optionsKey)).dy;
+          expect(
+            tester.getTopLeft(find.byKey(optionsKey)),
+            Offset(padding, bottomOfField - optionsSize.height),
+          );
+      }
+
+      // Add an extra pump to account for any potential frame delays introduced
+      // by the post frame callback in the _RawAutocompleteOptions
+      // implementation.
+      await tester.pump();
+      setState(() {
+        alignment = Alignment.topCenter;
+      });
+
+      // One frame for the field to move and one frame for the options to
+      // follow.
+      await tester.pump();
+      await tester.pump();
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsOneWidget);
+
+      switch (openDirection) {
+        case OptionsViewOpenDirection.up:
+          // Options are positioned as high as possible while still fitting on
+          // the screen.
+          expect(find.byType(InkWell), findsNWidgets(1));
+          final Size optionsSize = tester.getSize(find.byKey(optionsKey));
+          expect(optionsSize.height, kMinInteractiveDimension);
+          expect(tester.getTopLeft(find.byKey(optionsKey)), const Offset(padding, 0.0));
+          expect(tester.getBottomLeft(find.byKey(optionsKey)), Offset(padding, optionsSize.height));
+        case OptionsViewOpenDirection.down:
+          // Options are positioned and sized like normal.
+          expect(find.byType(InkWell), findsNWidgets(3));
+          final double optionHeight = tester.getSize(find.byType(InkWell).first).height;
+          final double bottomOfField = tester.getBottomLeft(find.byKey(fieldKey)).dy;
+          expect(tester.getTopLeft(find.byType(InkWell).first), Offset(padding, bottomOfField));
+          expect(
+            tester.getBottomLeft(find.byType(InkWell).at(2)),
+            Offset(padding, bottomOfField + 3 * optionHeight),
+          );
+      }
+    });
+
+    testWidgets('correct options alignment for RTL in direction $openDirection', (
+      WidgetTester tester,
+    ) async {
+      final GlobalKey fieldKey = GlobalKey();
+      final GlobalKey optionsKey = GlobalKey();
+      const double kOptionsWidth = 100.0;
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Scaffold(
+            body: Directionality(
+              textDirection: TextDirection.rtl,
+              child: Padding(
+                padding: const EdgeInsets.symmetric(horizontal: 32.0),
+                child: RawAutocomplete<String>(
+                  optionsViewOpenDirection: openDirection,
+                  optionsBuilder: (TextEditingValue textEditingValue) {
+                    return kOptions.where((String option) {
+                      return option.contains(textEditingValue.text.toLowerCase());
+                    });
+                  },
+                  fieldViewBuilder: (
+                    BuildContext context,
+                    TextEditingController textEditingController,
+                    FocusNode focusNode,
+                    VoidCallback onSubmitted,
+                  ) {
+                    return TextField(
+                      key: fieldKey,
+                      focusNode: focusNode,
+                      controller: textEditingController,
+                    );
+                  },
+                  optionsViewBuilder: (
+                    BuildContext context,
+                    AutocompleteOnSelected<String> onSelected,
+                    Iterable<String> options,
+                  ) {
+                    return SizedBox(width: kOptionsWidth, key: optionsKey);
+                  },
+                ),
+              ),
+            ),
+          ),
+        ),
+      );
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      await tester.tap(find.byType(TextField));
+      await tester.pump();
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsOneWidget);
+
+      final RenderBox optionsBox = tester.renderObject(find.byKey(optionsKey));
+      expect(optionsBox.size.width, kOptionsWidth);
+      expect(
+        tester.getTopRight(find.byKey(optionsKey)).dx,
+        tester.getTopRight(find.byKey(fieldKey)).dx,
+      );
+    });
+
+    testWidgets('options width matches field width with open direction $openDirection', (
+      WidgetTester tester,
+    ) async {
+      final GlobalKey fieldKey = GlobalKey();
+      final GlobalKey optionsKey = GlobalKey();
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Scaffold(
+            body: Padding(
+              padding: const EdgeInsets.symmetric(horizontal: 32.0),
+              child: Center(
+                child: RawAutocomplete<String>(
+                  optionsViewOpenDirection: openDirection,
+                  optionsBuilder: (TextEditingValue textEditingValue) {
+                    return kOptions.where((String option) {
+                      return option.contains(textEditingValue.text.toLowerCase());
+                    });
+                  },
+                  fieldViewBuilder: (
+                    BuildContext context,
+                    TextEditingController textEditingController,
+                    FocusNode focusNode,
+                    VoidCallback onSubmitted,
+                  ) {
+                    return TextField(
+                      key: fieldKey,
+                      focusNode: focusNode,
+                      controller: textEditingController,
+                    );
+                  },
+                  optionsViewBuilder: (
+                    BuildContext context,
+                    AutocompleteOnSelected<String> onSelected,
+                    Iterable<String> options,
+                  ) {
+                    return Container(key: optionsKey);
+                  },
+                ),
+              ),
+            ),
+          ),
+        ),
+      );
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      await tester.tap(find.byType(TextField));
+      // Two pumps required due to post frame callback.
+      await tester.pump();
+      await tester.pump();
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsOneWidget);
+
+      final RenderBox fieldBox = tester.renderObject(find.byKey(fieldKey));
+      final RenderBox optionsBox = tester.renderObject(find.byKey(optionsKey));
+      expect(optionsBox.size.width, equals(fieldBox.size.width));
+      expect(tester.getTopLeft(find.byKey(optionsKey)).dy, switch (openDirection) {
+        OptionsViewOpenDirection.down =>
+          tester.getTopLeft(find.byKey(fieldKey)).dy + fieldBox.size.height,
+        OptionsViewOpenDirection.up =>
+          tester.getTopLeft(find.byKey(fieldKey)).dy - optionsBox.size.height,
+      });
+    });
+  }
+
   testWidgets('can filter and select a list of custom User options', (WidgetTester tester) async {
     final GlobalKey fieldKey = GlobalKey();
     final GlobalKey optionsKey = GlobalKey();
@@ -490,6 +893,7 @@
         ),
       );
       await tester.showKeyboard(find.byType(TextField));
+      await tester.pump();
       expect(
         tester.getBottomLeft(find.byType(TextField)),
         offsetMoreOrLessEquals(tester.getTopLeft(find.text('a'))),
@@ -526,6 +930,7 @@
         ),
       );
       await tester.showKeyboard(find.byType(TextField));
+      await tester.pump();
       expect(
         tester.getBottomLeft(find.byType(TextField)),
         offsetMoreOrLessEquals(tester.getTopLeft(find.text('a'))),
@@ -561,6 +966,7 @@
         ),
       );
       await tester.showKeyboard(find.byType(TextField));
+      await tester.pump();
       expect(
         tester.getTopLeft(find.byType(TextField)),
         offsetMoreOrLessEquals(tester.getBottomLeft(find.text('a'))),
@@ -602,6 +1008,7 @@
           ),
         );
         await tester.showKeyboard(find.byType(TextField));
+        await tester.pump();
         expect(
           tester.getBottomLeft(find.byKey(autocompleteKey)),
           offsetMoreOrLessEquals(tester.getTopLeft(find.text('a'))),
@@ -642,6 +1049,7 @@
           ),
         );
         await tester.showKeyboard(find.byType(TextField));
+        await tester.pump();
         expect(
           tester.getTopLeft(find.byKey(autocompleteKey)),
           offsetMoreOrLessEquals(tester.getBottomLeft(find.text('a'))),
@@ -716,10 +1124,14 @@
     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 Offset optionsTopLeft = tester.getTopLeft(find.byKey(optionsKey));
+    final Offset fieldOffset = tester.getTopLeft(find.byKey(fieldKey));
     final Size fieldSize = tester.getSize(find.byKey(fieldKey));
-    expect(optionsOffset.dy, fieldOffset.dy + fieldSize.height);
+    expect(optionsTopLeft.dy, fieldOffset.dy + fieldSize.height);
+
+    // Add an extra pump to account for any potential frame delays introduced by
+    // the post frame callback in the _RawAutocompleteOptions implementation.
+    await tester.pump();
 
     // Move the field (similar to as if the keyboard opened). The options move
     // to follow the field.
@@ -727,10 +1139,68 @@
       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);
+    final Offset fieldOffsetFrame1 = tester.getTopLeft(find.byKey(fieldKey));
+    final Offset optionsTopLeftOpenFrame1 = tester.getTopLeft(find.byKey(optionsKey));
+
+    expect(fieldOffsetFrame1.dy, lessThan(fieldOffset.dy));
+    expect(optionsTopLeftOpenFrame1.dy, isNot(equals(optionsTopLeft.dy)));
+    expect(optionsTopLeftOpenFrame1.dy, fieldOffsetFrame1.dy + fieldSize.height);
+  });
+
+  testWidgets('options are shown one frame after tapping in field', (WidgetTester tester) async {
+    final GlobalKey fieldKey = GlobalKey();
+    final GlobalKey optionsKey = GlobalKey();
+
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Align(
+            alignment: Alignment.topCenter,
+            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,
+              ) {
+                return TextFormField(
+                  controller: fieldTextEditingController,
+                  focusNode: fieldFocusNode,
+                  key: fieldKey,
+                );
+              },
+              optionsViewBuilder: (
+                BuildContext context,
+                AutocompleteOnSelected<String> onSelected,
+                Iterable<String> options,
+              ) {
+                return ListView(
+                  key: optionsKey,
+                  children: options.map((String option) => Text(option)).toList(),
+                );
+              },
+            ),
+          ),
+        ),
+      ),
+    );
+
+    // Field is shown but not options.
+    expect(find.byKey(fieldKey), findsOneWidget);
+    expect(find.byKey(optionsKey), findsNothing);
+    expect(find.text('aardvark'), findsNothing);
+
+    // Tap to show the options.
+    await tester.tap(find.byKey(fieldKey));
+    await tester.pump();
+    expect(find.byKey(fieldKey), findsOneWidget);
+    expect(find.byKey(optionsKey), findsOneWidget);
+    expect(find.text('aardvark'), findsOneWidget);
   });
 
   testWidgets('can prevent options from showing by returning an empty iterable', (
@@ -1471,6 +1941,758 @@
     expect(find.byKey(optionsKey), findsNothing);
   });
 
+  testWidgets('options width matches field width after rebuilding', (WidgetTester tester) async {
+    final GlobalKey fieldKey = GlobalKey();
+    final GlobalKey optionsKey = GlobalKey();
+    late StateSetter setState;
+    double width = 100.0;
+
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Padding(
+            padding: const EdgeInsets.symmetric(horizontal: 32.0),
+            child: StatefulBuilder(
+              builder: (BuildContext context, StateSetter localStateSetter) {
+                setState = localStateSetter;
+                return SizedBox(
+                  width: width,
+                  child: RawAutocomplete<String>(
+                    optionsBuilder: (TextEditingValue textEditingValue) {
+                      return kOptions.where((String option) {
+                        return option.contains(textEditingValue.text.toLowerCase());
+                      });
+                    },
+                    optionsViewBuilder: (
+                      BuildContext context,
+                      AutocompleteOnSelected<String> onSelected,
+                      Iterable<String> options,
+                    ) {
+                      return Container(key: optionsKey);
+                    },
+                    fieldViewBuilder: (
+                      BuildContext context,
+                      TextEditingController textEditingController,
+                      FocusNode focusNode,
+                      VoidCallback onSubmitted,
+                    ) {
+                      return TextField(
+                        key: fieldKey,
+                        focusNode: focusNode,
+                        controller: textEditingController,
+                      );
+                    },
+                  ),
+                );
+              },
+            ),
+          ),
+        ),
+      ),
+    );
+
+    expect(find.byKey(fieldKey), findsOneWidget);
+    expect(find.byKey(optionsKey), findsNothing);
+
+    final RenderBox fieldBox = tester.renderObject(find.byKey(fieldKey));
+    expect(fieldBox.size.width, 100.0);
+
+    await tester.tap(find.byType(TextField));
+    await tester.pump();
+
+    expect(find.byKey(fieldKey), findsOneWidget);
+    expect(find.byKey(optionsKey), findsOneWidget);
+
+    final RenderBox optionsBox = tester.renderObject(find.byKey(optionsKey));
+    expect(optionsBox.size.width, 100.0);
+
+    // Add an extra pump to account for any potential frame delays introduced by
+    // the post frame callback in the _RawAutocompleteOptions implementation.
+    await tester.pump();
+    setState(() {
+      width = 200.0;
+    });
+    await tester.pump();
+
+    // The options width changes to match the field width.
+    expect(fieldBox.size.width, 200.0);
+    expect(optionsBox.size.width, 200.0);
+  });
+
+  testWidgets('options width matches field width after changing', (WidgetTester tester) async {
+    final GlobalKey fieldKey = GlobalKey();
+    final GlobalKey optionsKey = GlobalKey();
+    late StateSetter setState;
+    double width = 100.0;
+
+    final RawAutocomplete<String> autocomplete = RawAutocomplete<String>(
+      optionsBuilder: (TextEditingValue textEditingValue) {
+        return kOptions.where((String option) {
+          return option.contains(textEditingValue.text.toLowerCase());
+        });
+      },
+      optionsViewBuilder: (
+        BuildContext context,
+        AutocompleteOnSelected<String> onSelected,
+        Iterable<String> options,
+      ) {
+        return Container(key: optionsKey);
+      },
+      fieldViewBuilder: (
+        BuildContext context,
+        TextEditingController textEditingController,
+        FocusNode focusNode,
+        VoidCallback onSubmitted,
+      ) {
+        return TextField(key: fieldKey, focusNode: focusNode, controller: textEditingController);
+      },
+    );
+    await tester.pumpWidget(
+      MaterialApp(
+        home: Scaffold(
+          body: Padding(
+            padding: const EdgeInsets.symmetric(horizontal: 32.0),
+            child: StatefulBuilder(
+              builder: (BuildContext context, StateSetter localStateSetter) {
+                setState = localStateSetter;
+                return SizedBox(width: width, child: autocomplete);
+              },
+            ),
+          ),
+        ),
+      ),
+    );
+
+    expect(find.byKey(fieldKey), findsOneWidget);
+    expect(find.byKey(optionsKey), findsNothing);
+
+    final RenderBox fieldBox = tester.renderObject(find.byKey(fieldKey));
+    expect(fieldBox.size.width, 100.0);
+
+    await tester.tap(find.byType(TextField));
+    await tester.pump();
+
+    expect(find.byKey(fieldKey), findsOneWidget);
+    expect(find.byKey(optionsKey), findsOneWidget);
+
+    final RenderBox optionsBox = tester.renderObject(find.byKey(optionsKey));
+    expect(fieldBox.size.width, 100.0);
+    expect(optionsBox.size.width, 100.0);
+
+    // Add an extra pump to account for any potential frame delays introduced by
+    // the post frame callback in the _RawAutocompleteOptions implementation.
+    await tester.pump();
+    setState(() {
+      width = 200.0;
+    });
+    await tester.pump();
+
+    // The options width changes to match the field width.
+    expect(fieldBox.size.width, 200.0);
+    expect(optionsBox.size.width, 200.0);
+  });
+
+  group('screen size', () {
+    Future<void> pumpRawAutocomplete(
+      WidgetTester tester, {
+      GlobalKey? fieldKey,
+      GlobalKey? optionsKey,
+      OptionsViewOpenDirection optionsViewOpenDirection = OptionsViewOpenDirection.down,
+      Alignment alignment = Alignment.topLeft,
+    }) {
+      return tester.pumpWidget(
+        MaterialApp(
+          home: Scaffold(
+            body: Padding(
+              padding: const EdgeInsets.symmetric(horizontal: 32.0),
+              child: Align(
+                alignment: alignment,
+                child: RawAutocomplete<String>(
+                  optionsViewOpenDirection: optionsViewOpenDirection,
+                  optionsBuilder: (TextEditingValue textEditingValue) {
+                    return kOptions.where((String option) {
+                      return option.contains(textEditingValue.text.toLowerCase());
+                    });
+                  },
+                  optionsViewBuilder: (
+                    BuildContext context,
+                    AutocompleteOnSelected<String> onSelected,
+                    Iterable<String> options,
+                  ) {
+                    return ListView.builder(
+                      key: optionsKey,
+                      padding: EdgeInsets.zero,
+                      shrinkWrap: true,
+                      itemCount: options.length,
+                      itemBuilder: (BuildContext context, int index) {
+                        final String option = options.elementAt(index);
+                        return InkWell(
+                          onTap: () {
+                            onSelected(option);
+                          },
+                          child: Padding(padding: const EdgeInsets.all(16.0), child: Text(option)),
+                        );
+                      },
+                    );
+                  },
+                  fieldViewBuilder: (
+                    BuildContext context,
+                    TextEditingController textEditingController,
+                    FocusNode focusNode,
+                    VoidCallback onSubmitted,
+                  ) {
+                    return TextField(
+                      key: fieldKey,
+                      focusNode: focusNode,
+                      controller: textEditingController,
+                    );
+                  },
+                ),
+              ),
+            ),
+          ),
+        ),
+      );
+    }
+
+    testWidgets('options when screen changes landscape to portrait', (WidgetTester tester) async {
+      // Start with a portrait-sized window, with enough space for all of the
+      // options.
+      const Size wideWindowSize = Size(1920.0, 1080.0);
+      tester.view.physicalSize = wideWindowSize;
+      tester.view.devicePixelRatio = 1.0;
+      addTearDown(tester.view.reset);
+
+      final GlobalKey fieldKey = GlobalKey();
+      final GlobalKey optionsKey = GlobalKey();
+
+      await pumpRawAutocomplete(tester, fieldKey: fieldKey, optionsKey: optionsKey);
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      await tester.tap(find.byType(TextField));
+      await tester.pumpAndSettle();
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsOneWidget);
+      expect(find.byType(InkWell), findsNWidgets(kOptions.length));
+
+      final Size fieldSize1 = tester.getSize(find.byKey(fieldKey));
+      final Offset optionsTopLeft1 = tester.getTopLeft(find.byKey(optionsKey));
+      expect(
+        optionsTopLeft1,
+        Offset(
+          tester.getTopLeft(find.byKey(fieldKey)).dx,
+          tester.getTopLeft(find.byKey(fieldKey)).dy + fieldSize1.height,
+        ),
+      );
+      final Offset optionsBottomRight1 = tester.getBottomRight(find.byKey(optionsKey));
+      final double optionHeight = tester.getSize(find.byType(InkWell).first).height;
+      expect(
+        optionsBottomRight1,
+        Offset(
+          tester.getTopLeft(find.byKey(fieldKey)).dx + fieldSize1.width,
+          tester.getTopLeft(find.byKey(fieldKey)).dy +
+              fieldSize1.height +
+              optionHeight * kOptions.length,
+        ),
+      );
+
+      // Change the screen size to portrait.
+      const Size narrowWindowSize = Size(1070.0, 1770.0);
+      tester.view.physicalSize = narrowWindowSize;
+      tester.view.devicePixelRatio = 1.0;
+      await tester.pumpAndSettle();
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byType(InkWell), findsNWidgets(kOptions.length));
+      expect(tester.getTopLeft(find.byKey(optionsKey)), optionsTopLeft1);
+      final Size fieldSize2 = tester.getSize(find.byKey(fieldKey));
+      expect(fieldSize1.width, greaterThan(fieldSize2.width));
+      expect(fieldSize1.height, fieldSize2.height);
+      final Offset optionsBottomRight2 = tester.getBottomRight(find.byKey(optionsKey));
+      final Offset fieldTopLeft2 = tester.getTopLeft(find.byKey(fieldKey));
+      expect(optionsBottomRight2.dx, lessThan(optionsBottomRight1.dx));
+      expect(optionsBottomRight2.dy, optionsBottomRight1.dy);
+      expect(
+        optionsBottomRight2,
+        Offset(
+          fieldTopLeft2.dx + fieldSize2.width,
+          fieldTopLeft2.dy + fieldSize2.height + optionHeight * kOptions.length,
+        ),
+      );
+    });
+
+    testWidgets('options when screen changes portrait to landscape and overflows', (
+      WidgetTester tester,
+    ) async {
+      // Start with a portrait-sized window, with enough space for all of the
+      // options.
+      const Size narrowWindowSize = Size(1070.0, 1770.0);
+      tester.view.physicalSize = narrowWindowSize;
+      tester.view.devicePixelRatio = 1.0;
+      addTearDown(tester.view.reset);
+
+      final GlobalKey fieldKey = GlobalKey();
+      final GlobalKey optionsKey = GlobalKey();
+
+      await pumpRawAutocomplete(tester, fieldKey: fieldKey, optionsKey: optionsKey);
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      await tester.tap(find.byType(TextField));
+      await tester.pumpAndSettle();
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsOneWidget);
+      expect(find.byType(InkWell), findsNWidgets(kOptions.length));
+
+      final Size fieldSize1 = tester.getSize(find.byKey(fieldKey));
+      final Offset optionsTopLeft1 = tester.getTopLeft(find.byKey(optionsKey));
+      expect(
+        optionsTopLeft1,
+        Offset(
+          tester.getTopLeft(find.byKey(fieldKey)).dx,
+          tester.getTopLeft(find.byKey(fieldKey)).dy + fieldSize1.height,
+        ),
+      );
+      final Offset optionsBottomRight1 = tester.getBottomRight(find.byKey(optionsKey));
+      final double optionHeight = tester.getSize(find.byType(InkWell).first).height;
+      expect(
+        optionsBottomRight1,
+        Offset(
+          tester.getTopLeft(find.byKey(fieldKey)).dx + fieldSize1.width,
+          tester.getTopLeft(find.byKey(fieldKey)).dy +
+              fieldSize1.height +
+              optionHeight * kOptions.length,
+        ),
+      );
+
+      // Change the screen size to landscape where the options can't all fit on
+      // the screen.
+      const Size wideWindowSize = Size(1920.0, 580.0);
+      tester.view.physicalSize = wideWindowSize;
+      tester.view.devicePixelRatio = 1.0;
+      await tester.pumpAndSettle();
+      expect(find.byKey(fieldKey), findsOneWidget);
+
+      final int visibleOptions = (wideWindowSize.height / optionHeight).floor();
+      expect(visibleOptions, lessThan(kOptions.length));
+      expect(find.byType(InkWell), findsNWidgets(visibleOptions));
+      expect(tester.getTopLeft(find.byKey(optionsKey)), optionsTopLeft1);
+      final Size fieldSize2 = tester.getSize(find.byKey(fieldKey));
+      expect(fieldSize1.width, lessThan(fieldSize2.width));
+      expect(fieldSize1.height, fieldSize2.height);
+      final Offset optionsBottomRight2 = tester.getBottomRight(find.byKey(optionsKey));
+      final Offset fieldTopLeft2 = tester.getTopLeft(find.byKey(fieldKey));
+      expect(optionsBottomRight2.dx, greaterThan(optionsBottomRight1.dx));
+      expect(optionsBottomRight2.dy, lessThan(optionsBottomRight1.dy));
+      expect(
+        optionsBottomRight2,
+        Offset(
+          fieldTopLeft2.dx + fieldSize2.width,
+          // Options are taking all available space below the field.
+          wideWindowSize.height,
+        ),
+      );
+    });
+
+    testWidgets('screen changes portrait to landscape and overflows', (WidgetTester tester) async {
+      // Start with a portrait-sized window, with enough space for all of the
+      // options.
+      const Size narrowWindowSize = Size(1070.0, 1770.0);
+      tester.view.physicalSize = narrowWindowSize;
+      tester.view.devicePixelRatio = 1.0;
+      addTearDown(tester.view.reset);
+
+      final GlobalKey fieldKey = GlobalKey();
+      final GlobalKey optionsKey = GlobalKey();
+
+      await pumpRawAutocomplete(tester, fieldKey: fieldKey, optionsKey: optionsKey);
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      await tester.tap(find.byType(TextField));
+      await tester.pumpAndSettle();
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsOneWidget);
+
+      final double optionHeight = tester.getSize(find.byType(InkWell).first).height;
+      final double optionsHeight1 = tester.getSize(find.byKey(optionsKey)).height;
+      final int visibleOptions1 = (optionsHeight1 / optionHeight).ceil();
+      expect(find.byType(InkWell), findsNWidgets(visibleOptions1));
+      final Size fieldSize1 = tester.getSize(find.byKey(fieldKey));
+      final Offset optionsTopLeft1 = tester.getTopLeft(find.byKey(optionsKey));
+      final Offset fieldTopLeft1 = tester.getTopLeft(find.byKey(fieldKey));
+      expect(optionsTopLeft1, Offset(fieldTopLeft1.dx, fieldTopLeft1.dy + fieldSize1.height));
+      final Offset optionsBottomRight1 = tester.getBottomRight(find.byKey(optionsKey));
+      expect(
+        optionsBottomRight1,
+        Offset(
+          fieldTopLeft1.dx + fieldSize1.width,
+          fieldTopLeft1.dy + fieldSize1.height + optionsHeight1,
+        ),
+      );
+
+      // Change the screen size to landscape where the options can't all fit on
+      // the screen.
+      const Size wideWindowSize = Size(1920.0, 580.0);
+      tester.view.physicalSize = wideWindowSize;
+      tester.view.devicePixelRatio = 1.0;
+      await tester.pumpAndSettle();
+      expect(find.byKey(fieldKey), findsOneWidget);
+
+      final double optionsHeight2 = tester.getSize(find.byKey(optionsKey)).height;
+      final int visibleOptions2 = (optionsHeight2 / optionHeight).ceil();
+      expect(visibleOptions2, lessThan(kOptions.length));
+      expect(find.byType(InkWell), findsNWidgets(visibleOptions2));
+      final Offset optionsTopLeft2 = tester.getTopLeft(find.byKey(optionsKey));
+      expect(optionsTopLeft2, optionsTopLeft1);
+      final Size fieldSize2 = tester.getSize(find.byKey(fieldKey));
+      expect(fieldSize1.width, lessThan(fieldSize2.width));
+      expect(fieldSize1.height, fieldSize2.height);
+      final Offset optionsBottomRight2 = tester.getBottomRight(find.byKey(optionsKey));
+      final Offset fieldTopLeft2 = tester.getTopLeft(find.byKey(fieldKey));
+      expect(optionsBottomRight2.dx, greaterThan(optionsBottomRight1.dx));
+      expect(
+        optionsBottomRight2,
+        Offset(
+          fieldTopLeft2.dx + fieldSize2.width,
+          // Options are taking all available space below the field.
+          wideWindowSize.height,
+        ),
+      );
+
+      // Shrink the screen further so that the options become smaller than
+      // kMinInteractiveDimension and move to overlap the field.
+      const Size shortWindowSize = Size(1920.0, 90.0);
+      tester.view.physicalSize = shortWindowSize;
+      tester.view.devicePixelRatio = 1.0;
+      await tester.pumpAndSettle();
+      expect(find.byKey(fieldKey), findsOneWidget);
+
+      const int visibleOptions3 = 1;
+      expect(find.byType(InkWell), findsNWidgets(visibleOptions3));
+      final Offset optionsTopLeft3 = tester.getTopLeft(find.byKey(optionsKey));
+      expect(optionsTopLeft3.dx, optionsTopLeft1.dx);
+      // The options have moved up, overlapping the field, to still be able to
+      // show kMinInteractiveDimension.
+      expect(optionsTopLeft3.dy, lessThan(optionsTopLeft1.dy));
+      final Size fieldSize3 = tester.getSize(find.byKey(fieldKey));
+      final Offset fieldTopLeft3 = tester.getTopLeft(find.byKey(fieldKey));
+      expect(optionsTopLeft3.dy, lessThan(fieldTopLeft3.dy + fieldSize3.height));
+      expect(fieldSize3.width, fieldSize2.width);
+      expect(fieldSize1.height, fieldSize3.height);
+      final Offset optionsBottomRight3 = tester.getBottomRight(find.byKey(optionsKey));
+      expect(optionsBottomRight3.dx, greaterThan(optionsBottomRight1.dx));
+      expect(
+        optionsBottomRight3,
+        Offset(fieldTopLeft3.dx + fieldSize3.width, shortWindowSize.height),
+      );
+    });
+
+    testWidgets('when opening up screen changes portrait to landscape and overflows', (
+      WidgetTester tester,
+    ) async {
+      // Start with a portrait-sized window, with enough space for all of the
+      // options.
+      const Size narrowWindowSize = Size(1070.0, 1770.0);
+      tester.view.physicalSize = narrowWindowSize;
+      tester.view.devicePixelRatio = 1.0;
+      addTearDown(tester.view.reset);
+
+      final GlobalKey fieldKey = GlobalKey();
+      final GlobalKey optionsKey = GlobalKey();
+
+      await pumpRawAutocomplete(
+        tester,
+        fieldKey: fieldKey,
+        optionsKey: optionsKey,
+        optionsViewOpenDirection: OptionsViewOpenDirection.up,
+        alignment: Alignment.center,
+      );
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      await tester.tap(find.byType(TextField));
+      await tester.pumpAndSettle();
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsOneWidget);
+
+      final double optionHeight = tester.getSize(find.byType(InkWell).first).height;
+      final double optionsHeight1 = tester.getSize(find.byKey(optionsKey)).height;
+      final int visibleOptions1 = (optionsHeight1 / optionHeight).ceil();
+      expect(find.byType(InkWell), findsNWidgets(visibleOptions1));
+      final Size fieldSize1 = tester.getSize(find.byKey(fieldKey));
+      final Offset optionsTopLeft1 = tester.getTopLeft(find.byKey(optionsKey));
+      final Offset fieldTopLeft1 = tester.getTopLeft(find.byKey(fieldKey));
+      expect(optionsTopLeft1, Offset(fieldTopLeft1.dx, fieldTopLeft1.dy - optionsHeight1));
+      expect(optionsTopLeft1.dy, greaterThan(0.0));
+      final Offset optionsBottomRight1 = tester.getBottomRight(find.byKey(optionsKey));
+      expect(optionsBottomRight1, Offset(fieldTopLeft1.dx + fieldSize1.width, fieldTopLeft1.dy));
+
+      // Change the screen size to landscape where the options can't all fit on
+      // the screen.
+      const Size wideWindowSize = Size(1920.0, 580.0);
+      tester.view.physicalSize = wideWindowSize;
+      tester.view.devicePixelRatio = 1.0;
+      await tester.pumpAndSettle();
+      expect(find.byKey(fieldKey), findsOneWidget);
+
+      final double optionsHeight2 = tester.getSize(find.byKey(optionsKey)).height;
+      expect(optionsHeight2, lessThan(optionsHeight1));
+      final int visibleOptions2 = (optionsHeight2 / optionHeight).ceil();
+      expect(visibleOptions2, lessThan(visibleOptions1));
+      expect(find.byType(InkWell), findsNWidgets(visibleOptions2));
+      final Offset optionsTopLeft2 = tester.getTopLeft(find.byKey(optionsKey));
+      final Offset fieldTopLeft2 = tester.getTopLeft(find.byKey(fieldKey));
+      expect(optionsTopLeft2, Offset(optionsTopLeft1.dx, fieldTopLeft2.dy - optionsHeight2));
+      final Size fieldSize2 = tester.getSize(find.byKey(fieldKey));
+      expect(fieldSize1.width, lessThan(fieldSize2.width));
+      expect(fieldSize1.height, fieldSize2.height);
+      final Offset optionsBottomRight2 = tester.getBottomRight(find.byKey(optionsKey));
+      expect(optionsBottomRight2.dx, greaterThan(optionsBottomRight1.dx));
+      expect(optionsBottomRight2, Offset(fieldTopLeft2.dx + fieldSize2.width, fieldTopLeft2.dy));
+
+      // Shrink the screen further so that the options become smaller than
+      // kMinInteractiveDimension and move to overlap the field.
+      const Size shortWindowSize = Size(1920.0, 90.0);
+      tester.view.physicalSize = shortWindowSize;
+      tester.view.devicePixelRatio = 1.0;
+      await tester.pumpAndSettle();
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      const int visibleOptions3 = 1;
+      expect(find.byType(InkWell), findsNWidgets(visibleOptions3));
+      final Offset optionsTopLeft3 = tester.getTopLeft(find.byKey(optionsKey));
+      expect(optionsTopLeft3.dx, optionsTopLeft1.dx);
+      // The options have moved down, overlapping the field, to still be able to
+      // show kMinInteractiveDimension.
+      expect(optionsTopLeft3.dy, lessThan(optionsTopLeft1.dy));
+      final Size fieldSize3 = tester.getSize(find.byKey(fieldKey));
+      final Offset fieldTopLeft3 = tester.getTopLeft(find.byKey(fieldKey));
+      expect(optionsTopLeft3.dy, lessThan(fieldTopLeft3.dy + fieldSize3.height));
+      expect(fieldSize3.width, fieldSize2.width);
+      expect(fieldSize1.height, fieldSize3.height);
+      final Offset optionsBottomRight3 = tester.getBottomRight(find.byKey(optionsKey));
+      expect(optionsBottomRight3.dx, greaterThan(optionsBottomRight1.dx));
+      expect(optionsBottomRight3.dy, greaterThan(fieldTopLeft3.dy));
+      expect(optionsBottomRight3.dx, fieldTopLeft3.dx + fieldSize3.width);
+    });
+  });
+
+  testWidgets(
+    'when field scrolled offscreen, options are hidden and not reshown when scrolled back on desktop and web',
+    (WidgetTester tester) async {
+      final GlobalKey fieldKey = GlobalKey();
+      final GlobalKey optionsKey = GlobalKey();
+      final ScrollController scrollController = ScrollController();
+      addTearDown(scrollController.dispose);
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Scaffold(
+            body: ListView(
+              controller: scrollController,
+              children: <Widget>[
+                const SizedBox(height: 1000.0),
+                RawAutocomplete<String>(
+                  optionsBuilder: (TextEditingValue textEditingValue) {
+                    return kOptions.where((String option) {
+                      return option.contains(textEditingValue.text.toLowerCase());
+                    });
+                  },
+                  optionsViewBuilder: (
+                    BuildContext context,
+                    AutocompleteOnSelected<String> onSelected,
+                    Iterable<String> options,
+                  ) {
+                    return ListView.builder(
+                      key: optionsKey,
+                      padding: EdgeInsets.zero,
+                      shrinkWrap: true,
+                      itemCount: options.length,
+                      itemBuilder: (BuildContext context, int index) {
+                        final String option = options.elementAt(index);
+                        return InkWell(
+                          onTap: () {
+                            onSelected(option);
+                          },
+                          child: Padding(padding: const EdgeInsets.all(16.0), child: Text(option)),
+                        );
+                      },
+                    );
+                  },
+                  fieldViewBuilder: (
+                    BuildContext context,
+                    TextEditingController textEditingController,
+                    FocusNode focusNode,
+                    VoidCallback onSubmitted,
+                  ) {
+                    return TextField(
+                      key: fieldKey,
+                      focusNode: focusNode,
+                      controller: textEditingController,
+                    );
+                  },
+                ),
+                const SizedBox(height: 1000.0),
+              ],
+            ),
+          ),
+        ),
+      );
+
+      expect(find.byKey(fieldKey), findsNothing);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      await tester.scrollUntilVisible(find.byKey(fieldKey), 500.0);
+      await tester.pumpAndSettle();
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      await tester.tap(find.byKey(fieldKey));
+      await tester.pump();
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsOneWidget);
+
+      // Jump to the beginning. The field is off screen and the options are not
+      // showing either.
+      scrollController.jumpTo(0.0);
+      await tester.pumpAndSettle();
+
+      expect(find.byKey(fieldKey), findsNothing);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      // Scroll back to the field and ensure it is visible.
+      await tester.scrollUntilVisible(find.byKey(fieldKey), 500.0);
+      await tester.pumpAndSettle();
+
+      // The options are no longer visible on desktop and web.
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      // Jump to the end. The field is hidden again.
+      scrollController.jumpTo(2000.0);
+      await tester.pumpAndSettle();
+
+      expect(find.byKey(fieldKey), findsNothing);
+      expect(find.byKey(optionsKey), findsNothing);
+    },
+    variant: TargetPlatformVariant.desktop(),
+  );
+
+  testWidgets(
+    'when field scrolled offscreen, options are hidden and reshown when scrolled back on mobile',
+    (WidgetTester tester) async {
+      final GlobalKey fieldKey = GlobalKey();
+      final GlobalKey optionsKey = GlobalKey();
+      final ScrollController scrollController = ScrollController();
+      addTearDown(scrollController.dispose);
+
+      await tester.pumpWidget(
+        MaterialApp(
+          home: Scaffold(
+            body: ListView(
+              controller: scrollController,
+              children: <Widget>[
+                const SizedBox(height: 1000.0),
+                RawAutocomplete<String>(
+                  optionsBuilder: (TextEditingValue textEditingValue) {
+                    return kOptions.where((String option) {
+                      return option.contains(textEditingValue.text.toLowerCase());
+                    });
+                  },
+                  optionsViewBuilder: (
+                    BuildContext context,
+                    AutocompleteOnSelected<String> onSelected,
+                    Iterable<String> options,
+                  ) {
+                    return ListView.builder(
+                      key: optionsKey,
+                      padding: EdgeInsets.zero,
+                      shrinkWrap: true,
+                      itemCount: options.length,
+                      itemBuilder: (BuildContext context, int index) {
+                        final String option = options.elementAt(index);
+                        return InkWell(
+                          onTap: () {
+                            onSelected(option);
+                          },
+                          child: Padding(padding: const EdgeInsets.all(16.0), child: Text(option)),
+                        );
+                      },
+                    );
+                  },
+                  fieldViewBuilder: (
+                    BuildContext context,
+                    TextEditingController textEditingController,
+                    FocusNode focusNode,
+                    VoidCallback onSubmitted,
+                  ) {
+                    return TextField(
+                      key: fieldKey,
+                      focusNode: focusNode,
+                      controller: textEditingController,
+                    );
+                  },
+                ),
+                const SizedBox(height: 1000.0),
+              ],
+            ),
+          ),
+        ),
+      );
+
+      expect(find.byKey(fieldKey), findsNothing);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      await tester.scrollUntilVisible(find.byKey(fieldKey), 500.0);
+      await tester.pumpAndSettle();
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      await tester.tap(find.byKey(fieldKey));
+      await tester.pump();
+
+      expect(find.byKey(fieldKey), findsOneWidget);
+      expect(find.byKey(optionsKey), findsOneWidget);
+
+      // Jump to the beginning. The field is off screen and the options are not
+      // showing either.
+      scrollController.jumpTo(0.0);
+      await tester.pumpAndSettle();
+
+      expect(find.byKey(fieldKey), findsNothing);
+      expect(find.byKey(optionsKey), findsNothing);
+
+      // Scroll back to the field and ensure it is visible.
+      await tester.scrollUntilVisible(find.byKey(fieldKey), 500.0);
+      await tester.pumpAndSettle();
+
+      // The options remain visible on mobile, but not on web.
+      expect(find.byKey(fieldKey), findsOneWidget);
+      kIsWeb
+          ? expect(find.byKey(optionsKey), findsNothing)
+          : expect(find.byKey(optionsKey), findsOneWidget);
+
+      // Jump to the end. The field is hidden again.
+      scrollController.jumpTo(2000.0);
+      await tester.pumpAndSettle();
+
+      expect(find.byKey(fieldKey), findsNothing);
+      expect(find.byKey(optionsKey), findsNothing);
+    },
+    variant: TargetPlatformVariant.mobile(),
+  );
+
   testWidgets('can prevent older optionsBuilder results from replacing the new ones', (
     WidgetTester tester,
   ) async {