How to debounce search suggestions in flutter's SearchPage Widget?

Viewed 14163

I need to have Google Places search suggestions using the default flutter's SearchPage, whenever the user starts typing I need to give autocomplete suggestions and I achieve this Asynchronously using FutureBuilder, the problem now is that I need to debounce the dispatch of search requests for 500ms or more rather than wasting a lot of requests while the user is still typing

To summarize what I've done so far:

1) In my widget I call

showSearch(context: context, delegate: _delegate);

2) My delegate looks like this:

class _LocationSearchDelegate extends SearchDelegate<Suggestion> {   
  @override
  List<Widget> buildActions(BuildContext context) {
    return <Widget>[
      IconButton(
        tooltip: 'Clear',
        icon: const Icon(Icons.clear),
        onPressed: () {
          query = '';
          showSuggestions(context);
        },
      )
    ];
  }

  @override
  Widget buildLeading(BuildContext context) => IconButton(
        tooltip: 'Back',
        icon: AnimatedIcon(
          icon: AnimatedIcons.menu_arrow,
          progress: transitionAnimation,
        ),
        onPressed: () {
          close(context, null);
        },
      );

  @override
  Widget buildResults(BuildContext context) {
    return FutureBuilder<List<Suggestion>>(
      future: GooglePlaces.getInstance().getAutocompleteSuggestions(query),
      builder: (BuildContext context, AsyncSnapshot<List<Suggestion>> suggestions) {
        if (!suggestions.hasData) {
          return Text('No results');
        }
        return buildLocationSuggestions(suggestions.data);
      },
    );
  }

  @override
  Widget buildSuggestions(BuildContext context) {
    return buildResults(context);
  }

  Widget buildLocationSuggestions(List<Suggestion> suggestions) {
    return ListView.builder(
      itemBuilder: (context, index) => ListTile(
            leading: Icon(Icons.location_on),
            title: Text(suggestions[index].text),
            onTap: () {
              showResults(context);
            },
          ),
      itemCount: suggestions.length,
    );
  }
}

3-I need to throttle / debounce searching until xxx Milliseconds have passed

I have 1 idea in mind, would there be an easy way to convert FutureBuilder to Stream and Use Stream builder, (which I read in some articles that it supports debouncing)?

**I am aware that there are some 3rd party AutoComplete widgets like TypeAhead that's doing that (from scratch) but I don't wanna use this at the moment.

5 Answers

Update: I made a package for this that works with callbacks, futures, and/or streams. https://pub.dartlang.org/packages/debounce_throttle. Using it would simplify both of the approaches described below, especially the stream based approach as no new classes would need to be introduced. Here's a dartpad example https://dartpad.dartlang.org/e4e9c07dc320ec400a59827fff66bb49.

There are at least two ways of doing this, a Future based approach, and a Stream based approach. Similar questions have gotten pointed towards using Streams since debouncing is built in, but let's look at both methods.

Future-based approach

Futures themselves aren't cancelable, but the underlying Timers they use are. Here's a simple class that implements basic debounce functionality, using a callback instead of a Future.

class Debouncer<T> {
  Debouncer(this.duration, this.onValue);
  final Duration duration;
  void Function(T value) onValue;
  T _value;
  Timer _timer;
  T get value => _value;
  set value(T val) {
    _value = val;
    _timer?.cancel();
    _timer = Timer(duration, () => onValue(_value));
  }  
}

Then to use it (DartPad compatible):

import 'dart:async';

void main() {      
  final debouncer = Debouncer<String>(Duration(milliseconds: 250), print);
  debouncer.value = '';
  final timer = Timer.periodic(Duration(milliseconds: 200), (_) {
    debouncer.value += 'x';
  });
  /// prints "xxxxx" after 1250ms.
  Future.delayed(Duration(milliseconds: 1000)).then((_) => timer.cancel()); 
}

Now to turn the callback into a Future, use a Completer. Here's an example that debounces the List<Suggestion> call to Google's API.

void main() {
  final completer = Completer<List<Suggestion>>();  
  final debouncer = Debouncer<String>(Duration(milliseconds: 250), (value) async {        
    completer.complete(await GooglePlaces.getInstance().getAutocompleteSuggestions(value));
  });           

  /// Using with a FutureBuilder.
  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<Suggestion>>(
      future: completer.future,
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return Text(snapshot.data);
        } else if (snapshot.hasError) {
          return Text('${snapshot.error}');
        } else {
          return Center(child: CircularProgressIndicator());
        }
      },
    );
  }
}

Stream-based approach

Since the data in question arrives from a Future and not a Stream, we have to setup a class to handle query inputs and suggestion outputs. Luckily it handles debouncing the input stream naturally.

class SuggestionsController {
  SuggestionsController(this.duration) {
    _queryController.stream
        .transform(DebounceStreamTransformer(duration))
        .listen((query) async {
      _suggestions.add(
          await GooglePlaces.getInstance().getAutocompleteSuggestions(query));
    });
  }    

  final Duration duration;
  final _queryController = StreamController<String>();
  final _suggestions = BehaviorSubject<List<Suggestion>>();

  Sink<String> get query => _queryController.sink;
  Stream<List<Suggestion>> get suggestions => _suggestions.stream;

  void dispose() {
    _queryController.close();
    _suggestions.close();
  }
}

To use this controller class in Flutter, let's create a StatefulWidget that will manage the controller's state. This part includes the call to your function buildLocationSuggestions().

class SuggestionsWidget extends StatefulWidget {
  _SuggestionsWidgetState createState() => _SuggestionsWidgetState();
}

class _SuggestionsWidgetState extends State<SuggestionsWidget> {
  final duration = Duration(milliseconds: 250);
  SuggestionsController controller;

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<List<Suggestion>>(
      stream: controller.suggestions,
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return buildLocationSuggestions(snapshot.data);
        } else if (snapshot.hasError) {
          return Text('${snapshot.error}');
        } else {
          return Center(child: CircularProgressIndicator());
        }
      },
    );
  }

  @override
  void initState() {
    super.initState();
    controller = SuggestionsController(duration);
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  void didUpdateWidget(SuggestionsWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    controller.dispose();
    controller = SuggestionsController(duration);
  }
}

It's not clear from your example where the query string comes from, but to finish wiring this up, you would call controller.query.add(newQuery) and StreamBuilder handles the rest.

Conclusion

Since the API you're using yields Futures, it seems a little more straightforward to use that approach. The downside is the overhead of the Debouncer class and adding a Completer to tie it in to FutureBuilder.

The stream approach is popular, but includes a fair amount of overhead as well. Creating and disposing of the streams correctly can be tricky if you're not familiar.

Here's a simple alternative to the other answer.

import 'package:debounce_throttle/debounce_throttle.dart';

final debouncer = Debouncer<String>(Duration(milliseconds: 250));

Future<List<Suggestion>> queryChanged(String query) async {
  debouncer.value = query;      
  return GooglePlaces.getInstance().getAutocompleteSuggestions(await debouncer.nextValue)
}

  @override
  Widget buildResults(BuildContext context) {
    return FutureBuilder<List<Suggestion>>(
      future: queryChanged(query),
      builder: (BuildContext context, AsyncSnapshot<List<Suggestion>> suggestions) {
        if (!suggestions.hasData) {
          return Text('No results');
        }
        return buildLocationSuggestions(suggestions.data);
      },
    );
  }

That's roughly how you should be doing it I think.

Here are a couple of ideas for using a stream instead, using the debouncer.

void queryChanged(query) => debouncer.value = query;

Stream<List<Suggestion>> get suggestions async* {
   while (true)
   yield GooglePlaces.getInstance().getAutocompleteSuggestions(await debouncer.nexValue);
}

  @override
  Widget buildResults(BuildContext context) {
    return StreamBuilder<List<Suggestion>>(
      stream: suggestions,
      builder: (BuildContext context, AsyncSnapshot<List<Suggestion>> suggestions) {
        if (!suggestions.hasData) {
          return Text('No results');
        }
        return buildLocationSuggestions(suggestions.data);
      },
    );
  }

Or with a StreamTransformer.

Stream<List<Suggestion>> get suggestions => 
  debouncer.values.transform(StreamTransformer.fromHandlers(
    handleData: (value, sink) => sink.add(GooglePlaces.getInstance()
      .getAutocompleteSuggestions(value))));

I Simply did it this way no library required:

void searchWithThrottle(String keyword, {int throttleTime}) {
    _timer?.cancel();
    if (keyword != previousKeyword && keyword.isNotEmpty) {
      previousKeyword = keyword;
      _timer = Timer.periodic(Duration(milliseconds: throttleTime ?? 350), (timer) {
        print("Going to search with keyword : $keyword");
        search(keyword);
        _timer.cancel();
      });
    }
  }

Timer can be used to debounce search input.

  Timer debounce;

  void handleSearch(String value) {
    if (debounce != null) debounce.cancel();
    setState(() {
      debounce = Timer(Duration(seconds: 2), () {
        searchItems(value);
         //call api or other search functions here
      });
    });
  }

whenever a new input is added to the text box the function cancels previous timer and start a new one. The search function will be only initiated after 2 seconds of inactivity

I had trouble getting @Jacob Phillip's debounce_throttle package to work, as the code that worked for him back in 2018 no longer seems to work with the latest versions of Flutter / Dart. It waits for the debounce time to be reached but then executes all of the attempts at once after instead of just the last one.

I was able to get it working with some modifications. I would have posted this as a comment on his answer, but it's too long.

final _debouncer = Debouncer<String>(const Duration(milliseconds: 500), initialValue: '');
late String userQuery;

Future<Iterable<LocationModel>> onSearchChanged(TextEditingValue textEditingValue) async {
    // Debounce for half a second, so we don't make unnecessary api calls as the user types.
    _debouncer.value = userQuery = textEditingValue.text;
    await _debouncer.nextValue;
    if (textEditingValue.text != userQuery) {
      return const Iterable<LocationModel>.empty();
    }
    // Only retrieve location suggestions if the user typed at least 4 characters...
    if (textEditingValue.text.length < _userQueryMinSuggestionsLength) {
      return const Iterable<LocationModel>.empty();
    }

    // Get user's location.
    var position = await _locationService.getPosition();
    // Get location suggestions.
    var propertyMode = await _userSettingsService.propertyMode;
    try {
      _locationSuggestions = await _placesService.getLocationSuggestionsAsync(
          propertyMode, textEditingValue.text, position.latitude, position.longitude, LocationSearchType.property);
    } catch (e) {
      handleError(e, message: 'Error retrieving location suggestions');
    }
    notifyListeners();
    return _locationSuggestions ?? [];
  }
Related