How to debounce Textfield onChange in Dart?
Asked Answered
M

14

124

I'm trying to develop a TextField that update the data on a Firestore database when they change. It seems to work but I need to prevent the onChange event to fire multiple times.

In JS I would use lodash _debounce() but in Dart I don't know how to do it. I've read of some debounce libraries but I can't figure out how they work.

That's my code, it's only a test so something may be strange:

import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';


class ClientePage extends StatefulWidget {

  String idCliente;


  ClientePage(this.idCliente);

  @override
  _ClientePageState createState() => new _ClientePageState();

  
}

class _ClientePageState extends State<ClientePage> {

  TextEditingController nomeTextController = new TextEditingController();


  void initState() {
    super.initState();

    // Start listening to changes 
    nomeTextController.addListener(((){
        _updateNomeCliente(); // <- Prevent this function from run multiple times
    }));
  }


  _updateNomeCliente = (){

    print("Aggiorno nome cliente");
    Firestore.instance.collection('clienti').document(widget.idCliente).setData( {
      "nome" : nomeTextController.text
    }, merge: true);

  }



  @override
  Widget build(BuildContext context) {

    return new StreamBuilder<DocumentSnapshot>(
      stream: Firestore.instance.collection('clienti').document(widget.idCliente).snapshots(),
      builder: (BuildContext context, AsyncSnapshot<DocumentSnapshot> snapshot) {
        if (!snapshot.hasData) return new Text('Loading...');

        nomeTextController.text = snapshot.data['nome'];


        return new DefaultTabController(
          length: 3,
          child: new Scaffold(
            body: new TabBarView(
              children: <Widget>[
                new Column(
                  children: <Widget>[
                    new Padding(
                      padding: new EdgeInsets.symmetric(
                        vertical : 20.00
                      ),
                      child: new Container(
                        child: new Row(
                          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                          children: <Widget>[
                            new Text(snapshot.data['cognome']),
                            new Text(snapshot.data['ragionesociale']),
                          ],
                        ),
                      ),
                    ),
                    new Expanded(
                      child: new Container(
                        decoration: new BoxDecoration(
                          borderRadius: BorderRadius.only(
                            topLeft: Radius.circular(20.00),
                            topRight: Radius.circular(20.00)
                          ),
                          color: Colors.brown,
                        ),
                        child: new ListView(
                          children: <Widget>[
                            new ListTile(
                              title: new TextField(
                                style: new TextStyle(
                                  color: Colors.white70
                                ),
                                controller: nomeTextController,
                                decoration: new InputDecoration(labelText: "Nome")
                              ),
                            )
                          ]
                        )
                      ),
                    )
                  ],
                ),
                new Text("La seconda pagina"),
                new Text("La terza pagina"),
              ]
            ),
            appBar: new AppBar(
              title: Text(snapshot.data['nome'] + ' oh ' + snapshot.data['cognome']),
              bottom: new TabBar(          
                tabs: <Widget>[
                  new Tab(text: "Informazioni"),  // 1st Tab
                  new Tab(text: "Schede cliente"), // 2nd Tab
                  new Tab(text: "Altro"), // 3rd Tab
                ],
              ),
            ),
          )
        );
        
      },
    );

    print("Il widget id è");
    print(widget.idCliente);
    
  }
}
Momentarily answered 10/8, 2018 at 17:35 Comment(0)
M
281

Implementation

Import dependencies:

import 'dart:async';

In your widget state declare a timer:

Timer? _debounce;

Add a listener method:

_onSearchChanged(String query) {
    if (_debounce?.isActive ?? false) _debounce?.cancel();
    _debounce = Timer(const Duration(milliseconds: 500), () {
        // do something with query
    });
}

Don't forget to clean up:

@override
void dispose() {
    _debounce?.cancel();
    super.dispose();
}

Usage

In your build tree hook the onChanged event:


TextField(
    onChanged: _onSearchChanged,
    // ...
)
Manzo answered 22/10, 2018 at 13:8 Comment(3)
Is it fine to cancel it unconditionally even if the timer is finished? From my testing it worked, just want to make sure it's a good practice.Uncovenanted
@Uncovenanted if you dont cancel, you will keep the default behavior, you will only have the delay configured in the timerDeonnadeonne
what if _onSearchChanged needs the results to be returned? we can't really return them back to it, within the Timer callback..Lymphocytosis
S
120

You can make Debouncer class using Timer

import 'dart:async';

import 'package:flutter/foundation.dart';

class Debouncer {
  final int milliseconds;
  Timer? _timer;

  Debouncer({required this.milliseconds});

  void run(VoidCallback action) {
    _timer?.cancel();
    _timer = Timer(Duration(milliseconds: milliseconds), action);
  }

  void dispose() {
    _timer?.cancel();
  }
}

Declare it

final _debouncer = Debouncer(milliseconds: 500);

and trigger it

onTextChange(String text) {
  _debouncer.run(() => print(text));
}
Strongwilled answered 12/3, 2019 at 10:26 Comment(4)
I made some modifications to your answer in this gist: gist.github.com/venkatd/7125882a8e86d80000ea4c2da2c2a8ad. - Dropped dependency on Flutter so it can be used in pure Dart (no need for VoidCallback) - action instance var isn't used - used timer?.cancel() shorthand - swapped to Duration type in favor of passing in millisecondsSmyth
also don't forget to dispose the timerSpinel
how to dispose thisRayborn
@Rayborn See this gist.github.com/venkatd/…Baluster
D
35

Using BehaviorSubject from rxdart lib is a good solution. It ignores changes that happen within X seconds of the previous.

final searchOnChange = new BehaviorSubject<String>();
...
TextField(onChanged: _search)
...

void _search(String queryString) {
  searchOnChange.add(queryString);
}   

void initState() {    
  searchOnChange.debounceTime(Duration(seconds: 1)).listen((queryString) { 
  >> request data from your API
  });
}
Donny answered 15/2, 2019 at 16:0 Comment(1)
I think this approach debounces stream's emits not debouncing future function callsHerr
I
15

I like Dart's Callable Classes for my debounce class:

import 'dart:async';

class Debounce {
  Duration delay;
  Timer? _timer;

  Debounce(
    this.delay,
  );

  call(void Function() callback) {
    _timer?.cancel();
    _timer = Timer(delay, callback);
  }

  dispose() {
    _timer?.cancel();
  }
}

Usage is simple - example on dartpad

// 1 - Create a debounce instance
final Debounce _debounce = Debounce(Duration(milliseconds: 400));

// 2 - Use it
_debounce((){ print('First'); });
_debounce((){ print('Second'); });
_debounce((){ print('Third'); });

// ...after 400ms you'll see "Third"

For your specific example, it's important to dispose the timer, in case it makes use of your TextController after it's been disposed:

final TextEditingController _controller = TextEditingController();
final Debounce _debounce = Debounce(Duration(milliseconds: 400));

@override
void dispose() {
  _controller.dispose();
  _debounce.dispose();
  super.dispose();
}

@override
Widget build(BuildContext context) {
  return TextField(
    controller: _controller,
    onChanged: (String value) {
      _debounce((){
        print('Value is $value');
      });
    },
  );
}
Itinerant answered 2/9, 2021 at 13:9 Comment(0)
T
8

Here is my solution

 subject = new PublishSubject<String>();
      subject.stream
          .debounceTime(Duration(milliseconds: 300))
          .where((value) => value.isNotEmpty && value.toString().length > 1)
          .distinct()
          .listen(_search);
Trix answered 31/1, 2020 at 6:37 Comment(1)
If using Flutter, then subject is a field in your widget. This code above from the answer needs to go to initState(), the _search function will handle your debounced search query, and your onChange callback in TextField will need to call subject.add(string).Concerted
D
7

Have a look at EasyDebounce.

EasyDebounce.debounce(
  'my-debouncer',                 // <-- An ID for this particular debouncer
   Duration(milliseconds: 500),    // <-- The debounce duration
  () => myMethod()                // <-- The target method
);
Dallasdalli answered 30/8, 2020 at 3:49 Comment(1)
How to use this when, myMethod returns a value ?Breezy
I
5

As others have suggested, implementing a custom debouncer class is not that difficult. You can also use a Flutter plugin, such as EasyDebounce.

In your case you'd use it like this:

import 'package:easy_debounce/easy_debounce.dart';

...

// Start listening to changes 
nomeTextController.addListener(((){
    EasyDebounce.debounce(
        '_updatenomecliente',        // <-- An ID for this debounce operation
        Duration(milliseconds: 500), // <-- Adjust Duration to fit your needs
        () => _updateNomeCliente()
    ); 
}));

Full disclosure: I'm the author of EasyDebounce.

Imp answered 8/9, 2019 at 17:10 Comment(2)
thanks magnus, u're library help me alot thanks :+1:Messiah
I have a method that should return a value, how to use this in that case since debounce can only return void ?Breezy
C
5

What about a utility function like:

import 'dart:async';

Function debounce(Function func, int milliseconds) {
  Timer timer;
  return () { // or (arg) if you need an argument
    if (timer != null) {
      timer.cancel();
    }

    timer = Timer(Duration(milliseconds: milliseconds), func); // or () => func(arg) 
  };
}

Then:

var debouncedSearch = debounce(onSearchChanged, 250);
_searchQuery.addListener(debouncedSearch);

In the future with variable arguments it could be improved.

Concubinage answered 7/6, 2020 at 13:25 Comment(0)
G
2

You can use rxdart package to create an Observable using a stream then debounce it as per your requirements. I think this link would help you get started.

Gyrose answered 10/8, 2018 at 18:49 Comment(1)
Thanks Bhanu, I have understood how to debounce a stream, but how do i get the one related to my widget event?Momentarily
F
1

Here's my two cents with preemptive debouncing.

import 'dart:async';

/// Used to debounce function call.
/// That means [runnable] function will be called at most once per [delay].
class Debouncer {
  int _lastTime;
  Timer _timer;
  Duration delay;

  Debouncer(this.delay)
      : _lastTime = DateTime.now().millisecondsSinceEpoch;
  
  run(Function runnable) {
    _timer?.cancel();

    final current = DateTime.now().millisecondsSinceEpoch;
    final delta = current - _lastTime;

    // If elapsed time is bigger than [delayInMs] threshold -
    // call function immediately.
    if (delta > delay.inMilliseconds) {
      _lastTime = current;
      runnable();
    } else {
      // Elapsed time is less then [delayInMs] threshold -
      // setup the timer
      _timer = Timer(delay, runnable);
    }
  }
}
Fadeless answered 23/8, 2020 at 15:1 Comment(0)
S
1

This solution works for me using RxDart

final _search = TextEditingController(text: '');
  RxString searchText = ''.obs;

  @override
  void initState() {
    super.initState();
    _search.addListener(() {
      searchText.value = _search.text;
    });
    debounce(searchText, (_) {
      listenToSearch();
    }, time: const Duration(seconds: 1));
  }

  listenToSearch() {
    AlertsFilterModel filter = widget.filter;
    filter.searchText = _search.text;
    widget.sendAlertFilters(filter);
  }
Starstudded answered 17/5, 2022 at 9:10 Comment(1)
easy to apply, thank youHelper
R
1

I've written an easy to use Debouncer class that works with async/await. This will work great with an Autocomplete widget and is very flexible - you can run anything you want after debouncing.

import 'dart:async';

class Debouncer<Return> {

  Duration debounceDuration;
  
  Map<DateTime, Completer> _completers = {};
  Timer? _timer;

  Debouncer(this.debounceDuration);

  Future<Return?> run(Future<Return> Function() toRun) async {
    _timer?.cancel();
    _completers.entries
        .map((e) => e.value)
        .forEach((completer) {
          completer.completeError(_CancelException());
        });
    print('❕ Cancelling previous completer and timer…');

    final now = DateTime.now();
    _completers[now] = Completer();
    _timer = Timer(debounceDuration, () {
      _completers[now]?.complete();
    });

    try {
      await _completers[now]?.future;
      _completers.remove(now);
      print('✅ Will execute toRun…');
      return await toRun();
    } catch (exception) {
      print('❌ Cancelled!');
      _completers.remove(now);
      return null;
    }
  }

}

class _CancelException implements Exception {
  const _CancelException() : super();
}

Usage

final _debouncer = Debouncer<GeocodingResponse>(Duration(milliseconds: 330));

// This can get called many times in quick succession,
// but it will only actually execute the query after 330 milliseconds have elapsed since the last call.
final response = await _debouncer.run(() {
  return network.geocode(query);
});

I explain the solution in more details with a demo in my blogpost.

Receiptor answered 6/11, 2023 at 11:28 Comment(0)
H
0
Timer? debouncer;

startDebouncer() {
    debouncer = Timer(const Duration(milliseconds: 600), () {//set your desired duration
      //perform your logic here
    });
  }

resetDebouncer() {
    debouncer?.cancel();
    startTimer();
  }

Now call this resetDebouncer method inside the onChanged callback of your textfield. And don't forget to import dart:async

Hire answered 3/8, 2023 at 12:19 Comment(0)
R
0

https://mcmap.net/q/179860/-how-to-debounce-textfield-onchange-in-dart is a nice solution but if you're using flutter-hooks the following approach has the advantage that the effect will be cancelled automatically if the widget is disposed.

useDebouncedEffect(Dispose? Function() cb, List<dynamic> deps, int time) {
  final timer = useState<Timer?>(null);
  final dispose = useState<Dispose?>(null);
  final isFirstRender = useState(true);

  useEffect(() {
    if (!isFirstRender.value) {
      timer.value?.cancel();
      timer.value = Timer(Duration(milliseconds: time), () {
        dispose.value = cb();
      });
      return dispose.value;
    } else {
      isFirstRender.value = false;
      return null;
    }
  }, deps);

  useEffect(() {
    return () {
      timer.value?.cancel();
    };
  }, []);
}

then to use

class DebouncedTextFieldExample extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final query = useState("");

    useDebouncedEffect(() {
      print('Text after debounce: ${textController.text}');
      return null;
    }, [query], 500); // 500 milliseconds debounce time

    return Scaffold(
      appBar: AppBar(title: Text('Debounced Text Field')),
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: TextField(
          decoration: InputDecoration(labelText: 'Type something...'),
          onChanged: (value) {
            query.value = value;
          },
        ),
      ),
    );
  }
}

Reft answered 30/8, 2023 at 16:41 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.