Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AutocompleteCore #62927

Merged
merged 82 commits into from Oct 27, 2020
Merged

AutocompleteCore #62927

merged 82 commits into from Oct 27, 2020

Conversation

justinmc
Copy link
Contributor

@justinmc justinmc commented Aug 5, 2020

Description

This is the first of several PRs implementing autocomplete. This PR only contains AutocompleteCore, the minimal class in the widgets package. The full Autocomplete widget will be added in a subsequent PR.

See the design doc for an exploration of the overall autocomplete plan.

Related Issues

#46824

Tests

I added the following tests:

  • Autocomplete can filter results...
    • By default.
    • With a custom filter method.
    • With a custom data type.
  • AutocompleteCore can filter and select...
    • Strings
    • Custom data types
  • The results follow the field even when it moves.
  • Can select by submitting the field.
  • Custom display strings and custom filter strings work for advanced searching.

Breaking Change

No, all new API.

Example

Here is an example I wrote with an AutocompleteCore inside of a Form. The AutocompleteCore is the last field before the submit button.

https://github.com/justinmc/flutter-autocomplete-examples/blob/master/lib/core_basic_form_page.dart

autocomplete_form_2

@justinmc justinmc self-assigned this Aug 5, 2020
@flutter-dashboard flutter-dashboard bot added the framework flutter/packages/flutter repository. See also f: labels. label Aug 5, 2020
@justinmc justinmc marked this pull request as draft August 5, 2020 00:41
this.options,
this.search,
TextEditingController textEditingController,
}) : assert(search != null || options != null, "If a search function isn't specified, Autocomplete will search by string on the given options."),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a hint to what the user might want to do to correct their app if they trigger the assert, like:

Suggested change
}) : assert(search != null || options != null, "If a search function isn't specified, Autocomplete will search by string on the given options."),
}) : assert(
search != null || options != null,
"Both `search` and `options` cannot be simultaneously null. "
"If a `search` function isn't specified, Autocomplete will attempt "
"to search by matching the string on the given options."
),

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe touch upon how _searchByString might work for non-string options

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed how this works a bit, broken this assert into two, and put most of the details in the documentation.

/// case when querying an external service for search results, for example.
///
/// Defaults to a simple string-matching search of [options].
final AutocompleteSearchFunction<T> search;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would AutocompleteFilter and filter be a better names for this function, since it returns a list of filtered options?

This is from my JS background seeing that Array.filter() basically "filters" for values that pass a test, whereas String.search() basically searches for substrings in a string and returns the position of the match. That might not be a very compelling argument, but I figured if the terminology matched another language's, it might feel more "ergonomic" to our users.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think you're right. This function may sometimes get more complicated than a simple filter, such as when calling a backend endpoint, but I think that it will always have the ability to return multiple results, which is more like a filter than a search. I'll change the naming.


/// The current results being returned by [search].
///
/// This is a [ValueNotifier] so that UI may be updated when results change.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if this might also be used to update the app's state as well (if the app developer somehow wanted to store the results for any reason).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, I'll reword it to be more generic.

