From 291ee945064845cb0e560b653b328f65bf6b1340 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Tue, 27 Oct 2020 08:58:52 -0700 Subject: [PATCH] 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. --- .../flutter/lib/src/widgets/autocomplete.dart | 600 ++++++++++++++++++ packages/flutter/lib/widgets.dart | 1 + .../test/widgets/autocomplete_test.dart | 490 ++++++++++++++ 3 files changed, 1091 insertions(+) create mode 100644 packages/flutter/lib/src/widgets/autocomplete.dart create mode 100644 packages/flutter/test/widgets/autocomplete_test.dart diff --git a/packages/flutter/lib/src/widgets/autocomplete.dart b/packages/flutter/lib/src/widgets/autocomplete.dart new file mode 100644 index 0000000000..0261a96e2d --- /dev/null +++ b/packages/flutter/lib/src/widgets/autocomplete.dart @@ -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 = Iterable 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 = 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 = Widget Function( + BuildContext context, + AutocompleteOnSelected onSelected, + Iterable 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 = 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 _options = [ +/// 'aardvark', +/// 'bobcat', +/// 'chameleon', +/// ]; +/// +/// @override +/// Widget build(BuildContext context) { +/// return AutocompleteCore( +/// 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 onSelected, Iterable 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 _userOptions = [ +/// 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( +/// 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 onSelected, Iterable 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 { +/// final _formKey = GlobalKey(); +/// final TextEditingController _textEditingController = TextEditingController(); +/// String _dropdownValue; +/// String _autocompleteSelection; +/// +/// final List _options = [ +/// '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: [ +/// DropdownButtonFormField( +/// 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: ['One', 'Two', 'Free', 'Four'] +/// .map>((String value) { +/// return DropdownMenuItem( +/// 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( +/// 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 onSelected, Iterable 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( +/// context: context, +/// builder: (BuildContext context) { +/// return AlertDialog( +/// title: Text('Successfully submitted'), +/// content: SingleChildScrollView( +/// child: ListBody( +/// children: [ +/// Text('DropdownButtonFormField: "$_dropdownValue"'), +/// Text('TextFormField: "${_textEditingController.text}"'), +/// Text('AutocompleteCore: "$_autocompleteSelection"'), +/// ], +/// ), +/// ), +/// actions: [ +/// TextButton( +/// child: Text('Ok'), +/// onPressed: () { +/// Navigator.of(context).pop(); +/// }, +/// ), +/// ], +/// ); +/// }, +/// ); +/// }, +/// child: Text('Submit'), +/// ), +/// ], +/// ), +/// ), +/// ), +/// ); +/// } +/// } +/// ``` +/// {@end-tool} +class AutocompleteCore 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 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 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? onSelected; + + /// A function that returns the current selectable options objects given the + /// current TextEditingValue. + final AutocompleteOptionsBuilder optionsBuilder; + + // The default way to convert an option to a string. + static String _defaultStringForOption(dynamic option) { + return option.toString(); + } + + @override + _AutocompleteCoreState createState() => _AutocompleteCoreState(); +} + +class _AutocompleteCoreState extends State> { + final GlobalKey _fieldKey = GlobalKey(); + final LayerLink _optionsLayerLink = LayerLink(); + final TextEditingController _textEditingController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + Iterable _options = Iterable.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 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 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, + ), + ), + ); + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 695f12927c..81d9cde92b 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -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'; diff --git a/packages/flutter/test/widgets/autocomplete_test.dart b/packages/flutter/test/widgets/autocomplete_test.dart new file mode 100644 index 0000000000..90dcbdf0e4 --- /dev/null +++ b/packages/flutter/test/widgets/autocomplete_test.dart @@ -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 kOptions = [ + 'aardvark', + 'bobcat', + 'chameleon', + 'dingo', + 'elephant', + 'flamingo', + 'goose', + 'hippopotamus', + 'iguana', + 'jaguar', + 'koala', + 'lemur', + 'mouse', + 'northern white rhinocerous', + ]; + + const List kOptionsUsers = [ + 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 lastOptions; + late AutocompleteOnSelected lastOnSelected; + late FocusNode focusNode; + late TextEditingController textEditingController; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AutocompleteCore( + 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 onSelected, Iterable 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 lastOptions; + late AutocompleteOnSelected lastOnSelected; + late User lastUserSelected; + late FocusNode focusNode; + late TextEditingController textEditingController; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AutocompleteCore( + 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 onSelected, Iterable 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 lastOptions; + late AutocompleteOnSelected lastOnSelected; + late User lastUserSelected; + late final AutocompleteOptionToString displayStringForOption = (User option) => option.name; + late FocusNode focusNode; + late TextEditingController textEditingController; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AutocompleteCore( + 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 onSelected, Iterable 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 lastOptions; + late VoidCallback lastOnFieldSubmitted; + late FocusNode focusNode; + late TextEditingController textEditingController; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AutocompleteCore( + 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 onSelected, Iterable 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( + 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 onSelected, Iterable 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 lastOptions; + late FocusNode focusNode; + late TextEditingController textEditingController; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AutocompleteCore( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text == null || textEditingValue.text == '') { + return const Iterable.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 onSelected, Iterable 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'); + }); + }); +}