Unverified Commit 291ee945 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

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.
parent 4b017b62
// 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';
......
// 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');
});
});
}
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