From 3297454732841b1a5a25d9f35f1fd5d7a4479e12 Mon Sep 17 00:00:00 2001 From: Victor Sanni Date: Thu, 16 Jan 2025 22:50:56 -0800 Subject: [PATCH] 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]. [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 --- .../lib/src/material/autocomplete.dart | 8 +- .../flutter/lib/src/widgets/autocomplete.dart | 276 ++- .../test/material/autocomplete_test.dart | 1 + .../test/widgets/autocomplete_test.dart | 1666 ++++++++++++++--- 4 files changed, 1688 insertions(+), 263 deletions(-) diff --git a/packages/flutter/lib/src/material/autocomplete.dart b/packages/flutter/lib/src/material/autocomplete.dart index 6f3ca626538a1..d917393cd418c 100644 --- a/packages/flutter/lib/src/material/autocomplete.dart +++ b/packages/flutter/lib/src/material/autocomplete.dart @@ -134,7 +134,7 @@ class Autocomplete extends StatelessWidget { onSelected: onSelected, options: options, openDirection: optionsViewOpenDirection, - maxOptionsHeight: optionsMaxHeight, + optionsMaxHeight: optionsMaxHeight, ); }, onSelected: onSelected, @@ -176,7 +176,7 @@ class _AutocompleteOptions extends StatelessWidget { required this.onSelected, required this.openDirection, required this.options, - required this.maxOptionsHeight, + required this.optionsMaxHeight, }); final AutocompleteOptionToString displayStringForOption; @@ -185,7 +185,7 @@ class _AutocompleteOptions extends StatelessWidget { final OptionsViewOpenDirection openDirection; final Iterable options; - final double maxOptionsHeight; + final double optionsMaxHeight; @override Widget build(BuildContext context) { @@ -198,7 +198,7 @@ class _AutocompleteOptions extends StatelessWidget { 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 8cd23f563527f..958ce9e4aebce 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 @@ class RawAutocomplete extends StatefulWidget { /// {@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 RawAutocomplete extends StatefulWidget { class _RawAutocompleteState extends State> { final GlobalKey _fieldKey = GlobalKey(); final LayerLink _optionsLayerLink = LayerLink(); + + /// The box constraints that the field was last built with. + final ValueNotifier _fieldBoxConstraints = ValueNotifier(null); + final OverlayPortalController _optionsViewController = OverlayPortalController( debugLabel: '_RawAutocompleteState', ); @@ -439,30 +449,22 @@ class _RawAutocompleteState extends State> } 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( + 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 @@ class _RawAutocompleteState extends State> widget.focusNode?.removeListener(_updateOptionsViewVisibility); _internalFocusNode?.dispose(); _highlightedOptionIndex.dispose(); + _fieldBoxConstraints.dispose(); super.dispose(); } @@ -517,25 +520,224 @@ class _RawAutocompleteState extends State> _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 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 extends CallbackAction { _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 a085b64d99da5..3b7c72f6f978e 100644 --- a/packages/flutter/test/material/autocomplete_test.dart +++ b/packages/flutter/test/material/autocomplete_test.dart @@ -592,6 +592,7 @@ void main() { await tester.tap(find.byType(RawAutocomplete)); await tester.enterText(find.byType(RawAutocomplete), '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 0333d8b7d0856..c32b120a32f98 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 @@ void main() { 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 lastOptions; - late FocusNode focusNode; - late TextEditingController textEditingController; + late AutocompleteOnSelected 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( - 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 onSelected, - Iterable 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(autocompleteKey); + }, + ), + ), + body: Align( + alignment: Alignment.topLeft, + child: RawAutocomplete( + 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 onSelected, + Iterable 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,185 +209,485 @@ void main() { 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); - - await tester.tap(find.text(kOptions[2])); - await tester.pump(); - - expect(find.byKey(optionsKey), findsNothing); - - expect(textEditingController.text, equals(kOptions[2])); - }); - - testWidgets('can filter and select a list of custom User options', (WidgetTester tester) async { - final GlobalKey fieldKey = GlobalKey(); - final GlobalKey optionsKey = GlobalKey(); - late Iterable lastOptions; - late AutocompleteOnSelected lastOnSelected; - late User lastUserSelected; - late FocusNode focusNode; - late TextEditingController textEditingController; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: RawAutocomplete( - 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 onSelected, - Iterable options, - ) { - lastOptions = options; - lastOnSelected = onSelected; - return Container(key: optionsKey); - }, - ), - ), - ), - ); - - expect(find.byKey(fieldKey), findsOneWidget); - expect(find.byKey(optionsKey), findsNothing); + expect(tester.getSize(find.byKey(optionsKey)).width, greaterThan(0.0)); // Enter text. The options are filtered by the text. - focusNode.requestFocus(); textEditingController.value = const TextEditingValue( - text: 'example', - selection: TextSelection(baseOffset: 7, extentOffset: 7), + 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), kOptionsUsers[0]); - expect(lastOptions.elementAt(1), kOptionsUsers[1]); + expect(lastOptions.elementAt(0), 'chameleon'); + expect(lastOptions.elementAt(1), 'elephant'); - // Select an option. The options hide and onSelected is called. - final User selection = lastOptions.elementAt(1); + // 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(lastUserSelected, selection); - expect(textEditingController.text, selection.toString()); + expect(textEditingController.text, selection); - // Modify the field text. The options appear again and are filtered, this - // time by name instead of email. + // Modify the field text. The options appear again and are filtered. textEditingController.value = const TextEditingValue( - text: 'B', + text: 'e', 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]); + 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'); }); - 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 lastOptions; - late AutocompleteOnSelected lastOnSelected; - late User lastUserSelected; - String displayStringForOption(User option) => option.name; - late FocusNode focusNode; - late TextEditingController textEditingController; + 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 lastOptions; + late FocusNode focusNode; + late TextEditingController textEditingController; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: RawAutocomplete( - 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 onSelected, - Iterable options, - ) { - lastOptions = options; - lastOnSelected = onSelected; - return Container(key: optionsKey); - }, + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + const SizedBox(height: 200), + RawAutocomplete( + 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 onSelected, + Iterable 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)), + ); + }, + ), + ); + }, + ), + ], + ), ), ), - ), - ); + ); - expect(find.byKey(fieldKey), findsOneWidget); - expect(find.byKey(optionsKey), findsNothing); + // The field is always rendered, but the options are not unless needed. + 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]); + // 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( + optionsViewOpenDirection: openDirection, + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + optionsViewBuilder: ( + BuildContext context, + AutocompleteOnSelected onSelected, + Iterable 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( + 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 onSelected, + Iterable 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( + 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 onSelected, + Iterable 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(); + late Iterable lastOptions; + late AutocompleteOnSelected lastOnSelected; + late User lastUserSelected; + late FocusNode focusNode; + late TextEditingController textEditingController; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RawAutocomplete( + 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 onSelected, + Iterable 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 an option. The options hide and onSelected is called. The field - // has its text set to the selection's display string. + // Select an 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.name); + expect(textEditingController.text, selection.toString()); // Modify the field text. The options appear again and are filtered, this // time by name instead of email. @@ -391,23 +702,31 @@ void main() { expect(lastOptions.elementAt(0), kOptionsUsers[1]); }); - testWidgets('onFieldSubmitted selects the first option', (WidgetTester tester) async { + 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 lastOptions; - late VoidCallback lastOnFieldSubmitted; + late Iterable lastOptions; + late AutocompleteOnSelected lastOnSelected; + late User lastUserSelected; + String displayStringForOption(User option) => option.name; late FocusNode focusNode; late TextEditingController textEditingController; await tester.pumpWidget( MaterialApp( home: Scaffold( - body: RawAutocomplete( + body: RawAutocomplete( optionsBuilder: (TextEditingValue textEditingValue) { - return kOptions.where((String option) { - return option.contains(textEditingValue.text.toLowerCase()); + return kOptionsUsers.where((User option) { + return option.toString().contains(textEditingValue.text.toLowerCase()); }); }, + displayStringForOption: displayStringForOption, + onSelected: (User selected) { + lastUserSelected = selected; + }, fieldViewBuilder: ( BuildContext context, TextEditingController fieldTextEditingController, @@ -416,7 +735,6 @@ void main() { ) { textEditingController = fieldTextEditingController; focusNode = fieldFocusNode; - lastOnFieldSubmitted = onFieldSubmitted; return TextField( key: fieldKey, focusNode: focusNode, @@ -425,10 +743,11 @@ void main() { }, optionsViewBuilder: ( BuildContext context, - AutocompleteOnSelected onSelected, - Iterable options, + AutocompleteOnSelected onSelected, + Iterable options, ) { lastOptions = options; + lastOnSelected = onSelected; return Container(key: optionsKey); }, ), @@ -442,35 +761,119 @@ void main() { // Enter text. The options are filtered by the text. focusNode.requestFocus(); textEditingController.value = const TextEditingValue( - text: 'ele', - selection: TextSelection(baseOffset: 3, extentOffset: 3), + 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), 'chameleon'); - expect(lastOptions.elementAt(1), 'elephant'); + expect(lastOptions.elementAt(0), kOptionsUsers[0]); + expect(lastOptions.elementAt(1), kOptionsUsers[1]); - // Select the current string, as if the field was submitted. The options - // hide and the field updates to show the selection. - lastOnFieldSubmitted(); + // Select an 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(textEditingController.text, lastOptions.elementAt(0)); - }); + expect(lastUserSelected, selection); + expect(textEditingController.text, selection.name); - group('optionsViewOpenDirection', () { - testWidgets('unset (default behavior): open downward', (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Center( - child: RawAutocomplete( - optionsBuilder: (TextEditingValue textEditingValue) => ['a'], - fieldViewBuilder: ( - BuildContext context, + // 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 lastOptions; + late VoidCallback lastOnFieldSubmitted; + late FocusNode focusNode; + late TextEditingController textEditingController; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RawAutocomplete( + 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 onSelected, + Iterable 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)); + }); + + group('optionsViewOpenDirection', () { + testWidgets('unset (default behavior): open downward', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RawAutocomplete( + optionsBuilder: (TextEditingValue textEditingValue) => ['a'], + fieldViewBuilder: ( + BuildContext context, TextEditingController controller, FocusNode focusNode, VoidCallback onFieldSubmitted, @@ -490,6 +893,7 @@ void main() { ), ); 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 @@ void main() { ), ); 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 @@ void main() { ), ); 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 @@ void main() { ), ); 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 @@ void main() { ), ); 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 @@ void main() { 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 @@ void main() { 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( + 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 onSelected, + Iterable 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 @@ void main() { 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( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + optionsViewBuilder: ( + BuildContext context, + AutocompleteOnSelected onSelected, + Iterable 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 autocomplete = RawAutocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + optionsViewBuilder: ( + BuildContext context, + AutocompleteOnSelected onSelected, + Iterable 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 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( + optionsViewOpenDirection: optionsViewOpenDirection, + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + optionsViewBuilder: ( + BuildContext context, + AutocompleteOnSelected onSelected, + Iterable 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: [ + const SizedBox(height: 1000.0), + RawAutocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + optionsViewBuilder: ( + BuildContext context, + AutocompleteOnSelected onSelected, + Iterable 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: [ + const SizedBox(height: 1000.0), + RawAutocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + return kOptions.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + optionsViewBuilder: ( + BuildContext context, + AutocompleteOnSelected onSelected, + Iterable 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 {