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