From 8ab46bbce4d2c70559dafe1aeb55cea7fcd9a9f4 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Fri, 7 Jul 2023 10:25:35 -0700 Subject: [PATCH] (Raw)Autocomplete: Add optional [optionsViewOpenDirection] param (#129802) Allows positioning Autocomplete options above the field (previously hardcoded to under the field). --- .../lib/src/material/autocomplete.dart | 5 + .../flutter/lib/src/widgets/autocomplete.dart | 54 ++++++- .../test/material/autocomplete_test.dart | 49 +++++++ .../test/widgets/autocomplete_test.dart | 138 ++++++++++++++++++ 4 files changed, 240 insertions(+), 6 deletions(-) diff --git a/packages/flutter/lib/src/material/autocomplete.dart b/packages/flutter/lib/src/material/autocomplete.dart index 9f00633c9cc..c078018a6a0 100644 --- a/packages/flutter/lib/src/material/autocomplete.dart +++ b/packages/flutter/lib/src/material/autocomplete.dart @@ -66,6 +66,7 @@ class Autocomplete extends StatelessWidget { this.onSelected, this.optionsMaxHeight = 200.0, this.optionsViewBuilder, + this.optionsViewOpenDirection = OptionsViewOpenDirection.down, this.initialValue, }); @@ -90,6 +91,9 @@ class Autocomplete extends StatelessWidget { /// default. final AutocompleteOptionsViewBuilder? optionsViewBuilder; + /// {@macro flutter.widgets.RawAutocomplete.optionsViewOpenDirection} + final OptionsViewOpenDirection optionsViewOpenDirection; + /// The maximum height used for the default Material options list widget. /// /// When [optionsViewBuilder] is `null`, this property sets the maximum height @@ -116,6 +120,7 @@ class Autocomplete extends StatelessWidget { fieldViewBuilder: fieldViewBuilder, initialValue: initialValue, optionsBuilder: optionsBuilder, + optionsViewOpenDirection: optionsViewOpenDirection, optionsViewBuilder: optionsViewBuilder ?? (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { return _AutocompleteOptions( displayStringForOption: displayStringForOption, diff --git a/packages/flutter/lib/src/widgets/autocomplete.dart b/packages/flutter/lib/src/widgets/autocomplete.dart index 967c1160a2f..c5171ae50eb 100644 --- a/packages/flutter/lib/src/widgets/autocomplete.dart +++ b/packages/flutter/lib/src/widgets/autocomplete.dart @@ -77,6 +77,29 @@ typedef AutocompleteFieldViewBuilder = Widget Function( /// * [RawAutocomplete.displayStringForOption], which is of this type. typedef AutocompleteOptionToString = String Function(T option); +/// A direction in which to open the options-view overlay. +/// +/// See also: +/// +/// * [RawAutocomplete.optionsViewOpenDirection], which is of this type. +/// * [RawAutocomplete.optionsViewBuilder] to specify how to build the +/// selectable-options widget. +/// * [RawAutocomplete.fieldViewBuilder] to optionally specify how to build the +/// corresponding field widget. +enum OptionsViewOpenDirection { + /// Open upward. + /// + /// The bottom edge of the options view will align with the top edge + /// of the text field built by [RawAutocomplete.fieldViewBuilder]. + up, + + /// Open downward. + /// + /// The top edge of the options view will align with the bottom edge + /// of the text field built by [RawAutocomplete.fieldViewBuilder]. + down, +} + // TODO(justinmc): Mention AutocompleteCupertino when it is implemented. /// {@template flutter.widgets.RawAutocomplete.RawAutocomplete} /// A widget for helping the user make a selection by entering some text and @@ -128,6 +151,7 @@ class RawAutocomplete extends StatefulWidget { super.key, required this.optionsViewBuilder, required this.optionsBuilder, + this.optionsViewOpenDirection = OptionsViewOpenDirection.down, this.displayStringForOption = defaultStringForOption, this.fieldViewBuilder, this.focusNode, @@ -151,6 +175,9 @@ class RawAutocomplete extends StatefulWidget { /// Pass the provided [TextEditingController] to the field built here so that /// RawAutocomplete can listen for changes. /// {@endtemplate} + /// + /// If this parameter is null, then a [SizedBox.shrink] is built instead. + /// For how that pattern can be useful, see [textEditingController]. final AutocompleteFieldViewBuilder? fieldViewBuilder; /// The [FocusNode] that is used for the text field. @@ -161,9 +188,9 @@ class RawAutocomplete extends StatefulWidget { /// field built by [fieldViewBuilder]. For example, it may be desirable to /// place the text field in the AppBar and the options below in the main body. /// - /// When following this pattern, [fieldViewBuilder] can return - /// `SizedBox.shrink()` so that nothing is drawn where the text field would - /// normally be. A separate text field can be created elsewhere, and a + /// When following this pattern, [fieldViewBuilder] can be omitted, + /// so that a text field is not drawn where it would normally be. + /// A separate text field can be created elsewhere, and a /// FocusNode and TextEditingController can be passed both to that text field /// and to RawAutocomplete. /// @@ -182,9 +209,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 the field using a + /// 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]. + /// 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 @@ -197,6 +225,13 @@ class RawAutocomplete extends StatefulWidget { /// {@endtemplate} final AutocompleteOptionsViewBuilder optionsViewBuilder; + /// {@template flutter.widgets.RawAutocomplete.optionsViewOpenDirection} + /// The direction in which to open the options-view overlay. + /// + /// Defaults to [OptionsViewOpenDirection.down]. + /// {@endtemplate} + final OptionsViewOpenDirection optionsViewOpenDirection; + /// {@template flutter.widgets.RawAutocomplete.displayStringForOption} /// Returns the string to display in the field when the option is selected. /// @@ -421,7 +456,14 @@ class _RawAutocompleteState extends State> return CompositedTransformFollower( link: _optionsLayerLink, showWhenUnlinked: false, - targetAnchor: Alignment.bottomLeft, + targetAnchor: switch (widget.optionsViewOpenDirection) { + OptionsViewOpenDirection.up => Alignment.topLeft, + OptionsViewOpenDirection.down => Alignment.bottomLeft, + }, + followerAnchor: switch (widget.optionsViewOpenDirection) { + OptionsViewOpenDirection.up => Alignment.bottomLeft, + OptionsViewOpenDirection.down => Alignment.topLeft, + }, child: TextFieldTapRegion( child: AutocompleteHighlightedOption( highlightIndexNotifier: _highlightedOptionIndex, diff --git a/packages/flutter/test/material/autocomplete_test.dart b/packages/flutter/test/material/autocomplete_test.dart index ad33efd68c6..45f028bc022 100644 --- a/packages/flutter/test/material/autocomplete_test.dart +++ b/packages/flutter/test/material/autocomplete_test.dart @@ -507,4 +507,53 @@ void main() { checkOptionHighlight(tester, 'lemur', null); checkOptionHighlight(tester, 'northern white rhinoceros', null); }); + + group('optionsViewOpenDirection', () { + testWidgets('default (down)', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) => ['a'], + ), + ), + ), + ); + final OptionsViewOpenDirection actual = tester.widget>(find.byType(RawAutocomplete)) + .optionsViewOpenDirection; + expect(actual, equals(OptionsViewOpenDirection.down)); + }); + + testWidgets('down', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete( + optionsViewOpenDirection: OptionsViewOpenDirection.down, // ignore: avoid_redundant_argument_values + optionsBuilder: (TextEditingValue textEditingValue) => ['a'], + ), + ), + ), + ); + final OptionsViewOpenDirection actual = tester.widget>(find.byType(RawAutocomplete)) + .optionsViewOpenDirection; + expect(actual, equals(OptionsViewOpenDirection.down)); + }); + + testWidgets('up', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Autocomplete( + optionsViewOpenDirection: OptionsViewOpenDirection.up, + optionsBuilder: (TextEditingValue textEditingValue) => ['a'], + ), + ), + ), + ); + final OptionsViewOpenDirection actual = tester.widget>(find.byType(RawAutocomplete)) + .optionsViewOpenDirection; + expect(actual, equals(OptionsViewOpenDirection.up)); + }); + }); } diff --git a/packages/flutter/test/widgets/autocomplete_test.dart b/packages/flutter/test/widgets/autocomplete_test.dart index a35d324e5d4..cbedb27c769 100644 --- a/packages/flutter/test/widgets/autocomplete_test.dart +++ b/packages/flutter/test/widgets/autocomplete_test.dart @@ -421,6 +421,144 @@ void main() { expect(textEditingController.text, lastOptions.elementAt(0)); }); + group('optionsViewOpenDirection', () { + testWidgets('unset (default behavior): open downward', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RawAutocomplete( + optionsBuilder: (TextEditingValue textEditingValue) => ['a'], + fieldViewBuilder: (BuildContext context, TextEditingController controller, FocusNode focusNode, VoidCallback onFieldSubmitted) { + return TextField(controller: controller, focusNode: focusNode); + }, + optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + return const Text('a'); + }, + ), + ), + ), + ), + ); + await tester.showKeyboard(find.byType(TextField)); + expect(tester.getBottomLeft(find.byType(TextField)), + offsetMoreOrLessEquals(tester.getTopLeft(find.text('a')))); + }); + + testWidgets('down: open downward', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RawAutocomplete( + optionsViewOpenDirection: OptionsViewOpenDirection.down, // ignore: avoid_redundant_argument_values + optionsBuilder: (TextEditingValue textEditingValue) => ['a'], + fieldViewBuilder: (BuildContext context, TextEditingController controller, FocusNode focusNode, VoidCallback onFieldSubmitted) { + return TextField(controller: controller, focusNode: focusNode); + }, + optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + return const Text('a'); + }, + ), + ), + ), + ), + ); + await tester.showKeyboard(find.byType(TextField)); + expect(tester.getBottomLeft(find.byType(TextField)), + offsetMoreOrLessEquals(tester.getTopLeft(find.text('a')))); + }); + + testWidgets('up: open upward', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: RawAutocomplete( + optionsViewOpenDirection: OptionsViewOpenDirection.up, + optionsBuilder: (TextEditingValue textEditingValue) => ['a'], + fieldViewBuilder: (BuildContext context, TextEditingController controller, FocusNode focusNode, VoidCallback onFieldSubmitted) { + return TextField(controller: controller, focusNode: focusNode); + }, + optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + return const Text('a'); + }, + ), + ), + ), + ), + ); + await tester.showKeyboard(find.byType(TextField)); + expect(tester.getTopLeft(find.byType(TextField)), + offsetMoreOrLessEquals(tester.getBottomLeft(find.text('a')))); + }); + + group('fieldViewBuilder not passed', () { + testWidgets('down', (WidgetTester tester) async { + final GlobalKey autocompleteKey = GlobalKey(); + final TextEditingController controller = TextEditingController(); + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField(controller: controller, focusNode: focusNode), + RawAutocomplete( + key: autocompleteKey, + textEditingController: controller, + focusNode: focusNode, + optionsViewOpenDirection: OptionsViewOpenDirection.down, // ignore: avoid_redundant_argument_values + optionsBuilder: (TextEditingValue textEditingValue) => ['a'], + optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + return const Text('a'); + }, + ), + ], + ), + ), + ), + ); + await tester.showKeyboard(find.byType(TextField)); + expect(tester.getBottomLeft(find.byKey(autocompleteKey)), + offsetMoreOrLessEquals(tester.getTopLeft(find.text('a')))); + }); + + testWidgets('up', (WidgetTester tester) async { + final GlobalKey autocompleteKey = GlobalKey(); + final TextEditingController controller = TextEditingController(); + final FocusNode focusNode = FocusNode(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RawAutocomplete( + key: autocompleteKey, + textEditingController: controller, + focusNode: focusNode, + optionsViewOpenDirection: OptionsViewOpenDirection.up, + optionsBuilder: (TextEditingValue textEditingValue) => ['a'], + optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { + return const Text('a'); + }, + ), + TextField(controller: controller, focusNode: focusNode), + ], + ), + ), + ), + ); + await tester.showKeyboard(find.byType(TextField)); + expect(tester.getTopLeft(find.byKey(autocompleteKey)), + offsetMoreOrLessEquals(tester.getBottomLeft(find.text('a')))); + }); + }); + }); + testWidgets('options follow field when it moves', (WidgetTester tester) async { final GlobalKey fieldKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey();