Unverified Commit 8ab46bbc authored by Chris Bobbe's avatar Chris Bobbe Committed by GitHub

(Raw)Autocomplete: Add optional [optionsViewOpenDirection] param (#129802)

Allows positioning Autocomplete options above the field (previously hardcoded to under the field).
parent 61ebf755
...@@ -66,6 +66,7 @@ class Autocomplete<T extends Object> extends StatelessWidget { ...@@ -66,6 +66,7 @@ class Autocomplete<T extends Object> extends StatelessWidget {
this.onSelected, this.onSelected,
this.optionsMaxHeight = 200.0, this.optionsMaxHeight = 200.0,
this.optionsViewBuilder, this.optionsViewBuilder,
this.optionsViewOpenDirection = OptionsViewOpenDirection.down,
this.initialValue, this.initialValue,
}); });
...@@ -90,6 +91,9 @@ class Autocomplete<T extends Object> extends StatelessWidget { ...@@ -90,6 +91,9 @@ class Autocomplete<T extends Object> extends StatelessWidget {
/// default. /// default.
final AutocompleteOptionsViewBuilder<T>? optionsViewBuilder; final AutocompleteOptionsViewBuilder<T>? optionsViewBuilder;
/// {@macro flutter.widgets.RawAutocomplete.optionsViewOpenDirection}
final OptionsViewOpenDirection optionsViewOpenDirection;
/// The maximum height used for the default Material options list widget. /// The maximum height used for the default Material options list widget.
/// ///
/// When [optionsViewBuilder] is `null`, this property sets the maximum height /// When [optionsViewBuilder] is `null`, this property sets the maximum height
...@@ -116,6 +120,7 @@ class Autocomplete<T extends Object> extends StatelessWidget { ...@@ -116,6 +120,7 @@ class Autocomplete<T extends Object> extends StatelessWidget {
fieldViewBuilder: fieldViewBuilder, fieldViewBuilder: fieldViewBuilder,
initialValue: initialValue, initialValue: initialValue,
optionsBuilder: optionsBuilder, optionsBuilder: optionsBuilder,
optionsViewOpenDirection: optionsViewOpenDirection,
optionsViewBuilder: optionsViewBuilder ?? (BuildContext context, AutocompleteOnSelected<T> onSelected, Iterable<T> options) { optionsViewBuilder: optionsViewBuilder ?? (BuildContext context, AutocompleteOnSelected<T> onSelected, Iterable<T> options) {
return _AutocompleteOptions<T>( return _AutocompleteOptions<T>(
displayStringForOption: displayStringForOption, displayStringForOption: displayStringForOption,
......
...@@ -77,6 +77,29 @@ typedef AutocompleteFieldViewBuilder = Widget Function( ...@@ -77,6 +77,29 @@ typedef AutocompleteFieldViewBuilder = Widget Function(
/// * [RawAutocomplete.displayStringForOption], which is of this type. /// * [RawAutocomplete.displayStringForOption], which is of this type.
typedef AutocompleteOptionToString<T extends Object> = String Function(T option); typedef AutocompleteOptionToString<T extends Object> = 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. // TODO(justinmc): Mention AutocompleteCupertino when it is implemented.
/// {@template flutter.widgets.RawAutocomplete.RawAutocomplete} /// {@template flutter.widgets.RawAutocomplete.RawAutocomplete}
/// A widget for helping the user make a selection by entering some text and /// A widget for helping the user make a selection by entering some text and
...@@ -128,6 +151,7 @@ class RawAutocomplete<T extends Object> extends StatefulWidget { ...@@ -128,6 +151,7 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
super.key, super.key,
required this.optionsViewBuilder, required this.optionsViewBuilder,
required this.optionsBuilder, required this.optionsBuilder,
this.optionsViewOpenDirection = OptionsViewOpenDirection.down,
this.displayStringForOption = defaultStringForOption, this.displayStringForOption = defaultStringForOption,
this.fieldViewBuilder, this.fieldViewBuilder,
this.focusNode, this.focusNode,
...@@ -151,6 +175,9 @@ class RawAutocomplete<T extends Object> extends StatefulWidget { ...@@ -151,6 +175,9 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
/// Pass the provided [TextEditingController] to the field built here so that /// Pass the provided [TextEditingController] to the field built here so that
/// RawAutocomplete can listen for changes. /// RawAutocomplete can listen for changes.
/// {@endtemplate} /// {@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; final AutocompleteFieldViewBuilder? fieldViewBuilder;
/// The [FocusNode] that is used for the text field. /// The [FocusNode] that is used for the text field.
...@@ -161,9 +188,9 @@ class RawAutocomplete<T extends Object> extends StatefulWidget { ...@@ -161,9 +188,9 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
/// field built by [fieldViewBuilder]. For example, it may be desirable to /// 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. /// place the text field in the AppBar and the options below in the main body.
/// ///
/// When following this pattern, [fieldViewBuilder] can return /// When following this pattern, [fieldViewBuilder] can be omitted,
/// `SizedBox.shrink()` so that nothing is drawn where the text field would /// so that a text field is not drawn where it would normally be.
/// normally be. A separate text field can be created elsewhere, and a /// A separate text field can be created elsewhere, and a
/// FocusNode and TextEditingController can be passed both to that text field /// FocusNode and TextEditingController can be passed both to that text field
/// and to RawAutocomplete. /// and to RawAutocomplete.
/// ///
...@@ -182,9 +209,10 @@ class RawAutocomplete<T extends Object> extends StatefulWidget { ...@@ -182,9 +209,10 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
/// {@template flutter.widgets.RawAutocomplete.optionsViewBuilder} /// {@template flutter.widgets.RawAutocomplete.optionsViewBuilder}
/// Builds the selectable options widgets from a list of options objects. /// 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 /// [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 /// In order to track which item is highlighted by keyboard navigation, the
/// resulting options will be wrapped in an inherited /// resulting options will be wrapped in an inherited
...@@ -197,6 +225,13 @@ class RawAutocomplete<T extends Object> extends StatefulWidget { ...@@ -197,6 +225,13 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
/// {@endtemplate} /// {@endtemplate}
final AutocompleteOptionsViewBuilder<T> optionsViewBuilder; final AutocompleteOptionsViewBuilder<T> 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} /// {@template flutter.widgets.RawAutocomplete.displayStringForOption}
/// Returns the string to display in the field when the option is selected. /// Returns the string to display in the field when the option is selected.
/// ///
...@@ -421,7 +456,14 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> ...@@ -421,7 +456,14 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
return CompositedTransformFollower( return CompositedTransformFollower(
link: _optionsLayerLink, link: _optionsLayerLink,
showWhenUnlinked: false, 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: TextFieldTapRegion(
child: AutocompleteHighlightedOption( child: AutocompleteHighlightedOption(
highlightIndexNotifier: _highlightedOptionIndex, highlightIndexNotifier: _highlightedOptionIndex,
......
...@@ -507,4 +507,53 @@ void main() { ...@@ -507,4 +507,53 @@ void main() {
checkOptionHighlight(tester, 'lemur', null); checkOptionHighlight(tester, 'lemur', null);
checkOptionHighlight(tester, 'northern white rhinoceros', null); checkOptionHighlight(tester, 'northern white rhinoceros', null);
}); });
group('optionsViewOpenDirection', () {
testWidgets('default (down)', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Autocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) => <String>['a'],
),
),
),
);
final OptionsViewOpenDirection actual = tester.widget<RawAutocomplete<String>>(find.byType(RawAutocomplete<String>))
.optionsViewOpenDirection;
expect(actual, equals(OptionsViewOpenDirection.down));
});
testWidgets('down', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Autocomplete<String>(
optionsViewOpenDirection: OptionsViewOpenDirection.down, // ignore: avoid_redundant_argument_values
optionsBuilder: (TextEditingValue textEditingValue) => <String>['a'],
),
),
),
);
final OptionsViewOpenDirection actual = tester.widget<RawAutocomplete<String>>(find.byType(RawAutocomplete<String>))
.optionsViewOpenDirection;
expect(actual, equals(OptionsViewOpenDirection.down));
});
testWidgets('up', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Autocomplete<String>(
optionsViewOpenDirection: OptionsViewOpenDirection.up,
optionsBuilder: (TextEditingValue textEditingValue) => <String>['a'],
),
),
),
);
final OptionsViewOpenDirection actual = tester.widget<RawAutocomplete<String>>(find.byType(RawAutocomplete<String>))
.optionsViewOpenDirection;
expect(actual, equals(OptionsViewOpenDirection.up));
});
});
} }
...@@ -421,6 +421,144 @@ void main() { ...@@ -421,6 +421,144 @@ void main() {
expect(textEditingController.text, lastOptions.elementAt(0)); 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<String>(
optionsBuilder: (TextEditingValue textEditingValue) => <String>['a'],
fieldViewBuilder: (BuildContext context, TextEditingController controller, FocusNode focusNode, VoidCallback onFieldSubmitted) {
return TextField(controller: controller, focusNode: focusNode);
},
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> 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<String>(
optionsViewOpenDirection: OptionsViewOpenDirection.down, // ignore: avoid_redundant_argument_values
optionsBuilder: (TextEditingValue textEditingValue) => <String>['a'],
fieldViewBuilder: (BuildContext context, TextEditingController controller, FocusNode focusNode, VoidCallback onFieldSubmitted) {
return TextField(controller: controller, focusNode: focusNode);
},
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> 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<String>(
optionsViewOpenDirection: OptionsViewOpenDirection.up,
optionsBuilder: (TextEditingValue textEditingValue) => <String>['a'],
fieldViewBuilder: (BuildContext context, TextEditingController controller, FocusNode focusNode, VoidCallback onFieldSubmitted) {
return TextField(controller: controller, focusNode: focusNode);
},
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> 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: <Widget>[
TextField(controller: controller, focusNode: focusNode),
RawAutocomplete<String>(
key: autocompleteKey,
textEditingController: controller,
focusNode: focusNode,
optionsViewOpenDirection: OptionsViewOpenDirection.down, // ignore: avoid_redundant_argument_values
optionsBuilder: (TextEditingValue textEditingValue) => <String>['a'],
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> 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: <Widget>[
RawAutocomplete<String>(
key: autocompleteKey,
textEditingController: controller,
focusNode: focusNode,
optionsViewOpenDirection: OptionsViewOpenDirection.up,
optionsBuilder: (TextEditingValue textEditingValue) => <String>['a'],
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> 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 { testWidgets('options follow field when it moves', (WidgetTester tester) async {
final GlobalKey fieldKey = GlobalKey(); final GlobalKey fieldKey = GlobalKey();
final GlobalKey optionsKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment