'Flutter: Autocomplete not showing suggestions when suggestion list changes
Framework & Architecture
I have a specific architecture in my Flutter app. I am using BLoC pattern (flutter_bloc
) to maintain state and fetch data from remote server.
How autocomplete should behave
I want to build autocomplete input. When user types, it starts fetching data from server after few milliseconds. As user types, the list of suggestions should be updated from the remote server and shown with filtered values to the user. Additionally, I need to set initial value of the autocomplete text field if such value is present 1. The way data is presented is also custom. Suggestion list presents user with suggestions containing both name
and id
values but text field can only contain name
value (this name
value is also used for searching the suggestions) 2.
I am not having much luck when using RawAutocomplete
widget from Flutter material library. I have succeeded in making the initial value appear in the field by leveraging TextEditingController
and didUpdateWidget
method. The problem is, when I'm typing into the field, the suggestions are being fetched and passed to the widget but the suggestion list (built via optionsViewBuilder
) is not being built. Usually the list appears if I change value in the field but that's too late to be useful.
This is what I have tried:
Link to live demo
NOTE: Try typing "xyz", that is a pattern that should match one of the suggestions. Waiting a bit and deleting single character will show the suggestions.
I am attaching two components as an example. Parent component called DetailPage
takes care of triggering fetch of the suggestions and also stores selected suggestion / value of the input. Child component DetailPageForm
contains actual input.
The example is artificially constrained but it is in regular MaterialApp
parent widget. For brevity I'm not including BLoC code and just using regular streams. The code runs fine and I created it specifically for this example.
DetailPage
import 'dart:async';
import 'package:flutter/material.dart';
import 'detail_page_form.dart';
@immutable
class Suggestion {
const Suggestion({
this.id,
this.name,
});
final int id;
final String name;
}
class MockApi {
final _streamController = StreamController<List<Suggestion>>();
Future<void> fetch() async {
await Future.delayed(Duration(seconds: 2));
_streamController.add([
Suggestion(id: 1, name: 'xyz'),
Suggestion(id: 2, name: 'jkl'),
]);
}
void dispose() {
_streamController.close();
}
Stream<List<Suggestion>> get stream => _streamController.stream;
}
class DetailPage extends StatefulWidget {
final _mockApi = MockApi();
void _fetchSuggestions(String query) {
print('Fetching with query: $query');
_mockApi.fetch();
}
@override
_DetailPageState createState() => _DetailPageState(
onFetch: _fetchSuggestions,
stream: _mockApi.stream,
);
}
class _DetailPageState extends State<DetailPage> {
_DetailPageState({
this.onFetch,
this.stream,
});
final OnFetchCallback onFetch;
final Stream<List<Suggestion>> stream;
/* NOTE: This value can be used for initial value of the
autocomplete input
*/
Suggestion _value;
_handleSelect(Suggestion suggestion) {
setState(() {
_value = suggestion;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Detail')),
body: StreamBuilder<List<Suggestion>>(
initialData: [],
stream: stream,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Container(
padding: const EdgeInsets.all(10.0),
decoration: BoxDecoration(
color: Colors.red,
),
child: Flex(
direction: Axis.horizontal,
children: [ Text(snapshot.error.toString()) ]
)
);
}
return DetailPageForm(
list: snapshot.data,
value: _value != null ? _value.name : '',
onSelect: _handleSelect,
onFetch: onFetch,
);
}));
}
}
DetailPageForm
import 'dart:async';
import 'package:flutter/material.dart';
import 'detail_page.dart';
typedef OnFetchCallback = void Function(String);
typedef OnSelectCallback = void Function(Suggestion);
class DetailPageForm extends StatefulWidget {
DetailPageForm({
this.list,
this.value,
this.onFetch,
this.onSelect,
});
final List<Suggestion> list;
final String value;
final OnFetchCallback onFetch;
final OnSelectCallback onSelect;
@override
_DetailPageFormState createState() => _DetailPageFormState();
}
class _DetailPageFormState extends State<DetailPageForm> {
Timer _debounce;
TextEditingController _controller = TextEditingController();
FocusNode _focusNode = FocusNode();
List<Suggestion> _list;
@override
void initState() {
super.initState();
_controller.text = widget.value ?? '';
_list = widget.list;
}
@override
void dispose() {
super.dispose();
_controller.dispose();
}
@override
void didUpdateWidget(covariant DetailPageForm oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value) {
_controller = TextEditingController.fromValue(TextEditingValue(
text: widget.value,
selection: TextSelection.fromPosition(TextPosition(offset: widget.value.length)),
));
}
if (oldWidget.list != widget.list) {
setState(() {
_list = widget.list;
});
}
}
void _handleInput(String value) {
if (_debounce != null && _debounce.isActive) {
_debounce.cancel();
}
_debounce = Timer(const Duration(milliseconds: 300), () {
widget.onFetch(value);
});
}
@override
Widget build(BuildContext context) {
print(_list);
return Container(
padding: const EdgeInsets.all(10.0),
child: RawAutocomplete<Suggestion>(
focusNode: _focusNode,
textEditingController: _controller,
optionsBuilder: (TextEditingValue textEditingValue) {
return _list.where((Suggestion option) {
return option.name
.trim()
.toLowerCase()
.contains(textEditingValue.text.trim().toLowerCase());
});
},
fieldViewBuilder: (BuildContext context,
TextEditingController textEditingController,
FocusNode focusNode,
VoidCallback onFieldSubmitted) {
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
onChanged: _handleInput,
onFieldSubmitted: (String value) {
onFieldSubmitted();
},
);
},
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4.0,
child: SizedBox(
height: 200.0,
child: ListView.builder(
padding: const EdgeInsets.all(8.0),
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final option = options.elementAt(index);
return GestureDetector(
onTap: () {
onSelected(option);
},
child: ListTile(
title: Text('${option.id} ${option.name}'),
),
);
},
),
),
),
);
},
onSelected: widget.onSelect,
));
}
}
On the image you can see right at the end that I have to delete one letter to get the suggestions to show up.
Expected behaviour
I expect the suggestions list to be re-built every time new suggestions are available and provide them to the user.
1 The reason for that being that the input should show user a value that was selected before. This value might also be stored on the device. So the input is either empty or pre-filled with the value.
2 This example is constrained but basically text field should not contain the same text that the suggestions contain for specific reasons.
Solution 1:[1]
I solved this by calling the notifyListeners method which exists on the TextEditingController while I was setting the suggestions to the state.
setState(() {
_isFetching = false;
_suggestions = suggestions.sublist(0, min(suggestions.length, 5));
_searchController.notifyListeners();
});
The linter did say I should be implementing the ChangeNotifier class onto the Widget, but In this case I did not have to, it worked without it.
Solution 2:[2]
Move your _handleInput
inside optionsBuilder
becucaue the latter is called first.
optionsBuilder: (TextEditingValue textEditingValue) {
_handleInput(textEditingValue.text); // await if necessary
return _list.where((Suggestion option) {
return option.name
.trim()
.toLowerCase()
.contains(textEditingValue.text.trim().toLowerCase());
});
},
Solution 3:[3]
In 'optionsViewBuilder' in DetailsFormPage you need to pass _list instead of options.
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | RiskyTicTac |
Solution 2 | ghchoi |
Solution 3 |