'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.

Example Flutter Application image

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