AutocompleteCore (#62927)
A new widget that chooses an item from a list based on text input. Just the core widget, with Material and Cupertino versions to come.
This commit is contained in:
parent
4b017b6220
commit
291ee94506
600
packages/flutter/lib/src/widgets/autocomplete.dart
Normal file
600
packages/flutter/lib/src/widgets/autocomplete.dart
Normal file
@ -0,0 +1,600 @@
|
||||
// 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/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import 'basic.dart';
|
||||
import 'container.dart';
|
||||
import 'editable_text.dart';
|
||||
import 'focus_manager.dart';
|
||||
import 'framework.dart';
|
||||
import 'overlay.dart';
|
||||
|
||||
/// The type of the [AutocompleteCore] callback which computes the list of
|
||||
/// optional completions for the widget's field based on the text the user has
|
||||
/// entered so far.
|
||||
///
|
||||
/// See also:
|
||||
/// * [AutocompleteCore.optionsBuilder], which is of this type.
|
||||
typedef AutocompleteOptionsBuilder<T extends Object> = Iterable<T> Function(TextEditingValue textEditingValue);
|
||||
|
||||
/// The type of the callback used by the [AutocompleteCore] widget to indicate
|
||||
/// that the user has selected an option.
|
||||
///
|
||||
/// See also:
|
||||
/// * [AutocompleteCore.onSelected], which is of this type.
|
||||
typedef AutocompleteOnSelected<T extends Object> = void Function(T option);
|
||||
|
||||
/// The type of the [AutocompleteCore] callback which returns a [Widget] that
|
||||
/// displays the specified [options] and calls [onSelected] if the user
|
||||
/// selects an option.
|
||||
///
|
||||
/// See also:
|
||||
/// * [AutocompleteCore.optionsViewBuilder], which is of this type.
|
||||
typedef AutocompleteOptionsViewBuilder<T extends Object> = Widget Function(
|
||||
BuildContext context,
|
||||
AutocompleteOnSelected<T> onSelected,
|
||||
Iterable<T> options,
|
||||
);
|
||||
|
||||
/// The type of the Autocomplete callback which returns the widget that
|
||||
/// contains the input [TextField] or [TextFormField].
|
||||
///
|
||||
/// See also:
|
||||
/// * [AutocompleteCore.fieldViewBuilder], which is of this type.
|
||||
typedef AutocompleteFieldViewBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
TextEditingController textEditingController,
|
||||
FocusNode focusNode,
|
||||
VoidCallback onFieldSubmitted,
|
||||
);
|
||||
|
||||
/// The type of the [AutocompleteCore] callback that converts an option value to
|
||||
/// a string which can be displayed in the widget's options menu.
|
||||
///
|
||||
/// See also:
|
||||
/// * [AutocompleteCore.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.
|
||||
/// 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].
|
||||
///
|
||||
/// {@tool dartpad --template=freeform}
|
||||
/// This example shows how to create a very basic autocomplete widget using the
|
||||
/// [fieldViewBuilder] and [optionsViewBuilder] parameters.
|
||||
///
|
||||
/// ```dart imports
|
||||
/// import 'package:flutter/widgets.dart';
|
||||
/// import 'package:flutter/material.dart';
|
||||
/// ```
|
||||
///
|
||||
/// ```dart
|
||||
/// class AutocompleteBasicExample extends StatelessWidget {
|
||||
/// AutocompleteBasicExample({Key key}) : super(key: key);
|
||||
///
|
||||
/// static final List<String> _options = <String>[
|
||||
/// 'aardvark',
|
||||
/// 'bobcat',
|
||||
/// 'chameleon',
|
||||
/// ];
|
||||
///
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return AutocompleteCore<String>(
|
||||
/// optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
/// return _options.where((String option) {
|
||||
/// return option.contains(textEditingValue.text.toLowerCase());
|
||||
/// });
|
||||
/// },
|
||||
/// fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) {
|
||||
/// return TextFormField(
|
||||
/// controller: textEditingController,
|
||||
/// focusNode: focusNode,
|
||||
/// onFieldSubmitted: (String value) {
|
||||
/// onFieldSubmitted();
|
||||
/// },
|
||||
/// );
|
||||
/// },
|
||||
/// optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
|
||||
/// return Align(
|
||||
/// alignment: Alignment.topLeft,
|
||||
/// child: Material(
|
||||
/// elevation: 4.0,
|
||||
/// child: Container(
|
||||
/// height: 200.0,
|
||||
/// child: ListView.builder(
|
||||
/// padding: 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),
|
||||
/// ),
|
||||
/// );
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// },
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// The type parameter T represents the type of the options. Most commonly this
|
||||
/// is a String, as in the example above. However, it's also possible to use
|
||||
/// another type with a `toString` method, or a custom [displayStringForOption].
|
||||
/// Options will be compared using `==`, so it may be beneficial to override
|
||||
/// [Object.==] and [Object.hashCode] for custom types.
|
||||
///
|
||||
/// {@tool dartpad --template=freeform}
|
||||
/// This example is similar to the previous example, but it uses a custom T data
|
||||
/// type instead of directly using String.
|
||||
///
|
||||
/// ```dart imports
|
||||
/// import 'package:flutter/widgets.dart';
|
||||
/// import 'package:flutter/material.dart';
|
||||
/// ```
|
||||
///
|
||||
/// ```dart
|
||||
/// // An example of a type that someone might want to autocomplete a list of.
|
||||
/// class User {
|
||||
/// const User({
|
||||
/// this.email,
|
||||
/// 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 AutocompleteCustomTypeExample extends StatelessWidget {
|
||||
/// AutocompleteCustomTypeExample({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 AutocompleteCore<User>(
|
||||
/// optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
/// return _userOptions.where((User option) {
|
||||
/// // Search based on User.toString, which includes both name and
|
||||
/// // email, even though the display string is just the name.
|
||||
/// return option.toString().contains(textEditingValue.text.toLowerCase());
|
||||
/// });
|
||||
/// },
|
||||
/// displayStringForOption: _displayStringForOption,
|
||||
/// fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) {
|
||||
/// return TextFormField(
|
||||
/// controller: textEditingController,
|
||||
/// focusNode: focusNode,
|
||||
/// onFieldSubmitted: (String value) {
|
||||
/// onFieldSubmitted();
|
||||
/// },
|
||||
/// );
|
||||
/// },
|
||||
/// optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) {
|
||||
/// return Align(
|
||||
/// alignment: Alignment.topLeft,
|
||||
/// child: Material(
|
||||
/// elevation: 4.0,
|
||||
/// child: Container(
|
||||
/// height: 200.0,
|
||||
/// child: ListView.builder(
|
||||
/// padding: EdgeInsets.all(8.0),
|
||||
/// itemCount: options.length,
|
||||
/// itemBuilder: (BuildContext context, int index) {
|
||||
/// final User option = options.elementAt(index);
|
||||
/// return GestureDetector(
|
||||
/// onTap: () {
|
||||
/// onSelected(option);
|
||||
/// },
|
||||
/// child: ListTile(
|
||||
/// title: Text(_displayStringForOption(option)),
|
||||
/// ),
|
||||
/// );
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// },
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// {@tool dartpad --template=freeform}
|
||||
/// This example shows the use of AutocompleteCore in a form.
|
||||
///
|
||||
/// ```dart imports
|
||||
/// import 'package:flutter/widgets.dart';
|
||||
/// import 'package:flutter/material.dart';
|
||||
/// ```
|
||||
///
|
||||
/// ```dart
|
||||
/// class AutocompleteFormExamplePage extends StatefulWidget {
|
||||
/// AutocompleteFormExamplePage({Key key}) : super(key: key);
|
||||
///
|
||||
/// @override
|
||||
/// AutocompleteFormExample createState() => AutocompleteFormExample();
|
||||
/// }
|
||||
///
|
||||
/// class AutocompleteFormExample extends State<AutocompleteFormExamplePage> {
|
||||
/// final _formKey = GlobalKey<FormState>();
|
||||
/// final TextEditingController _textEditingController = TextEditingController();
|
||||
/// String _dropdownValue;
|
||||
/// String _autocompleteSelection;
|
||||
///
|
||||
/// final List<String> _options = <String>[
|
||||
/// 'aardvark',
|
||||
/// 'bobcat',
|
||||
/// 'chameleon',
|
||||
/// ];
|
||||
///
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return Scaffold(
|
||||
/// appBar: AppBar(
|
||||
/// title: Text('Autocomplete Form Example'),
|
||||
/// ),
|
||||
/// body: Center(
|
||||
/// child: Form(
|
||||
/// key: _formKey,
|
||||
/// child: Column(
|
||||
/// children: <Widget>[
|
||||
/// DropdownButtonFormField<String>(
|
||||
/// value: _dropdownValue,
|
||||
/// icon: Icon(Icons.arrow_downward),
|
||||
/// hint: const Text('This is a regular DropdownButtonFormField'),
|
||||
/// iconSize: 24,
|
||||
/// elevation: 16,
|
||||
/// style: TextStyle(color: Colors.deepPurple),
|
||||
/// onChanged: (String newValue) {
|
||||
/// setState(() {
|
||||
/// _dropdownValue = newValue;
|
||||
/// });
|
||||
/// },
|
||||
/// items: <String>['One', 'Two', 'Free', 'Four']
|
||||
/// .map<DropdownMenuItem<String>>((String value) {
|
||||
/// return DropdownMenuItem<String>(
|
||||
/// value: value,
|
||||
/// child: Text(value),
|
||||
/// );
|
||||
/// }).toList(),
|
||||
/// validator: (String value) {
|
||||
/// if (value == null) {
|
||||
/// return 'Must make a selection.';
|
||||
/// }
|
||||
/// return null;
|
||||
/// },
|
||||
/// ),
|
||||
/// TextFormField(
|
||||
/// controller: _textEditingController,
|
||||
/// decoration: InputDecoration(
|
||||
/// hintText: 'This is a regular TextFormField',
|
||||
/// ),
|
||||
/// validator: (String value) {
|
||||
/// if (value.isEmpty) {
|
||||
/// return 'Can\'t be empty.';
|
||||
/// }
|
||||
/// return null;
|
||||
/// },
|
||||
/// ),
|
||||
/// AutocompleteCore<String>(
|
||||
/// optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
/// return _options.where((String option) {
|
||||
/// return option.contains(textEditingValue.text.toLowerCase());
|
||||
/// });
|
||||
/// },
|
||||
/// onSelected: (String selection) {
|
||||
/// setState(() {
|
||||
/// _autocompleteSelection = selection;
|
||||
/// });
|
||||
/// },
|
||||
/// fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) {
|
||||
/// return TextFormField(
|
||||
/// controller: textEditingController,
|
||||
/// decoration: InputDecoration(
|
||||
/// hintText: 'This is an AutocompleteCore!',
|
||||
/// ),
|
||||
/// focusNode: focusNode,
|
||||
/// onFieldSubmitted: (String value) {
|
||||
/// onFieldSubmitted();
|
||||
/// },
|
||||
/// validator: (String value) {
|
||||
/// if (!_options.contains(value)) {
|
||||
/// return 'Nothing selected.';
|
||||
/// }
|
||||
/// return null;
|
||||
/// },
|
||||
/// );
|
||||
/// },
|
||||
/// optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
|
||||
/// return Align(
|
||||
/// alignment: Alignment.topLeft,
|
||||
/// child: Material(
|
||||
/// elevation: 4.0,
|
||||
/// child: Container(
|
||||
/// height: 200.0,
|
||||
/// child: ListView.builder(
|
||||
/// padding: 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),
|
||||
/// ),
|
||||
/// );
|
||||
/// },
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// },
|
||||
/// ),
|
||||
/// ElevatedButton(
|
||||
/// onPressed: () {
|
||||
/// FocusScope.of(context).requestFocus(new FocusNode());
|
||||
/// if (!_formKey.currentState.validate()) {
|
||||
/// return;
|
||||
/// }
|
||||
/// showDialog<void>(
|
||||
/// context: context,
|
||||
/// builder: (BuildContext context) {
|
||||
/// return AlertDialog(
|
||||
/// title: Text('Successfully submitted'),
|
||||
/// content: SingleChildScrollView(
|
||||
/// child: ListBody(
|
||||
/// children: <Widget>[
|
||||
/// Text('DropdownButtonFormField: "$_dropdownValue"'),
|
||||
/// Text('TextFormField: "${_textEditingController.text}"'),
|
||||
/// Text('AutocompleteCore: "$_autocompleteSelection"'),
|
||||
/// ],
|
||||
/// ),
|
||||
/// ),
|
||||
/// actions: <Widget>[
|
||||
/// TextButton(
|
||||
/// child: Text('Ok'),
|
||||
/// onPressed: () {
|
||||
/// Navigator.of(context).pop();
|
||||
/// },
|
||||
/// ),
|
||||
/// ],
|
||||
/// );
|
||||
/// },
|
||||
/// );
|
||||
/// },
|
||||
/// child: Text('Submit'),
|
||||
/// ),
|
||||
/// ],
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
class AutocompleteCore<T extends Object> extends StatefulWidget {
|
||||
/// Create an instance of AutocompleteCore.
|
||||
///
|
||||
/// [fieldViewBuilder] and [optionsViewBuilder] must not be null.
|
||||
const AutocompleteCore({
|
||||
Key? key,
|
||||
required this.fieldViewBuilder,
|
||||
required this.optionsViewBuilder,
|
||||
required this.optionsBuilder,
|
||||
this.displayStringForOption = _defaultStringForOption,
|
||||
this.onSelected,
|
||||
}) : assert(displayStringForOption != null),
|
||||
assert(fieldViewBuilder != null),
|
||||
assert(optionsBuilder != null),
|
||||
assert(optionsViewBuilder != null),
|
||||
super(key: key);
|
||||
|
||||
/// Builds the field whose input is used to get the options.
|
||||
///
|
||||
/// Pass the provided [TextEditingController] to the field built here so that
|
||||
/// AutocompleteCore can listen for changes.
|
||||
final AutocompleteFieldViewBuilder fieldViewBuilder;
|
||||
|
||||
/// 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 AutocompleteCore.
|
||||
final AutocompleteOptionsViewBuilder<T> optionsViewBuilder;
|
||||
|
||||
/// 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()`.
|
||||
final AutocompleteOptionToString<T> displayStringForOption;
|
||||
|
||||
/// 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.
|
||||
final AutocompleteOnSelected<T>? onSelected;
|
||||
|
||||
/// A function that returns the current selectable options objects given the
|
||||
/// current TextEditingValue.
|
||||
final AutocompleteOptionsBuilder<T> optionsBuilder;
|
||||
|
||||
// The default way to convert an option to a string.
|
||||
static String _defaultStringForOption(dynamic option) {
|
||||
return option.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
_AutocompleteCoreState<T> createState() => _AutocompleteCoreState<T>();
|
||||
}
|
||||
|
||||
class _AutocompleteCoreState<T extends Object> extends State<AutocompleteCore<T>> {
|
||||
final GlobalKey _fieldKey = GlobalKey();
|
||||
final LayerLink _optionsLayerLink = LayerLink();
|
||||
final TextEditingController _textEditingController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
Iterable<T> _options = Iterable<T>.empty();
|
||||
T? _selection;
|
||||
|
||||
// The OverlayEntry containing the options.
|
||||
OverlayEntry? _floatingOptions;
|
||||
|
||||
// True iff the state indicates that the options should be visible.
|
||||
bool get _shouldShowOptions {
|
||||
return _focusNode.hasFocus && _selection == null && _options.isNotEmpty;
|
||||
}
|
||||
|
||||
// Called when _textEditingController changes.
|
||||
void _onChangedField() {
|
||||
final Iterable<T> options = widget.optionsBuilder(
|
||||
_textEditingController.value,
|
||||
);
|
||||
_options = options;
|
||||
if (_selection != null
|
||||
&& _textEditingController.text != widget.displayStringForOption(_selection!)) {
|
||||
_selection = null;
|
||||
}
|
||||
_updateOverlay();
|
||||
}
|
||||
|
||||
// Called when the field's FocusNode changes.
|
||||
void _onChangedFocus() {
|
||||
_updateOverlay();
|
||||
}
|
||||
|
||||
// Called from fieldViewBuilder when the user submits the field.
|
||||
void _onFieldSubmitted() {
|
||||
if (_options.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_select(_options.first);
|
||||
}
|
||||
|
||||
// Select the given option and update the widget.
|
||||
void _select(T nextSelection) {
|
||||
if (nextSelection == _selection) {
|
||||
return;
|
||||
}
|
||||
_selection = nextSelection;
|
||||
final String selectionString = widget.displayStringForOption(nextSelection);
|
||||
_textEditingController.value = TextEditingValue(
|
||||
selection: TextSelection.collapsed(offset: selectionString.length),
|
||||
text: selectionString,
|
||||
);
|
||||
widget.onSelected?.call(_selection!);
|
||||
}
|
||||
|
||||
// Hide or show the options overlay, if needed.
|
||||
void _updateOverlay() {
|
||||
if (_shouldShowOptions) {
|
||||
_floatingOptions?.remove();
|
||||
_floatingOptions = OverlayEntry(
|
||||
builder: (BuildContext context) {
|
||||
return CompositedTransformFollower(
|
||||
link: _optionsLayerLink,
|
||||
showWhenUnlinked: false,
|
||||
targetAnchor: Alignment.bottomLeft,
|
||||
child: widget.optionsViewBuilder(context, _select, _options),
|
||||
);
|
||||
},
|
||||
);
|
||||
Overlay.of(context, rootOverlay: true)!.insert(_floatingOptions!);
|
||||
} else if (_floatingOptions != null) {
|
||||
_floatingOptions!.remove();
|
||||
_floatingOptions = null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textEditingController.addListener(_onChangedField);
|
||||
_focusNode.addListener(_onChangedFocus);
|
||||
SchedulerBinding.instance!.addPostFrameCallback((Duration _) {
|
||||
_updateOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AutocompleteCore<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
SchedulerBinding.instance!.addPostFrameCallback((Duration _) {
|
||||
_updateOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textEditingController.removeListener(_onChangedField);
|
||||
_focusNode.removeListener(_onChangedFocus);
|
||||
_floatingOptions?.remove();
|
||||
_floatingOptions = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: _fieldKey,
|
||||
child: CompositedTransformTarget(
|
||||
link: _optionsLayerLink,
|
||||
child: widget.fieldViewBuilder(
|
||||
context,
|
||||
_textEditingController,
|
||||
_focusNode,
|
||||
_onFieldSubmitted,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -23,6 +23,7 @@ export 'src/widgets/animated_switcher.dart';
|
||||
export 'src/widgets/annotated_region.dart';
|
||||
export 'src/widgets/app.dart';
|
||||
export 'src/widgets/async.dart';
|
||||
export 'src/widgets/autocomplete.dart';
|
||||
export 'src/widgets/autofill.dart';
|
||||
export 'src/widgets/automatic_keep_alive.dart';
|
||||
export 'src/widgets/banner.dart';
|
||||
|
490
packages/flutter/test/widgets/autocomplete_test.dart
Normal file
490
packages/flutter/test/widgets/autocomplete_test.dart
Normal file
@ -0,0 +1,490 @@
|
||||
// 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/widgets.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'),
|
||||
];
|
||||
|
||||
group('AutocompleteCore', () {
|
||||
testWidgets('can filter and select a list of string options', (WidgetTester tester) async {
|
||||
final GlobalKey fieldKey = GlobalKey();
|
||||
final GlobalKey optionsKey = GlobalKey();
|
||||
late Iterable<String> lastOptions;
|
||||
late AutocompleteOnSelected<String> lastOnSelected;
|
||||
late FocusNode focusNode;
|
||||
late TextEditingController textEditingController;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AutocompleteCore<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;
|
||||
lastOnSelected = onSelected;
|
||||
return Container(key: optionsKey);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// The field is always rendered, but the options are not unless needed.
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
expect(find.byKey(optionsKey), findsNothing);
|
||||
|
||||
// Focus the empty field. All the options are displayed.
|
||||
focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
expect(find.byKey(optionsKey), findsOneWidget);
|
||||
expect(lastOptions.length, kOptions.length);
|
||||
|
||||
// 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 a 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);
|
||||
|
||||
// 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 rhinocerous');
|
||||
});
|
||||
|
||||
testWidgets('can filter and select a list of custom User options', (WidgetTester tester) async {
|
||||
final GlobalKey fieldKey = GlobalKey();
|
||||
final GlobalKey optionsKey = GlobalKey();
|
||||
late Iterable<User> lastOptions;
|
||||
late AutocompleteOnSelected<User> lastOnSelected;
|
||||
late User lastUserSelected;
|
||||
late FocusNode focusNode;
|
||||
late TextEditingController textEditingController;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AutocompleteCore<User>(
|
||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
return kOptionsUsers.where((User option) {
|
||||
return option.toString().contains(textEditingValue.text.toLowerCase());
|
||||
});
|
||||
},
|
||||
onSelected: (User selected) {
|
||||
lastUserSelected = selected;
|
||||
},
|
||||
fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
|
||||
focusNode = fieldFocusNode;
|
||||
textEditingController = fieldTextEditingController;
|
||||
return TextField(
|
||||
key: fieldKey,
|
||||
focusNode: focusNode,
|
||||
controller: fieldTextEditingController,
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) {
|
||||
lastOptions = options;
|
||||
lastOnSelected = onSelected;
|
||||
return Container(key: optionsKey);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
expect(find.byKey(optionsKey), findsNothing);
|
||||
|
||||
// Enter text. The options are filtered by the text.
|
||||
focusNode.requestFocus();
|
||||
textEditingController.value = const TextEditingValue(
|
||||
text: 'example',
|
||||
selection: TextSelection(baseOffset: 7, extentOffset: 7),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
expect(find.byKey(optionsKey), findsOneWidget);
|
||||
expect(lastOptions.length, 2);
|
||||
expect(lastOptions.elementAt(0), kOptionsUsers[0]);
|
||||
expect(lastOptions.elementAt(1), kOptionsUsers[1]);
|
||||
|
||||
// Select a option. The options hide and onSelected is called.
|
||||
final User selection = lastOptions.elementAt(1);
|
||||
lastOnSelected(selection);
|
||||
await tester.pump();
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
expect(find.byKey(optionsKey), findsNothing);
|
||||
expect(lastUserSelected, selection);
|
||||
expect(textEditingController.text, selection.toString());
|
||||
|
||||
// Modify the field text. The options appear again and are filtered, this
|
||||
// time by name instead of email.
|
||||
textEditingController.value = const TextEditingValue(
|
||||
text: 'B',
|
||||
selection: TextSelection(baseOffset: 1, extentOffset: 1),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
expect(find.byKey(optionsKey), findsOneWidget);
|
||||
expect(lastOptions.length, 1);
|
||||
expect(lastOptions.elementAt(0), kOptionsUsers[1]);
|
||||
});
|
||||
|
||||
testWidgets('can specify a custom display string for a list of custom User options', (WidgetTester tester) async {
|
||||
final GlobalKey fieldKey = GlobalKey();
|
||||
final GlobalKey optionsKey = GlobalKey();
|
||||
late Iterable<User> lastOptions;
|
||||
late AutocompleteOnSelected<User> lastOnSelected;
|
||||
late User lastUserSelected;
|
||||
late final AutocompleteOptionToString<User> displayStringForOption = (User option) => option.name;
|
||||
late FocusNode focusNode;
|
||||
late TextEditingController textEditingController;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AutocompleteCore<User>(
|
||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
return kOptionsUsers.where((User option) {
|
||||
return option
|
||||
.toString()
|
||||
.contains(textEditingValue.text.toLowerCase());
|
||||
});
|
||||
},
|
||||
displayStringForOption: displayStringForOption,
|
||||
onSelected: (User selected) {
|
||||
lastUserSelected = selected;
|
||||
},
|
||||
fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
|
||||
textEditingController = fieldTextEditingController;
|
||||
focusNode = fieldFocusNode;
|
||||
return TextField(
|
||||
key: fieldKey,
|
||||
focusNode: focusNode,
|
||||
controller: fieldTextEditingController,
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) {
|
||||
lastOptions = options;
|
||||
lastOnSelected = onSelected;
|
||||
return Container(key: optionsKey);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
expect(find.byKey(optionsKey), findsNothing);
|
||||
|
||||
// Enter text. The options are filtered by the text.
|
||||
focusNode.requestFocus();
|
||||
textEditingController.value = const TextEditingValue(
|
||||
text: 'example',
|
||||
selection: TextSelection(baseOffset: 7, extentOffset: 7),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
expect(find.byKey(optionsKey), findsOneWidget);
|
||||
expect(lastOptions.length, 2);
|
||||
expect(lastOptions.elementAt(0), kOptionsUsers[0]);
|
||||
expect(lastOptions.elementAt(1), kOptionsUsers[1]);
|
||||
|
||||
// Select a option. The options hide and onSelected is called. The field
|
||||
// has its text set to the selection's display string.
|
||||
final User selection = lastOptions.elementAt(1);
|
||||
lastOnSelected(selection);
|
||||
await tester.pump();
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
expect(find.byKey(optionsKey), findsNothing);
|
||||
expect(lastUserSelected, selection);
|
||||
expect(textEditingController.text, selection.name);
|
||||
|
||||
// Modify the field text. The options appear again and are filtered, this
|
||||
// time by name instead of email.
|
||||
textEditingController.value = const TextEditingValue(
|
||||
text: 'B',
|
||||
selection: TextSelection(baseOffset: 1, extentOffset: 1),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
expect(find.byKey(optionsKey), findsOneWidget);
|
||||
expect(lastOptions.length, 1);
|
||||
expect(lastOptions.elementAt(0), kOptionsUsers[1]);
|
||||
});
|
||||
|
||||
testWidgets('onFieldSubmitted selects the first option', (WidgetTester tester) async {
|
||||
final GlobalKey fieldKey = GlobalKey();
|
||||
final GlobalKey optionsKey = GlobalKey();
|
||||
late Iterable<String> lastOptions;
|
||||
late VoidCallback lastOnFieldSubmitted;
|
||||
late FocusNode focusNode;
|
||||
late TextEditingController textEditingController;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AutocompleteCore<String>(
|
||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
return kOptions.where((String option) {
|
||||
return option.contains(textEditingValue.text.toLowerCase());
|
||||
});
|
||||
},
|
||||
fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
|
||||
textEditingController = fieldTextEditingController;
|
||||
focusNode = fieldFocusNode;
|
||||
lastOnFieldSubmitted = onFieldSubmitted;
|
||||
return TextField(
|
||||
key: fieldKey,
|
||||
focusNode: focusNode,
|
||||
controller: fieldTextEditingController,
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
|
||||
lastOptions = options;
|
||||
return Container(key: optionsKey);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Enter text. The options are filtered by the text.
|
||||
focusNode.requestFocus();
|
||||
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 the current string, as if the field was submitted. The options
|
||||
// hide and the field updates to show the selection.
|
||||
lastOnFieldSubmitted();
|
||||
await tester.pump();
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
expect(find.byKey(optionsKey), findsNothing);
|
||||
expect(textEditingController.text, lastOptions.elementAt(0));
|
||||
});
|
||||
|
||||
testWidgets('options follow field when it moves', (WidgetTester tester) async {
|
||||
final GlobalKey fieldKey = GlobalKey();
|
||||
final GlobalKey optionsKey = GlobalKey();
|
||||
late StateSetter setState;
|
||||
Alignment alignment = Alignment.center;
|
||||
late FocusNode focusNode;
|
||||
late TextEditingController textEditingController;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: StatefulBuilder(
|
||||
builder: (BuildContext context, StateSetter setter) {
|
||||
setState = setter;
|
||||
return Align(
|
||||
alignment: alignment,
|
||||
child: AutocompleteCore<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 TextFormField(
|
||||
controller: fieldTextEditingController,
|
||||
focusNode: focusNode,
|
||||
key: fieldKey,
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
|
||||
return Container(key: optionsKey);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Field is shown but not options.
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
expect(find.byKey(optionsKey), findsNothing);
|
||||
|
||||
// Enter text to show the options.
|
||||
focusNode.requestFocus();
|
||||
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);
|
||||
|
||||
// Options are just below the field.
|
||||
final Offset optionsOffset = tester.getTopLeft(find.byKey(optionsKey));
|
||||
Offset fieldOffset = tester.getTopLeft(find.byKey(fieldKey));
|
||||
final Size fieldSize = tester.getSize(find.byKey(fieldKey));
|
||||
expect(optionsOffset.dy, fieldOffset.dy + fieldSize.height);
|
||||
|
||||
// Move the field (similar to as if the keyboard opened). The options move
|
||||
// to follow the field.
|
||||
setState(() {
|
||||
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);
|
||||
});
|
||||
|
||||
testWidgets('can prevent options from showing by returning an empty iterable', (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: AutocompleteCore<String>(
|
||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
if (textEditingValue.text == null || textEditingValue.text == '') {
|
||||
return const Iterable<String>.empty();
|
||||
}
|
||||
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: fieldTextEditingController,
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
|
||||
lastOptions = options;
|
||||
return Container(key: optionsKey);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// The field is always rendered, but the options are not unless needed.
|
||||
expect(find.byKey(fieldKey), findsOneWidget);
|
||||
expect(find.byKey(optionsKey), findsNothing);
|
||||
|
||||
// Focus the empty field. The options are not displayed because
|
||||
// optionsBuilder returns nothing for an empty field query.
|
||||
focusNode.requestFocus();
|
||||
textEditingController.value = const TextEditingValue(
|
||||
text: '',
|
||||
selection: TextSelection(baseOffset: 0, extentOffset: 0),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byKey(optionsKey), findsNothing);
|
||||
|
||||
// Enter text. Now the options appear, 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');
|
||||
});
|
||||
});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user