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)
class RawAutocomplete<T extends Object> extends StatefulWidget {
/// Create an instance of RawAutocomplete.
///
/// [fieldViewBuilder] and [optionsViewBuilder] must not be null.
/// [displayStringForOption], [optionsBuilder] and [optionsViewBuilder] must
/// not be null.
const RawAutocomplete({
Key? key,
required this.fieldViewBuilder,
required this.optionsViewBuilder,
required this.optionsBuilder,
this.displayStringForOption = _defaultStringForOption,
this.fieldViewBuilder,
this.focusNode,
this.onSelected,
this.textEditingController,
}) : 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(optionsViewBuilder != null),
assert((focusNode == null) == (textEditingController == 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
/// 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.
///
......@@ -468,6 +578,31 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
/// current TextEditingValue.
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.
static String _defaultStringForOption(dynamic option) {
return option.toString();
......@@ -480,8 +615,8 @@ class RawAutocomplete<T extends Object> extends StatefulWidget {
class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>> {
final GlobalKey _fieldKey = GlobalKey();
final LayerLink _optionsLayerLink = LayerLink();
final TextEditingController _textEditingController = TextEditingController();
final FocusNode _focusNode = FocusNode();
late TextEditingController _textEditingController;
late FocusNode _focusNode;
Iterable<T> _options = Iterable<T>.empty();
T? _selection;
......@@ -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
void initState() {
super.initState();
_textEditingController = widget.textEditingController ?? TextEditingController();
_textEditingController.addListener(_onChangedField);
_focusNode = widget.focusNode ?? FocusNode();
_focusNode.addListener(_onChangedFocus);
SchedulerBinding.instance!.addPostFrameCallback((Duration _) {
_updateOverlay();
......@@ -567,6 +744,11 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
@override
void didUpdateWidget(RawAutocomplete<T> oldWidget) {
super.didUpdateWidget(oldWidget);
_updateTextEditingController(
oldWidget.textEditingController,
widget.textEditingController,
);
_updateFocusNode(oldWidget.focusNode, widget.focusNode);
SchedulerBinding.instance!.addPostFrameCallback((Duration _) {
_updateOverlay();
});
......@@ -575,7 +757,13 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
@override
void dispose() {
_textEditingController.removeListener(_onChangedField);
if (widget.textEditingController == null) {
_textEditingController.dispose();
}
_focusNode.removeListener(_onChangedFocus);
if (widget.focusNode == null) {
_focusNode.dispose();
}
_floatingOptions?.remove();
_floatingOptions = null;
super.dispose();
......@@ -587,7 +775,9 @@ class _RawAutocompleteState<T extends Object> extends State<RawAutocomplete<T>>
key: _fieldKey,
child: CompositedTransformTarget(
link: _optionsLayerLink,
child: widget.fieldViewBuilder(
child: widget.fieldViewBuilder == null
? const SizedBox.shrink()
: widget.fieldViewBuilder!(
context,
_textEditingController,
_focusNode,
......
......@@ -45,7 +45,6 @@ void main() {
User(name: 'Charlie', email: 'charlie123@gmail.com'),
];
group('RawAutocomplete', () {
testWidgets('can filter and select a list of string options', (WidgetTester tester) async {
final GlobalKey fieldKey = GlobalKey();
final GlobalKey optionsKey = GlobalKey();
......@@ -327,6 +326,9 @@ void main() {
),
);
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(
......@@ -486,5 +488,70 @@ void main() {
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);
},
),
),
),
);
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: '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');
// Submit the field. The options hide and the field updates to show the
// selection.
await tester.showKeyboard(find.byType(TextFormField));
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(find.byKey(fieldKey), findsOneWidget);
expect(find.byKey(optionsKey), findsNothing);
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