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

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))));

Tags:

Dart

Flutter