Unverified Commit 29d33cc3 authored by Justin McCandless's avatar Justin McCandless Committed by GitHub

Autocomplete Split UI (#72553)

Allows passing in a TextEditingController and FocusNode to RawAutocomplete, which enables split UIs where the TextField is in another part of the tree from the results.
parent 5c6640df
...@@ -422,25 +422,135 @@ typedef AutocompleteOptionToString<T extends Object> = String Function(T option) ...@@ -422,25 +422,135 @@ typedef AutocompleteOptionToString<T extends Object> = String Function(T option)
class RawAutocomplete<T extends Object> extends StatefulWidget { class RawAutocomplete<T extends Object> extends StatefulWidget {
/// Create an instance of RawAutocomplete. /// Create an instance of RawAutocomplete.
/// ///
/// [fieldViewBuilder] and [optionsViewBuilder] must not be null. /// [displayStringForOption], [optionsBuilder] and [optionsViewBuilder] must
/// not be null.
const RawAutocomplete({ const RawAutocomplete({
Key? key, Key? key,
required this.fieldViewBuilder,
required this.optionsViewBuilder, required this.optionsViewBuilder,
required this.optionsBuilder, required this.optionsBuilder,
this.displayStringForOption = _defaultStringForOption, this.displayStringForOption = _defaultStringForOption,
this.fieldViewBuilder,
this.focusNode,
this.onSelected, this.onSelected,
this.textEditingController,
}) : assert(displayStringForOption != null), }) : assert(displayStringForOption != null),
assert(fieldViewBuilder != null), assert(
fieldViewBuilder != null
|| (key != null && focusNode != null && textEditingController != null),
'Pass in a fieldViewBuilder, or otherwise create a separate field and pass in the FocusNode, TextEditingController, and a key. Use the key with RawAutocomplete.onFieldSubmitted.',
),
assert(optionsBuilder != null), assert(optionsBuilder != null),
assert(optionsViewBuilder != null), assert(optionsViewBuilder != null),
assert((focusNode == null) == (textEditingController == null)),
super(key: key); super(key: key);
/// Builds the field whose input is used to get the options. /// Builds the field whose input is used to get the options.
/// ///
/// Pass the provided [TextEditingController] to the field built here so that /// Pass the provided [TextEditingController] to the field built here so that
/// RawAutocomplete can listen for changes. /// RawAutocomplete can listen for changes.
final AutocompleteFieldViewBuilder fieldViewBuilder; final AutocompleteFieldViewBuilder? fieldViewBuilder;
/// The [FocusNode] that is used for the text field.
///
/// {@template flutter.widgets.RawAutocomplete.split}
/// The main purpose of this parameter is to allow the use of a separate text
/// field located in another part of the widget tree instead of the text
/// field built by [fieldViewBuilder]. For example, it may be desirable to
/// place the text field in the AppBar and the options below in the main body.
///
/// When following this pattern, [fieldViewBuilder] can return
/// `SizedBox.shrink()` so that nothing is drawn where the text field would
/// normally be. A separate text field can be created elsewhere, and a
/// FocusNode and TextEditingController can be passed both to that text field
/// and to RawAutocomplete.
///
/// {@tool dartpad --template=freeform}
/// This examples shows how to create an autocomplete widget with the text
/// field in the AppBar and the results in the main body of the app.
///
/// ```dart imports
/// import 'package:flutter/widgets.dart';
/// import 'package:flutter/material.dart';
/// ```
///
/// ```dart
/// final List<String> _options = <String>[
/// 'aardvark',
/// 'bobcat',
/// 'chameleon',
/// ];
///
/// class RawAutocompleteSplitPage extends StatefulWidget {
/// RawAutocompleteSplitPage({Key? key}) : super(key: key);
///
/// RawAutocompleteSplitPageState createState() => RawAutocompleteSplitPageState();
/// }
///
/// class RawAutocompleteSplitPageState extends State<RawAutocompleteSplitPage> {
/// final TextEditingController _textEditingController = TextEditingController();
/// final FocusNode _focusNode = FocusNode();
/// final GlobalKey _autocompleteKey = GlobalKey();
///
/// @override
/// Widget build(BuildContext context) {
/// return MaterialApp(
/// theme: ThemeData(
/// primarySwatch: Colors.blue,
/// ),
/// title: 'Split RawAutocomplete App',
/// home: Scaffold(
/// appBar: AppBar(
/// // This is where the real field is being built.
/// title: TextFormField(
/// controller: _textEditingController,
/// focusNode: _focusNode,
/// decoration: InputDecoration(
/// hintText: 'Split RawAutocomplete App',
/// ),
/// onFieldSubmitted: (String value) {
/// RawAutocomplete.onFieldSubmitted<String>(_autocompleteKey);
/// },
/// ),
/// ),
/// body: Align(
/// alignment: Alignment.topLeft,
/// child: RawAutocomplete<String>(
/// key: _autocompleteKey,
/// focusNode: _focusNode,
/// textEditingController: _textEditingController,
/// optionsBuilder: (TextEditingValue textEditingValue) {
/// return _options.where((String option) {
/// return option.contains(textEditingValue.text.toLowerCase());
/// }).toList();
/// },
/// optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
/// return Material(
/// elevation: 4.0,
/// child: ListView(
/// children: options.map((String option) => GestureDetector(
/// onTap: () {
/// onSelected(option);
/// },
/// child: ListTile(
/// title: Text(option),
/// ),
/// )).toList(),
/// ),
/// );
/// },
/// ),
/// ),
/// ),
/// );
/// }
/// }
/// ```
/// {@end-tool}
/// {@endtemplate}
///
/// If this parameter is not null, then [textEditingController] must also be
/// not null.
final FocusNode? focusNode;
/// Builds the selectable options widgets from a list of options objects. /// Builds the selectable options widgets from a list of options objects.
/// ///
...@@ -468,6 +578,31 @@ class RawAutocomplete<T extends Object> extends StatefulWidget { ...@@ -468,6 +578,31 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
/// current TextEditingValue. /// current TextEditingValue.
final AutocompleteOptionsBuilder<T> optionsBuilder; final AutocompleteOptionsBuilder<T> optionsBuilder;
/// The [TextEditingController] that is used for the text field.
///
/// {@macro flutter.widgets.RawAutocomplete.split}
///
/// If this parameter is not null, then [focusNode] must also be not null.
final TextEditingController? textEditingController;
/// Calls [AutocompleteFieldViewBuilder]'s onFieldSubmitted callback for the
/// RawAutocomplete widget indicated by the given [GlobalKey].
///
/// This is not typically used unless a custom field is implemented instead of
/// using [fieldViewBuilder]. In the typical case, the onFieldSubmitted
/// callback is passed via the [AutocompleteFieldViewBuilder] signature. When
/// not using fieldViewBuilder, the same callback can be called by using this
/// static method.
///
/// See also:
///
/// * [focusNode] and [textEditingController], which contain a code example
/// showing how to create a separate field outside of fieldViewBuilder.
static void onFieldSubmitted<T extends Object>(GlobalKey key) {
final _RawAutocompleteState<T> rawAutocomplete = key.currentState! as _RawAutocompleteState<T>;
rawAutocomplete._onFieldSubmitted();
}
// The default way to convert an option to a string. // The default way to convert an option to a string.
static String _defaultStringForOption(dynamic option) { static String _defaultStringForOption(dynamic option) {
return option.toString(); return option.toString();
...@@ -480,8 +615,8 @@ class RawAutocomplete<T extends Object> extends StatefulWidget { ...@@ -480,8 +615,8 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> { class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> {
final GlobalKey _fieldKey = GlobalKey(); final GlobalKey _fieldKey = GlobalKey();
final LayerLink _optionsLayerLink = LayerLink(); final LayerLink _optionsLayerLink = LayerLink();
final TextEditingController _textEditingController = TextEditingController(); late TextEditingController _textEditingController;
final FocusNode _focusNode = FocusNode(); late FocusNode _focusNode;
Iterable<T> _options = Iterable<T>.empty(); Iterable<T> _options = Iterable<T>.empty();
T? _selection; T? _selection;
...@@ -554,10 +689,52 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> ...@@ -554,10 +689,52 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
} }
} }
// Handle a potential change in textEditingController by properly disposing of
// the old one and setting up the new one, if needed.
void _updateTextEditingController(TextEditingController? old, TextEditingController? current) {
if ((old == null && current == null) || old == current) {
return;
}
if (old == null) {
_textEditingController.removeListener(_onChangedField);
_textEditingController.dispose();
_textEditingController = current!;
} else if (current == null) {
_textEditingController.removeListener(_onChangedField);
_textEditingController = TextEditingController();
} else {
_textEditingController.removeListener(_onChangedField);
_textEditingController = current;
}
_textEditingController.addListener(_onChangedField);
}
// Handle a potential change in focusNode by properly disposing of the old one
// and setting up the new one, if needed.
void _updateFocusNode(FocusNode? old, FocusNode? current) {
if ((old == null && current == null) || old == current) {
return;
}
if (old == null) {
_focusNode.removeListener(_onChangedFocus);
_focusNode.dispose();
_focusNode = current!;
} else if (current == null) {
_focusNode.removeListener(_onChangedFocus);
_focusNode = FocusNode();
} else {
_focusNode.removeListener(_onChangedFocus);
_focusNode = current;
}
_focusNode.addListener(_onChangedFocus);
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textEditingController = widget.textEditingController ?? TextEditingController();
_textEditingController.addListener(_onChangedField); _textEditingController.addListener(_onChangedField);
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onChangedFocus); _focusNode.addListener(_onChangedFocus);
SchedulerBinding.instance!.addPostFrameCallback((Duration _) { SchedulerBinding.instance!.addPostFrameCallback((Duration _) {
_updateOverlay(); _updateOverlay();
...@@ -567,6 +744,11 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> ...@@ -567,6 +744,11 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
@override @override
void didUpdateWidget(RawAutocomplete<T> oldWidget) { void didUpdateWidget(RawAutocomplete<T> oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
_updateTextEditingController(
oldWidget.textEditingController,
widget.textEditingController,
);
_updateFocusNode(oldWidget.focusNode, widget.focusNode);
SchedulerBinding.instance!.addPostFrameCallback((Duration _) { SchedulerBinding.instance!.addPostFrameCallback((Duration _) {
_updateOverlay(); _updateOverlay();
}); });
...@@ -575,7 +757,13 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> ...@@ -575,7 +757,13 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
@override @override
void dispose() { void dispose() {
_textEditingController.removeListener(_onChangedField); _textEditingController.removeListener(_onChangedField);
if (widget.textEditingController == null) {
_textEditingController.dispose();
}
_focusNode.removeListener(_onChangedFocus); _focusNode.removeListener(_onChangedFocus);
if (widget.focusNode == null) {
_focusNode.dispose();
}
_floatingOptions?.remove(); _floatingOptions?.remove();
_floatingOptions = null; _floatingOptions = null;
super.dispose(); super.dispose();
...@@ -587,12 +775,14 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> ...@@ -587,12 +775,14 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
key: _fieldKey, key: _fieldKey,
child: CompositedTransformTarget( child: CompositedTransformTarget(
link: _optionsLayerLink, link: _optionsLayerLink,
child: widget.fieldViewBuilder( child: widget.fieldViewBuilder == null
context, ? const SizedBox.shrink()
_textEditingController, : widget.fieldViewBuilder!(
_focusNode, context,
_onFieldSubmitted, _textEditingController,
), _focusNode,
_onFieldSubmitted,
),
), ),
); );
} }
......
...@@ -45,446 +45,513 @@ void main() { ...@@ -45,446 +45,513 @@ void main() {
User(name: 'Charlie', email: 'charlie123@gmail.com'), User(name: 'Charlie', email: 'charlie123@gmail.com'),
]; ];
group('RawAutocomplete', () { testWidgets('can filter and select a list of string options', (WidgetTester tester) async {
testWidgets('can filter and select a list of string options', (WidgetTester tester) async { final GlobalKey fieldKey = GlobalKey();
final GlobalKey fieldKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey();
final GlobalKey optionsKey = GlobalKey(); late Iterable<String> lastOptions;
late Iterable<String> lastOptions; late AutocompleteOnSelected<String> lastOnSelected;
late AutocompleteOnSelected<String> lastOnSelected; late FocusNode focusNode;
late FocusNode focusNode; late TextEditingController textEditingController;
late TextEditingController textEditingController;
await tester.pumpWidget(
await tester.pumpWidget( MaterialApp(
MaterialApp( home: Scaffold(
home: Scaffold( body: RawAutocomplete<String>(
body: RawAutocomplete<String>( optionsBuilder: (TextEditingValue textEditingValue) {
optionsBuilder: (TextEditingValue textEditingValue) { return kOptions.where((String option) {
return kOptions.where((String option) { return option.contains(textEditingValue.text.toLowerCase());
return option.contains(textEditingValue.text.toLowerCase()); });
}); },
}, fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { focusNode = fieldFocusNode;
focusNode = fieldFocusNode; textEditingController = fieldTextEditingController;
textEditingController = fieldTextEditingController; return TextField(
return TextField( key: fieldKey,
key: fieldKey, focusNode: focusNode,
focusNode: focusNode, controller: textEditingController,
controller: textEditingController, );
); },
}, optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) { lastOptions = options;
lastOptions = options; lastOnSelected = onSelected;
lastOnSelected = onSelected; return Container(key: optionsKey);
return Container(key: optionsKey); },
},
),
), ),
), ),
); ),
);
// The field is always rendered, but the options are not unless needed.
expect(find.byKey(fieldKey), findsOneWidget); // The field is always rendered, but the options are not unless needed.
expect(find.byKey(optionsKey), findsNothing); expect(find.byKey(fieldKey), findsOneWidget);
expect(find.byKey(optionsKey), findsNothing);
// Focus the empty field. All the options are displayed.
focusNode.requestFocus(); // Focus the empty field. All the options are displayed.
await tester.pump(); focusNode.requestFocus();
expect(find.byKey(optionsKey), findsOneWidget); await tester.pump();
expect(lastOptions.length, kOptions.length); expect(find.byKey(optionsKey), findsOneWidget);
expect(lastOptions.length, kOptions.length);
// Enter text. The options are filtered by the text.
textEditingController.value = const TextEditingValue( // Enter text. The options are filtered by the text.
text: 'ele', textEditingController.value = const TextEditingValue(
selection: TextSelection(baseOffset: 3, extentOffset: 3), text: 'ele',
); selection: TextSelection(baseOffset: 3, extentOffset: 3),
await tester.pump(); );
expect(find.byKey(fieldKey), findsOneWidget); await tester.pump();
expect(find.byKey(optionsKey), findsOneWidget); expect(find.byKey(fieldKey), findsOneWidget);
expect(lastOptions.length, 2); expect(find.byKey(optionsKey), findsOneWidget);
expect(lastOptions.elementAt(0), 'chameleon'); expect(lastOptions.length, 2);
expect(lastOptions.elementAt(1), 'elephant'); expect(lastOptions.elementAt(0), 'chameleon');
expect(lastOptions.elementAt(1), 'elephant');
// Select a option. The options hide and the field updates to show the
// selection. // Select a option. The options hide and the field updates to show the
final String selection = lastOptions.elementAt(1); // selection.
lastOnSelected(selection); final String selection = lastOptions.elementAt(1);
await tester.pump(); lastOnSelected(selection);
expect(find.byKey(fieldKey), findsOneWidget); await tester.pump();
expect(find.byKey(optionsKey), findsNothing); expect(find.byKey(fieldKey), findsOneWidget);
expect(textEditingController.text, selection); expect(find.byKey(optionsKey), findsNothing);
expect(textEditingController.text, selection);
// Modify the field text. The options appear again and are filtered.
textEditingController.value = const TextEditingValue( // Modify the field text. The options appear again and are filtered.
text: 'e', textEditingController.value = const TextEditingValue(
selection: TextSelection(baseOffset: 1, extentOffset: 1), text: 'e',
); selection: TextSelection(baseOffset: 1, extentOffset: 1),
await tester.pump(); );
expect(find.byKey(fieldKey), findsOneWidget); await tester.pump();
expect(find.byKey(optionsKey), findsOneWidget); expect(find.byKey(fieldKey), findsOneWidget);
expect(lastOptions.length, 6); expect(find.byKey(optionsKey), findsOneWidget);
expect(lastOptions.elementAt(0), 'chameleon'); expect(lastOptions.length, 6);
expect(lastOptions.elementAt(1), 'elephant'); expect(lastOptions.elementAt(0), 'chameleon');
expect(lastOptions.elementAt(2), 'goose'); expect(lastOptions.elementAt(1), 'elephant');
expect(lastOptions.elementAt(3), 'lemur'); expect(lastOptions.elementAt(2), 'goose');
expect(lastOptions.elementAt(4), 'mouse'); expect(lastOptions.elementAt(3), 'lemur');
expect(lastOptions.elementAt(5), 'northern white rhinocerous'); 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 { testWidgets('can filter and select a list of custom User options', (WidgetTester tester) async {
final GlobalKey fieldKey = GlobalKey(); final GlobalKey fieldKey = GlobalKey();
final GlobalKey optionsKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey();
late Iterable<User> lastOptions; late Iterable<User> lastOptions;
late AutocompleteOnSelected<User> lastOnSelected; late AutocompleteOnSelected<User> lastOnSelected;
late User lastUserSelected; late User lastUserSelected;
late FocusNode focusNode; late FocusNode focusNode;
late TextEditingController textEditingController; late TextEditingController textEditingController;
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: RawAutocomplete<User>( body: RawAutocomplete<User>(
optionsBuilder: (TextEditingValue textEditingValue) { optionsBuilder: (TextEditingValue textEditingValue) {
return kOptionsUsers.where((User option) { return kOptionsUsers.where((User option) {
return option.toString().contains(textEditingValue.text.toLowerCase()); return option.toString().contains(textEditingValue.text.toLowerCase());
}); });
}, },
onSelected: (User selected) { onSelected: (User selected) {
lastUserSelected = selected; lastUserSelected = selected;
}, },
fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
focusNode = fieldFocusNode; focusNode = fieldFocusNode;
textEditingController = fieldTextEditingController; textEditingController = fieldTextEditingController;
return TextField( return TextField(
key: fieldKey, key: fieldKey,
focusNode: focusNode, focusNode: focusNode,
controller: fieldTextEditingController, controller: fieldTextEditingController,
); );
}, },
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) { optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) {
lastOptions = options; lastOptions = options;
lastOnSelected = onSelected; lastOnSelected = onSelected;
return Container(key: optionsKey); return Container(key: optionsKey);
}, },
),
), ),
), ),
); ),
);
expect(find.byKey(fieldKey), findsOneWidget);
expect(find.byKey(optionsKey), findsNothing); expect(find.byKey(fieldKey), findsOneWidget);
expect(find.byKey(optionsKey), findsNothing);
// Enter text. The options are filtered by the text.
focusNode.requestFocus(); // Enter text. The options are filtered by the text.
textEditingController.value = const TextEditingValue( focusNode.requestFocus();
text: 'example', textEditingController.value = const TextEditingValue(
selection: TextSelection(baseOffset: 7, extentOffset: 7), text: 'example',
); selection: TextSelection(baseOffset: 7, extentOffset: 7),
await tester.pump(); );
expect(find.byKey(fieldKey), findsOneWidget); await tester.pump();
expect(find.byKey(optionsKey), findsOneWidget); expect(find.byKey(fieldKey), findsOneWidget);
expect(lastOptions.length, 2); expect(find.byKey(optionsKey), findsOneWidget);
expect(lastOptions.elementAt(0), kOptionsUsers[0]); expect(lastOptions.length, 2);
expect(lastOptions.elementAt(1), kOptionsUsers[1]); 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); // Select a option. The options hide and onSelected is called.
lastOnSelected(selection); final User selection = lastOptions.elementAt(1);
await tester.pump(); lastOnSelected(selection);
expect(find.byKey(fieldKey), findsOneWidget); await tester.pump();
expect(find.byKey(optionsKey), findsNothing); expect(find.byKey(fieldKey), findsOneWidget);
expect(lastUserSelected, selection); expect(find.byKey(optionsKey), findsNothing);
expect(textEditingController.text, selection.toString()); 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. // Modify the field text. The options appear again and are filtered, this
textEditingController.value = const TextEditingValue( // time by name instead of email.
text: 'B', textEditingController.value = const TextEditingValue(
selection: TextSelection(baseOffset: 1, extentOffset: 1), text: 'B',
); selection: TextSelection(baseOffset: 1, extentOffset: 1),
await tester.pump(); );
expect(find.byKey(fieldKey), findsOneWidget); await tester.pump();
expect(find.byKey(optionsKey), findsOneWidget); expect(find.byKey(fieldKey), findsOneWidget);
expect(lastOptions.length, 1); expect(find.byKey(optionsKey), findsOneWidget);
expect(lastOptions.elementAt(0), kOptionsUsers[1]); 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 { testWidgets('can specify a custom display string for a list of custom User options', (WidgetTester tester) async {
final GlobalKey fieldKey = GlobalKey(); final GlobalKey fieldKey = GlobalKey();
final GlobalKey optionsKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey();
late Iterable<User> lastOptions; late Iterable<User> lastOptions;
late AutocompleteOnSelected<User> lastOnSelected; late AutocompleteOnSelected<User> lastOnSelected;
late User lastUserSelected; late User lastUserSelected;
late final AutocompleteOptionToString<User> displayStringForOption = (User option) => option.name; late final AutocompleteOptionToString<User> displayStringForOption = (User option) => option.name;
late FocusNode focusNode; late FocusNode focusNode;
late TextEditingController textEditingController; late TextEditingController textEditingController;
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: RawAutocomplete<User>( body: RawAutocomplete<User>(
optionsBuilder: (TextEditingValue textEditingValue) { optionsBuilder: (TextEditingValue textEditingValue) {
return kOptionsUsers.where((User option) { return kOptionsUsers.where((User option) {
return option return option
.toString() .toString()
.contains(textEditingValue.text.toLowerCase()); .contains(textEditingValue.text.toLowerCase());
}); });
}, },
displayStringForOption: displayStringForOption, displayStringForOption: displayStringForOption,
onSelected: (User selected) { onSelected: (User selected) {
lastUserSelected = selected; lastUserSelected = selected;
}, },
fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
textEditingController = fieldTextEditingController; textEditingController = fieldTextEditingController;
focusNode = fieldFocusNode; focusNode = fieldFocusNode;
return TextField( return TextField(
key: fieldKey, key: fieldKey,
focusNode: focusNode, focusNode: focusNode,
controller: fieldTextEditingController, controller: fieldTextEditingController,
); );
}, },
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) { optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<User> onSelected, Iterable<User> options) {
lastOptions = options; lastOptions = options;
lastOnSelected = onSelected; lastOnSelected = onSelected;
return Container(key: optionsKey); return Container(key: optionsKey);
}, },
),
), ),
), ),
); ),
);
expect(find.byKey(fieldKey), findsOneWidget);
expect(find.byKey(optionsKey), findsNothing); expect(find.byKey(fieldKey), findsOneWidget);
expect(find.byKey(optionsKey), findsNothing);
// Enter text. The options are filtered by the text.
focusNode.requestFocus(); // Enter text. The options are filtered by the text.
textEditingController.value = const TextEditingValue( focusNode.requestFocus();
text: 'example', textEditingController.value = const TextEditingValue(
selection: TextSelection(baseOffset: 7, extentOffset: 7), text: 'example',
); selection: TextSelection(baseOffset: 7, extentOffset: 7),
await tester.pump(); );
expect(find.byKey(fieldKey), findsOneWidget); await tester.pump();
expect(find.byKey(optionsKey), findsOneWidget); expect(find.byKey(fieldKey), findsOneWidget);
expect(lastOptions.length, 2); expect(find.byKey(optionsKey), findsOneWidget);
expect(lastOptions.elementAt(0), kOptionsUsers[0]); expect(lastOptions.length, 2);
expect(lastOptions.elementAt(1), kOptionsUsers[1]); 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. // Select a option. The options hide and onSelected is called. The field
final User selection = lastOptions.elementAt(1); // has its text set to the selection's display string.
lastOnSelected(selection); final User selection = lastOptions.elementAt(1);
await tester.pump(); lastOnSelected(selection);
expect(find.byKey(fieldKey), findsOneWidget); await tester.pump();
expect(find.byKey(optionsKey), findsNothing); expect(find.byKey(fieldKey), findsOneWidget);
expect(lastUserSelected, selection); expect(find.byKey(optionsKey), findsNothing);
expect(textEditingController.text, selection.name); 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. // Modify the field text. The options appear again and are filtered, this
textEditingController.value = const TextEditingValue( // time by name instead of email.
text: 'B', textEditingController.value = const TextEditingValue(
selection: TextSelection(baseOffset: 1, extentOffset: 1), text: 'B',
); selection: TextSelection(baseOffset: 1, extentOffset: 1),
await tester.pump(); );
expect(find.byKey(fieldKey), findsOneWidget); await tester.pump();
expect(find.byKey(optionsKey), findsOneWidget); expect(find.byKey(fieldKey), findsOneWidget);
expect(lastOptions.length, 1); expect(find.byKey(optionsKey), findsOneWidget);
expect(lastOptions.elementAt(0), kOptionsUsers[1]); expect(lastOptions.length, 1);
}); expect(lastOptions.elementAt(0), kOptionsUsers[1]);
});
testWidgets('onFieldSubmitted selects the first option', (WidgetTester tester) async { testWidgets('onFieldSubmitted selects the first option', (WidgetTester tester) async {
final GlobalKey fieldKey = GlobalKey(); final GlobalKey fieldKey = GlobalKey();
final GlobalKey optionsKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey();
late Iterable<String> lastOptions; late Iterable<String> lastOptions;
late VoidCallback lastOnFieldSubmitted; late VoidCallback lastOnFieldSubmitted;
late FocusNode focusNode; late FocusNode focusNode;
late TextEditingController textEditingController; late TextEditingController textEditingController;
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: RawAutocomplete<String>( body: RawAutocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) { optionsBuilder: (TextEditingValue textEditingValue) {
return kOptions.where((String option) { return kOptions.where((String option) {
return option.contains(textEditingValue.text.toLowerCase()); return option.contains(textEditingValue.text.toLowerCase());
}); });
}, },
fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
textEditingController = fieldTextEditingController; textEditingController = fieldTextEditingController;
focusNode = fieldFocusNode; focusNode = fieldFocusNode;
lastOnFieldSubmitted = onFieldSubmitted; lastOnFieldSubmitted = onFieldSubmitted;
return TextField( return TextField(
key: fieldKey, key: fieldKey,
focusNode: focusNode, focusNode: focusNode,
controller: fieldTextEditingController, controller: fieldTextEditingController,
); );
}, },
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) { optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
lastOptions = options; lastOptions = options;
return Container(key: optionsKey); return Container(key: optionsKey);
}, },
),
), ),
), ),
); ),
);
// Enter text. The options are filtered by the text.
focusNode.requestFocus(); expect(find.byKey(fieldKey), findsOneWidget);
textEditingController.value = const TextEditingValue( expect(find.byKey(optionsKey), findsNothing);
text: 'ele',
selection: TextSelection(baseOffset: 3, extentOffset: 3), // Enter text. The options are filtered by the text.
); focusNode.requestFocus();
await tester.pump(); textEditingController.value = const TextEditingValue(
expect(find.byKey(fieldKey), findsOneWidget); text: 'ele',
expect(find.byKey(optionsKey), findsOneWidget); selection: TextSelection(baseOffset: 3, extentOffset: 3),
expect(lastOptions.length, 2); );
expect(lastOptions.elementAt(0), 'chameleon'); await tester.pump();
expect(lastOptions.elementAt(1), 'elephant'); expect(find.byKey(fieldKey), findsOneWidget);
expect(find.byKey(optionsKey), findsOneWidget);
// Select the current string, as if the field was submitted. The options expect(lastOptions.length, 2);
// hide and the field updates to show the selection. expect(lastOptions.elementAt(0), 'chameleon');
lastOnFieldSubmitted(); expect(lastOptions.elementAt(1), 'elephant');
await tester.pump();
expect(find.byKey(fieldKey), findsOneWidget); // Select the current string, as if the field was submitted. The options
expect(find.byKey(optionsKey), findsNothing); // hide and the field updates to show the selection.
expect(textEditingController.text, lastOptions.elementAt(0)); 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 { testWidgets('options follow field when it moves', (WidgetTester tester) async {
final GlobalKey fieldKey = GlobalKey(); final GlobalKey fieldKey = GlobalKey();
final GlobalKey optionsKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey();
late StateSetter setState; late StateSetter setState;
Alignment alignment = Alignment.center; Alignment alignment = Alignment.center;
late FocusNode focusNode; late FocusNode focusNode;
late TextEditingController textEditingController; late TextEditingController textEditingController;
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: StatefulBuilder( body: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) { builder: (BuildContext context, StateSetter setter) {
setState = setter; setState = setter;
return Align( return Align(
alignment: alignment, alignment: alignment,
child: RawAutocomplete<String>( child: RawAutocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) { optionsBuilder: (TextEditingValue textEditingValue) {
return kOptions.where((String option) { return kOptions.where((String option) {
return option.contains(textEditingValue.text.toLowerCase()); return option.contains(textEditingValue.text.toLowerCase());
}); });
}, },
fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
focusNode = fieldFocusNode; focusNode = fieldFocusNode;
textEditingController = fieldTextEditingController; textEditingController = fieldTextEditingController;
return TextFormField( return TextFormField(
controller: fieldTextEditingController, controller: fieldTextEditingController,
focusNode: focusNode, focusNode: focusNode,
key: fieldKey, key: fieldKey,
); );
}, },
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) { optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
return Container(key: optionsKey); return Container(key: optionsKey);
}, },
), ),
); );
}, },
),
), ),
), ),
); ),
);
// Field is shown but not options.
expect(find.byKey(fieldKey), findsOneWidget); // Field is shown but not options.
expect(find.byKey(optionsKey), findsNothing); expect(find.byKey(fieldKey), findsOneWidget);
expect(find.byKey(optionsKey), findsNothing);
// Enter text to show the options.
focusNode.requestFocus(); // Enter text to show the options.
textEditingController.value = const TextEditingValue( focusNode.requestFocus();
text: 'ele', textEditingController.value = const TextEditingValue(
selection: TextSelection(baseOffset: 3, extentOffset: 3), text: 'ele',
); selection: TextSelection(baseOffset: 3, extentOffset: 3),
await tester.pump(); );
expect(find.byKey(fieldKey), findsOneWidget); await tester.pump();
expect(find.byKey(optionsKey), findsOneWidget); expect(find.byKey(fieldKey), findsOneWidget);
expect(find.byKey(optionsKey), findsOneWidget);
// Options are just below the field.
final Offset optionsOffset = tester.getTopLeft(find.byKey(optionsKey)); // Options are just below the field.
Offset fieldOffset = tester.getTopLeft(find.byKey(fieldKey)); final Offset optionsOffset = tester.getTopLeft(find.byKey(optionsKey));
final Size fieldSize = tester.getSize(find.byKey(fieldKey)); Offset fieldOffset = tester.getTopLeft(find.byKey(fieldKey));
expect(optionsOffset.dy, fieldOffset.dy + fieldSize.height); 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. // Move the field (similar to as if the keyboard opened). The options move
setState(() { // to follow the field.
alignment = Alignment.topCenter; 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);
}); });
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 { testWidgets('can prevent options from showing by returning an empty iterable', (WidgetTester tester) async {
final GlobalKey fieldKey = GlobalKey(); final GlobalKey fieldKey = GlobalKey();
final GlobalKey optionsKey = GlobalKey(); final GlobalKey optionsKey = GlobalKey();
late Iterable<String> lastOptions; late Iterable<String> lastOptions;
late FocusNode focusNode; late FocusNode focusNode;
late TextEditingController textEditingController; late TextEditingController textEditingController;
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: Scaffold( home: Scaffold(
body: RawAutocomplete<String>( body: RawAutocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) { optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text == null || textEditingValue.text == '') { if (textEditingValue.text == null || textEditingValue.text == '') {
return const Iterable<String>.empty(); return const Iterable<String>.empty();
} }
return kOptions.where((String option) { return kOptions.where((String option) {
return option.contains(textEditingValue.text.toLowerCase()); return option.contains(textEditingValue.text.toLowerCase());
}); });
}, },
fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) { fieldViewBuilder: (BuildContext context, TextEditingController fieldTextEditingController, FocusNode fieldFocusNode, VoidCallback onFieldSubmitted) {
focusNode = fieldFocusNode; focusNode = fieldFocusNode;
textEditingController = fieldTextEditingController; textEditingController = fieldTextEditingController;
return TextField( return TextField(
key: fieldKey, key: fieldKey,
focusNode: focusNode, focusNode: focusNode,
controller: fieldTextEditingController, controller: fieldTextEditingController,
); );
}, },
optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) { optionsViewBuilder: (BuildContext context, AutocompleteOnSelected<String> onSelected, Iterable<String> options) {
lastOptions = options; lastOptions = options;
return Container(key: optionsKey); 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');
});
testWidgets('can create a field outside of fieldViewBuilder', (WidgetTester tester) async {
final GlobalKey fieldKey = GlobalKey();
final GlobalKey optionsKey = GlobalKey();
final GlobalKey autocompleteKey = GlobalKey();
late Iterable<String> lastOptions;
final FocusNode focusNode = FocusNode();
final TextEditingController textEditingController = TextEditingController();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(
// This is where the real field is being built.
title: TextFormField(
key: fieldKey,
controller: textEditingController,
focusNode: focusNode,
onFieldSubmitted: (String value) {
RawAutocomplete.onFieldSubmitted(autocompleteKey);
}, },
), ),
), ),
body: RawAutocomplete<String>(
key: autocompleteKey,
focusNode: focusNode,
textEditingController: textEditingController,
optionsBuilder: (TextEditingValue textEditingValue) {
return kOptions.where((String option) {
return option.contains(textEditingValue.text.toLowerCase());
});
},
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(fieldKey), findsOneWidget);
expect(find.byKey(optionsKey), findsNothing); expect(find.byKey(optionsKey), findsNothing);
// Focus the empty field. The options are not displayed because // Enter text. The options are filtered by the text.
// optionsBuilder returns nothing for an empty field query. focusNode.requestFocus();
focusNode.requestFocus(); textEditingController.value = const TextEditingValue(
textEditingController.value = const TextEditingValue( text: 'ele',
text: '', selection: TextSelection(baseOffset: 3, extentOffset: 3),
selection: TextSelection(baseOffset: 0, extentOffset: 0), );
); await tester.pump();
await tester.pump(); expect(find.byKey(fieldKey), findsOneWidget);
expect(find.byKey(optionsKey), findsNothing); expect(find.byKey(optionsKey), findsOneWidget);
expect(lastOptions.length, 2);
// Enter text. Now the options appear, filtered by the text. expect(lastOptions.elementAt(0), 'chameleon');
textEditingController.value = const TextEditingValue( expect(lastOptions.elementAt(1), 'elephant');
text: 'ele',
selection: TextSelection(baseOffset: 3, extentOffset: 3), // Submit the field. The options hide and the field updates to show the
); // selection.
await tester.pump(); await tester.showKeyboard(find.byType(TextFormField));
expect(find.byKey(fieldKey), findsOneWidget); await tester.testTextInput.receiveAction(TextInputAction.done);
expect(find.byKey(optionsKey), findsOneWidget); await tester.pump();
expect(lastOptions.length, 2); expect(find.byKey(fieldKey), findsOneWidget);
expect(lastOptions.elementAt(0), 'chameleon'); expect(find.byKey(optionsKey), findsNothing);
expect(lastOptions.elementAt(1), 'elephant'); expect(textEditingController.text, lastOptions.elementAt(0));
});
}); });
} }
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