Unverified Commit f3947ea0 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Autocomplete (Material) (#73753)

* Material Autocomplete widget
parent d9a69e3b
......@@ -22,6 +22,7 @@ export 'src/material/app.dart';
export 'src/material/app_bar.dart';
export 'src/material/app_bar_theme.dart';
export 'src/material/arc.dart';
export 'src/material/autocomplete.dart';
export 'src/material/back_button.dart';
export 'src/material/banner.dart';
export 'src/material/banner_theme.dart';
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/widgets.dart';
import 'ink_well.dart';
import 'material.dart';
import 'text_form_field.dart';
/// {@macro flutter.widgets.RawAutocomplete.RawAutocomplete}
///
/// {@tool dartpad --template=freeform}
/// This example shows how to create a very basic Autocomplete widget using the
/// default UI.
///
/// ```dart imports
/// import 'package:flutter/material.dart';
/// ```
///
/// ```dart
/// class AutocompleteBasicExample extends StatelessWidget {
/// AutocompleteBasicExample({Key? key}) : super(key: key);
///
/// final List<String> _kOptions = <String>[
/// 'aardvark',
/// 'bobcat',
/// 'chameleon',
/// ];
///
/// @override
/// Widget build(BuildContext context) {
/// return Autocomplete<String>(
/// optionsBuilder: (TextEditingValue textEditingValue) {
/// if (textEditingValue.text == '') {
/// return const Iterable<String>.empty();
/// }
/// return _kOptions.where((String option) {
/// return option.contains(textEditingValue.text.toLowerCase());
/// });
/// },
/// onSelected: (String selection) {
/// print('You just selected $selection');
/// },
/// );
/// }
/// }
/// ```
/// {@end-tool}
///
/// {@tool dartpad --template=freeform}
/// This example shows how to create an Autocomplete widget with a custom type.
/// Try searching with text from the name or email field.
///
/// ```dart imports
/// import 'package:flutter/material.dart';
/// ```
///
/// ```dart
/// class User {
/// const User({
/// required this.email,
/// required this.name,
/// });
///
/// final String email;
/// final String name;
///
/// @override
/// String toString() {
/// return '$name, $email';
/// }
///
/// @override
/// bool operator ==(Object other) {
/// if (other.runtimeType != runtimeType)
/// return false;
/// return other is User
/// && other.name == name
/// && other.email == email;
/// }
///
/// @override
/// int get hashCode => hashValues(email, name);
/// }
///
/// class AutocompleteBasicUserExample extends StatelessWidget {
/// AutocompleteBasicUserExample({Key? key}) : super(key: key);
///
/// static final List<User> _userOptions = <User>[
/// User(name: 'Alice', email: 'alice@example.com'),
/// User(name: 'Bob', email: 'bob@example.com'),
/// User(name: 'Charlie', email: 'charlie123@gmail.com'),
/// ];
///
/// static String _displayStringForOption(User option) => option.name;
///
/// @override
/// Widget build(BuildContext context) {
/// return Autocomplete<User>(
/// displayStringForOption: _displayStringForOption,
/// optionsBuilder: (TextEditingValue textEditingValue) {
/// if (textEditingValue.text == '') {
/// return const Iterable<User>.empty();
/// }
/// return _userOptions.where((User option) {
/// return option.toString().contains(textEditingValue.text.toLowerCase());
/// });
/// },
/// onSelected: (User selection) {
/// print('You just selected ${_displayStringForOption(selection)}');
/// },
/// );
/// }
/// }
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [RawAutocomplete], which is what Autocomplete is built upon, and which
/// contains more detailed examples.
class Autocomplete<T extends Object> extends StatelessWidget {
/// Creates an instance of [Autocomplete].
const Autocomplete({
Key? key,
required this.optionsBuilder,
this.displayStringForOption = RawAutocomplete.defaultStringForOption,
this.fieldViewBuilder = _defaultFieldViewBuilder,
this.onSelected,
this.optionsViewBuilder,
}) : assert(displayStringForOption != null),
assert(optionsBuilder != null),
super(key: key);
/// {@macro flutter.widgets.RawAutocomplete.displayStringForOption}
final AutocompleteOptionToString<T> displayStringForOption;
/// {@macro flutter.widgets.RawAutocomplete.fieldViewBuilder}
///
/// If not provided, will build a standard Material-style text field by
/// default.
final AutocompleteFieldViewBuilder fieldViewBuilder;
/// {@macro flutter.widgets.RawAutocomplete.onSelected}
final AutocompleteOnSelected<T>? onSelected;
/// {@macro flutter.widgets.RawAutocomplete.optionsBuilder}
final AutocompleteOptionsBuilder<T> optionsBuilder;
/// {@macro flutter.widgets.RawAutocomplete.optionsViewBuilder}
///
/// If not provided, will build a standard Material-style list of results by
/// default.
final AutocompleteOptionsViewBuilder<T>? optionsViewBuilder;
static Widget _defaultFieldViewBuilder(BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) {
return _AutocompleteField(
focusNode: focusNode,
textEditingController: textEditingController,
onFieldSubmitted: onFieldSubmitted,
);
}
@override
Widget build(BuildContext context) {
return RawAutocomplete<T>(
displayStringForOption: displayStringForOption,
fieldViewBuilder: fieldViewBuilder,
optionsBuilder: optionsBuilder,
optionsViewBuilder: optionsViewBuilder ?? (BuildContext context, AutocompleteOnSelected<T> onSelected, Iterable<T> options) {
return _AutocompleteOptions<T>(
displayStringForOption: displayStringForOption,
onSelected: onSelected,
options: options,
);
},
onSelected: onSelected,
);
}
}
// The default Material-style Autocomplete text field.
class _AutocompleteField extends StatelessWidget {
const _AutocompleteField({
Key? key,
required this.focusNode,
required this.textEditingController,
required this.onFieldSubmitted,
}) : super(key: key);
final FocusNode focusNode;
final VoidCallback onFieldSubmitted;
final TextEditingController textEditingController;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
onFieldSubmitted: (String value) {
onFieldSubmitted();
},
);
}
}
// The default Material-style Autocomplete options.
class _AutocompleteOptions<T extends Object> extends StatelessWidget {
const _AutocompleteOptions({
Key? key,
required this.displayStringForOption,
required this.onSelected,
required this.options,
}) : super(key: key);
final AutocompleteOptionToString<T> displayStringForOption;
final AutocompleteOnSelected<T> onSelected;
final Iterable<T> options;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4.0,
child: Container(
height: 200.0,
child: ListView.builder(
padding: const EdgeInsets.all(0.0),
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final T option = options.elementAt(index);
return InkWell(
onTap: () {
onSelected(option);
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(displayStringForOption(option)),
),
);
},
),
),
),
);
}
}
......@@ -16,6 +16,7 @@ import 'overlay.dart';
/// entered so far.
///
/// See also:
///
/// * [RawAutocomplete.optionsBuilder], which is of this type.
typedef AutocompleteOptionsBuilder<T extends Object> = Iterable<T> Function(TextEditingValue textEditingValue);
......@@ -23,6 +24,7 @@ typedef AutocompleteOptionsBuilder<T extends Object> = Iterable<T> Function(Text
/// that the user has selected an option.
///
/// See also:
///
/// * [RawAutocomplete.onSelected], which is of this type.
typedef AutocompleteOnSelected<T extends Object> = void Function(T option);
......@@ -31,6 +33,7 @@ typedef AutocompleteOnSelected<T extends Object> = void Function(T option);
/// selects an option.
///
/// See also:
///
/// * [RawAutocomplete.optionsViewBuilder], which is of this type.
typedef AutocompleteOptionsViewBuilder<T extends Object> = Widget Function(
BuildContext context,
......@@ -42,6 +45,7 @@ typedef AutocompleteOptionsViewBuilder<T extends Object> = Widget Function(
/// contains the input [TextField] or [TextFormField].
///
/// See also:
///
/// * [RawAutocomplete.fieldViewBuilder], which is of this type.
typedef AutocompleteFieldViewBuilder = Widget Function(
BuildContext context,
......@@ -54,19 +58,21 @@ typedef AutocompleteFieldViewBuilder = Widget Function(
/// a string which can be displayed in the widget's options menu.
///
/// See also:
///
/// * [RawAutocomplete.displayStringForOption], which is of this type.
typedef AutocompleteOptionToString<T extends Object> = String Function(T option);
// TODO(justinmc): Mention Autocomplete and AutocompleteCupertino when they are
// implemented.
// 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
/// choosing from among a list of options.
///
/// This is a core framework widget with very basic UI.
///
/// The user's text input is received in a field built with the
/// [fieldViewBuilder] parameter. The options to be displayed are determined
/// using [optionsBuilder] and rendered with [optionsViewBuilder].
/// {@endtemplate}
///
/// This is a core framework widget with very basic UI.
///
/// {@tool dartpad --template=freeform}
/// This example shows how to create a very basic autocomplete widget using the
......@@ -419,6 +425,11 @@ typedef AutocompleteOptionToString<T extends Object> = String Function(T option)
/// }
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [Autocomplete], which is a Material-styled implementation that is based
/// on RawAutocomplete.
class RawAutocomplete<T extends Object> extends StatefulWidget {
/// Create an instance of RawAutocomplete.
///
......@@ -428,7 +439,7 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
Key? key,
required this.optionsViewBuilder,
required this.optionsBuilder,
this.displayStringForOption = _defaultStringForOption,
this.displayStringForOption = defaultStringForOption,
this.fieldViewBuilder,
this.focusNode,
this.onSelected,
......@@ -444,10 +455,12 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
assert((focusNode == null) == (textEditingController == null)),
super(key: key);
/// {@template flutter.widgets.RawAutocomplete.fieldViewBuilder}
/// Builds the field whose input is used to get the options.
///
/// Pass the provided [TextEditingController] to the field built here so that
/// RawAutocomplete can listen for changes.
/// {@endtemplate}
final AutocompleteFieldViewBuilder? fieldViewBuilder;
/// The [FocusNode] that is used for the text field.
......@@ -552,30 +565,38 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
/// not null.
final FocusNode? focusNode;
/// {@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
/// [CompositedTransformFollower] inside of an [Overlay], not at the same
/// place in the widget tree as RawAutocomplete.
/// place in the widget tree as [RawAutocomplete].
/// {@endtemplate}
final AutocompleteOptionsViewBuilder<T> optionsViewBuilder;
/// {@template flutter.widgets.RawAutocomplete.displayStringForOption}
/// Returns the string to display in the field when the option is selected.
///
/// This is useful when using a custom T type and the string to display is
/// different than the string to search by.
///
/// If not provided, will use `option.toString()`.
/// {@endtemplate}
final AutocompleteOptionToString<T> displayStringForOption;
/// {@template flutter.widgets.RawAutocomplete.onSelected}
/// Called when an option is selected by the user.
///
/// Any [TextEditingController] listeners will not be called when the user
/// selects an option, even though the field will update with the selected
/// value, so use this to be informed of selection.
/// {@endtemplate}
final AutocompleteOnSelected<T>? onSelected;
/// {@template flutter.widgets.RawAutocomplete.optionsBuilder}
/// A function that returns the current selectable options objects given the
/// current TextEditingValue.
/// {@endtemplate}
final AutocompleteOptionsBuilder<T> optionsBuilder;
/// The [TextEditingController] that is used for the text field.
......@@ -603,8 +624,11 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
rawAutocomplete._onFieldSubmitted();
}
// The default way to convert an option to a string.
static String _defaultStringForOption(dynamic option) {
/// The default way to convert an option to a string in
/// [displayStringForOption].
///
/// Simply uses the `toString` method on the option.
static String defaultStringForOption(dynamic option) {
return option.toString();
}
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
class User {
const User({
required this.email,
required this.name,
});
final String email;
final String name;
@override
String toString() {
return '$name, $email';
}
}
void main() {
const List<String> kOptions = <String>[
'aardvark',
'bobcat',
'chameleon',
'dingo',
'elephant',
'flamingo',
'goose',
'hippopotamus',
'iguana',
'jaguar',
'koala',
'lemur',
'mouse',
'northern white rhinocerous',
];
const List<User> kOptionsUsers = <User>[
User(name: 'Alice', email: 'alice@example.com'),
User(name: 'Bob', email: 'bob@example.com'),
User(name: 'Charlie', email: 'charlie123@gmail.com'),
];
testWidgets('can filter and select a list of string options', (WidgetTester tester) async {
late String lastSelection;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Autocomplete<String>(
onSelected: (String selection) {
lastSelection = selection;
},
optionsBuilder: (TextEditingValue textEditingValue) {
return kOptions.where((String option) {
return option.contains(textEditingValue.text.toLowerCase());
});
},
),
),
),
);
// The field is always rendered, but the options are not unless needed.
expect(find.byType(TextFormField), findsOneWidget);
expect(find.byType(ListView), findsNothing);
// Focus the empty field. All the options are displayed.
await tester.tap(find.byType(TextFormField));
await tester.pump();
expect(find.byType(ListView), findsOneWidget);
ListView list = find.byType(ListView).evaluate().first.widget as ListView;
expect(list.semanticChildCount, kOptions.length);
// Enter text. The options are filtered by the text.
await tester.enterText(find.byType(TextFormField), 'ele');
await tester.pump();
expect(find.byType(TextFormField), findsOneWidget);
expect(find.byType(ListView), findsOneWidget);
list = find.byType(ListView).evaluate().first.widget as ListView;
// 'chameleon' and 'elephant' are displayed.
expect(list.semanticChildCount, 2);
// Select a option. The options hide and the field updates to show the
// selection.
await tester.tap(find.byType(InkWell).first);
await tester.pump();
expect(find.byType(TextFormField), findsOneWidget);
expect(find.byType(ListView), findsNothing);
final TextFormField field = find.byType(TextFormField).evaluate().first.widget as TextFormField;
expect(field.controller!.text, 'chameleon');
expect(lastSelection, 'chameleon');
// Modify the field text. The options appear again and are filtered.
await tester.enterText(find.byType(TextFormField), 'e');
await tester.pump();
expect(find.byType(TextFormField), findsOneWidget);
expect(find.byType(ListView), findsOneWidget);
list = find.byType(ListView).evaluate().first.widget as ListView;
// 'chameleon', 'elephant', 'goose', 'lemur', 'mouse', and
// 'northern white rhinocerous' are displayed.
expect(list.semanticChildCount, 6);
});
testWidgets('can filter and select a list of custom User options', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Autocomplete<User>(
optionsBuilder: (TextEditingValue textEditingValue) {
return kOptionsUsers.where((User option) {
return option.toString().contains(textEditingValue.text.toLowerCase());
});
},
),
),
),
);
// The field is always rendered, but the options are not unless needed.
expect(find.byType(TextFormField), findsOneWidget);
expect(find.byType(ListView), findsNothing);
// Focus the empty field. All the options are displayed.
await tester.tap(find.byType(TextFormField));
await tester.pump();
expect(find.byType(ListView), findsOneWidget);
ListView list = find.byType(ListView).evaluate().first.widget as ListView;
expect(list.semanticChildCount, kOptionsUsers.length);
// Enter text. The options are filtered by the text.
await tester.enterText(find.byType(TextFormField), 'example');
await tester.pump();
expect(find.byType(TextFormField), findsOneWidget);
expect(find.byType(ListView), findsOneWidget);
list = find.byType(ListView).evaluate().first.widget as ListView;
// 'Alice' and 'Bob' are displayed because they have "example.com" emails.
expect(list.semanticChildCount, 2);
// Select a option. The options hide and the field updates to show the
// selection.
await tester.tap(find.byType(InkWell).first);
await tester.pump();
expect(find.byType(TextFormField), findsOneWidget);
expect(find.byType(ListView), findsNothing);
final TextFormField field = find.byType(TextFormField).evaluate().first.widget as TextFormField;
expect(field.controller!.text, 'Alice, alice@example.com');
// Modify the field text. The options appear again and are filtered.
await tester.enterText(find.byType(TextFormField), 'B');
await tester.pump();
expect(find.byType(TextFormField), findsOneWidget);
expect(find.byType(ListView), findsOneWidget);
list = find.byType(ListView).evaluate().first.widget as ListView;
// 'Bob' is displayed.
expect(list.semanticChildCount, 1);
});
testWidgets('displayStringForOption is displayed in the options', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Autocomplete<User>(
displayStringForOption: (User option) {
return option.name;
},
optionsBuilder: (TextEditingValue textEditingValue) {
return kOptionsUsers.where((User option) {
return option.toString().contains(textEditingValue.text.toLowerCase());
});
},
),
),
),
);
// The field is always rendered, but the options are not unless needed.
expect(find.byType(TextFormField), findsOneWidget);
expect(find.byType(ListView), findsNothing);
// Focus the empty field. All the options are displayed, and the string that
// is used comes from displayStringForOption.
await tester.tap(find.byType(TextFormField));
await tester.pump();
expect(find.byType(ListView), findsOneWidget);
final ListView list = find.byType(ListView).evaluate().first.widget as ListView;
expect(list.semanticChildCount, kOptionsUsers.length);
for (int i = 0; i < kOptionsUsers.length; i++) {
expect(find.text(kOptionsUsers[i].name), findsOneWidget);
}
// Select a option. The options hide and the field updates to show the
// selection. The text in the field is given by displayStringForOption.
await tester.tap(find.byType(InkWell).first);
await tester.pump();
expect(find.byType(TextFormField), findsOneWidget);
expect(find.byType(ListView), findsNothing);
final TextFormField field = find.byType(TextFormField).evaluate().first.widget as TextFormField;
expect(field.controller!.text, kOptionsUsers.first.name);
});
testWidgets('can build a custom field', (WidgetTester tester) async {
final GlobalKey fieldKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Autocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) {
return kOptions.where((String option) {
return option.contains(textEditingValue.text.toLowerCase());
});
},
fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) {
return Container(key: fieldKey);
},
),
),
),
);
// The custom field is rendered and not the default TextFormField.
expect(find.byKey(fieldKey), findsOneWidget);
expect(find.byType(TextFormField), findsNothing);
});
testWidgets('can build custom options', (WidgetTester tester) async {
final GlobalKey optionsKey = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Autocomplete<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);
},
),
),
),
);
// The default field is rendered but not the options, yet.
expect(find.byKey(optionsKey), findsNothing);
expect(find.byType(TextFormField), findsOneWidget);
// Focus the empty field. The custom options is displayed.
await tester.tap(find.byType(TextFormField));
await tester.pump();
expect(find.byKey(optionsKey), findsOneWidget);
});
}
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