From 1b0441c18ae0266ec62a66d846b6b38debc8f772 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 13 Jan 2025 17:29:08 -0800 Subject: [PATCH] Autocomplete Options Width (#143249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Problem | Before | After | | --- | --- | --- | | Width | Screenshot 2024-02-09 at 1 29 09 PM | Screenshot 2024-02-09 at 1 23 59 PM | | Overflow | ![Screenshot from 2024-06-07 13-39-45](https://github.com/flutter/flutter/assets/389558/8a24c87a-2b5e-4bdc-8347-339d850f5a82) | ![Screenshot from 2024-06-07 13-38-26](https://github.com/flutter/flutter/assets/389558/735248aa-8969-413b-a6cf-4f9b708f9ea8) | Fixes https://github.com/flutter/flutter/issues/78746 Fixes https://github.com/flutter/flutter/issues/92851 Part of https://github.com/flutter/flutter/issues/101620 Fixes https://github.com/flutter/flutter/issues/147483 Fixes https://github.com/flutter/flutter/issues/153274 Part of Google b/317115348 Fixes optionsViewOpenDirection not working, mentioned in https://github.com/flutter/flutter/pull/143249#issuecomment-2036191457. ### Requirements * [x] By default, the width of the options matches the width of the field. * [x] Options can be aligned to the start or end for LTR/RTL languages. * [x] The optionsViewOpenDirection parameter is respected. * [x] If the options would vertically exceed the top or bottom of the screen, they reposition themselves to fit on the screen while covering the field. At least enough to tap an option. This has accessibility implications, because sometimes an Autocomplete near the edge of a narrow screen can be unusable. * [x] If the Autocomplete is in a ScrollView, then the options move along with the field during scrolling. * [x] When the field moves or resizes, the options position and size change to match. Even if the field is animated. * [ ] The options layout updates on the same frame that the field layout changes. It's probably not possible to check all of these boxes so we'll probably need to compromise. #### Tools that I've used to try to achieve this * LayoutBuilder to provide the field constraints[^1]. * Looking up layout information of a widget via GlobalKey. * CompositedTransformFollower/Target. * CustomSingleChildLayout. [^1]: Originally this didn't work due to a bug when using LayoutBuilder with OverlayPortal (https://github.com/flutter/flutter/pull/147856). That has now been fixed. Co-authored-by: Victor Sanni --- .../lib/src/material/autocomplete.dart | 8 +- .../flutter/lib/src/widgets/autocomplete.dart | 276 +++- .../test/material/autocomplete_test.dart | 1 + .../test/widgets/autocomplete_test.dart | 1336 ++++++++++++++++- 4 files changed, 1523 insertions(+), 98 deletions(-) diff --git a/packages/flutter/lib/src/material/autocomplete.dart b/packages/flutter/lib/src/material/autocomplete.dart index 6f3ca62653..d917393cd4 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 8cd23f5635..958ce9e4ae 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 a085b64d99..3b7c72f6f9 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 0333d8b7d0..c32b120a32 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,20 +209,412 @@ 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); + 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 lastOptions; + late FocusNode focusNode; + late TextEditingController textEditingController; + + 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)), + ); + }, + ), + ); + }, + ), + ], + ), + ), + ), + ); + + // 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( + 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(); @@ -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 {