// the dispose method of the widget it was created in.
void dispose() {
textEditingController.removeListener(_onQueryChanged);
// TODO(justinmc): Shouldn't be disposed if it wasn't created here.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to provide a dispose method for the AutocompleteController. In the same vein, do we need to remove the listener before disposing of the TextEditingController? If we don't have to explicitly remove listeners before disposing, we could look at animation controller usage as an example and simply advise users to dispose of the TextEditingController directly from their app.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've worked around this by adding a bool _ownsTextEditingController that is set to true when a TextEditingController isn't passed in. That way I can decide to dispose the TextEditingController only if it was created by the AutocompleteController. Otherwise it's up to the creator that passed it in to dispose it.

Comment on lines 124 to 133
/// Builds the field that is used to input the query.
final AutocompleteFieldBuilder buildField;

/// Builds the selectable results of searching.
final AutocompleteResultsBuilder<T> buildResults;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At some point, we may need to provide a very simple example for these two so that users know what they're doing here. Then again, it might not be necessary if we have examples via Autocomplete and AutocompleteCupertino implementations, as long as they're simple enough.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I agree. I will add a top level example to AutocompleteCore for this PR at least. It might be nice to add them to buildField and buildResults as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added an example to AutocompleteCore and AutocompleteController, but I didn't add them to the builders since it's covered by the AutocompleteCore example. When we implement the Material Autocomplete we should add an example to buildField and buildResults there, because there they have default values and can be used independently of each other.

void onSelected (T result) {
setState(() {
_selection = result;
widget.autocompleteController.textEditingController.text = result.toString();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is T.toString()-ing non-string types a normal thing for the TextEditingController? I notice you did that here and with the _searchByString, and I'm wondering how this actually works out in practice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking that T will often be some app-specific type like a User object, and if the developer wants Autocomplete to search their Users automatically, then they need to provide a sensible toString method. If their search logic is even more complicated than that, then they can pass a custom search function.

Actually I just added an example of this to the AutocompleteCore docs because you're right that it's not immediately clear how to do this. Let me know it makes sense to you after looking at the example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also ended up adding the onSelected parameter to allow users to customize that. It's useful when using a custom type in case you want to populate the field with something different than toString. It's also in the example.

widget.autocompleteController.textEditingController,
),
if (_selection == null)
// TODO(justinmc): should this expanded be here?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like in most cases, this does make sense. I'm trying to imagine an odd layout where a developer would not want the results to be expanded, but can't off the top of my head.

// True iff the state indicates that the options should be visible.
bool get _shouldShowOptions {
final TextSelection selection = _textEditingController.selection;
final bool fieldIsFocused = selection.baseOffset >= 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah sounds good. On desktop people may want to navigate through the options using the keyboard without unfocusing the text field, and unfortunately the text field and the options UI are on different overlay entries, so each option may want to attach to the text field's FocusNode as well.

required this.optionsBuilder,
this.displayStringForOption = _defaultStringForOption,
this.onSelected,
}) : assert(fieldViewBuilder != null),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we still need these for a while? dart-lang/language#1018.

_textEditingController.value,
);
assert(options != null);
setState(() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: do we need to rebuild this widget? TextEditingController will rebuild the text field if needed, and the options UI is on a different overlay entry?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also is the setState in _select needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, I was able to remove these by refactoring a bit to make sure updateOverlay is called in the right place. When we implement flat results we may need to add them back, though.


// Select the given option and update the widget.
void _select(T? nextSelection) {
if (nextSelection == _selection) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When should the developer call _select with null?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also does this mean we want the developer to implement == and hashCode, if they're generating the list of options on the fly?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling _select with null: Good point, they shouldn't. I will make it non-nullable. If the user wants to clear the selection, they can just clear or change the field.

About comparison: Yes, I will document that.

@justinmc
Copy link
Contributor Author

@LongCatIsLooong Updated with your suggestions, thanks!

}
_selection = nextSelection;
final String selectionString = nextSelection == null
? ''
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: nextSelection can not be null?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, thanks!

class _AutocompleteCoreState<T extends Object> extends State<AutocompleteCore<T>> {
final GlobalKey _fieldKey = GlobalKey();
final LayerLink _optionsLayerLink = LayerLink();
final TextEditingController _textEditingController = TextEditingController();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we let the user bring their own controller?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that is my next PR! I tried to simplify it by keeping it private for now. It will be necessary to use your own controller if you want to do things like split the field and results into different parts of the widget tree. I'll be tagging you for review soon :)

@zaidkazi
Copy link

zaidkazi commented Mar 7, 2021

How can the optionsBuilder be used to perform async tasks such as retrieving options from a third party search provider like Algolia rather than a data structure locally?

Ideally being able to pass a futureOption builder in async function would be awesome.

As an example

   optionsBuilder: (TextEditingValue textEditingValue) async {
                          if (textEditingValue.text == '') {
                            return const Iterable<AlgoliaObjectSnapshot>.empty();
                          }
                          return await algoliaService.algolia
                              .index("somealgoliaindex")
                              .setHitsPerPage(3)
                              .search(pattern)
                              .getObjects()
                              .hits;
                        },

@shihaohong
Copy link
Contributor

Hey @zaidkazi, we have discussed async support in the design doc if you're interested in taking a look. @justinmc has also created a proof-of-concept example that would use FutureOr here, and it's a feature we're planning on working on soon!

@zaidkazi
Copy link

zaidkazi commented Mar 8, 2021

@shihaohong that's awesome thank you

@JayPerfetto
Copy link

this feature is fairly useless without the ability to do async calls in it. It was never a challenge to filter a fixed list in memory based on a text fields input.....

@justinmc
Copy link
Contributor Author

justinmc commented Apr 1, 2021

I agree that that's a vital feature. I have vague plans for it which you've probably seen in the design doc. I want to make sure we get it right with efficient debouncing and everything. Let me know if you have any ideas.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
a: text input Entering text in a text field or keyboard related problems c: new feature Nothing broken; request for a new capability framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

10 participants