Kotlin Android debounce
Solution 1
You can use kotlin coroutines to achieve that. Here is an example.
Be aware that coroutines are experimental at kotlin 1.1+ and it may be changed in upcoming kotlin versions.
UPDATE
Since Kotlin 1.3 release, coroutines are now stable.
Solution 2
I've created a gist with three debounce operators inspired by this elegant solution from Patrick where I added two more similar cases: throttleFirst
and throttleLatest
. Both of these are very similar to their RxJava analogues (throttleFirst, throttleLatest).
throttleLatest
works similar to debounce
but it operates on time intervals and returns the latest data for each one, which allows you to get and process intermediate data if you need to.
fun <T> throttleLatest(
intervalMs: Long = 300L,
coroutineScope: CoroutineScope,
destinationFunction: (T) -> Unit
): (T) -> Unit {
var throttleJob: Job? = null
var latestParam: T
return { param: T ->
latestParam = param
if (throttleJob?.isCompleted != false) {
throttleJob = coroutineScope.launch {
delay(intervalMs)
latestParam.let(destinationFunction)
}
}
}
}
throttleFirst
is useful when you need to process the first call right away and then skip subsequent calls for some time to avoid undesired behavior (avoid starting two identical activities on Android, for example).
fun <T> throttleFirst(
skipMs: Long = 300L,
coroutineScope: CoroutineScope,
destinationFunction: (T) -> Unit
): (T) -> Unit {
var throttleJob: Job? = null
return { param: T ->
if (throttleJob?.isCompleted != false) {
throttleJob = coroutineScope.launch {
destinationFunction(param)
delay(skipMs)
}
}
}
}
debounce
helps to detect the state when no new data is submitted for some time, effectively allowing you to process a data when the input is completed.
fun <T> debounce(
waitMs: Long = 300L,
coroutineScope: CoroutineScope,
destinationFunction: (T) -> Unit
): (T) -> Unit {
var debounceJob: Job? = null
return { param: T ->
debounceJob?.cancel()
debounceJob = coroutineScope.launch {
delay(waitMs)
destinationFunction(param)
}
}
}
All these operators can be used as follows:
val onEmailChange: (String) -> Unit = throttleLatest(
300L,
viewLifecycleOwner.lifecycleScope,
viewModel::onEmailChanged
)
emailView.onTextChanged(onEmailChange)
Solution 3
For a simple approach from inside a ViewModel
, you can just launch a job within the viewModelScope
, keep track of the job, and cancel it if a new value arises before the job is complete:
private var searchJob: Job? = null
fun searchDebounced(searchText: String) {
searchJob?.cancel()
searchJob = viewModelScope.launch {
delay(500)
search(searchText)
}
}
Solution 4
I use a callbackFlow and debounce from Kotlin Coroutines to achieve debouncing. For example, to achieve debouncing of a button click event, you do the following:
Create an extension method on Button
to produce a callbackFlow
:
fun Button.onClicked() = callbackFlow<Unit> {
setOnClickListener { offer(Unit) }
awaitClose { setOnClickListener(null) }
}
Subscribe to the events within your life-cycle aware activity or fragment. The following snippet debounces click events every 250ms:
buttonFoo
.onClicked()
.debounce(250)
.onEach { doSomethingRadical() }
.launchIn(lifecycleScope)
Solution 5
A more simple and generic solution is to use a function that returns a function that does the debounce logic, and store that in a val.
fun <T> debounce(delayMs: Long = 500L,
coroutineContext: CoroutineContext,
f: (T) -> Unit): (T) -> Unit {
var debounceJob: Job? = null
return { param: T ->
if (debounceJob?.isCompleted != false) {
debounceJob = CoroutineScope(coroutineContext).launch {
delay(delayMs)
f(param)
}
}
}
}
Now it can be used with:
val handleClickEventsDebounced = debounce<Unit>(500, coroutineContext) {
doStuff()
}
fun initViews() {
myButton.setOnClickListener { handleClickEventsDebounced(Unit) }
}
Comments
-
Kyryl Zotov almost 2 years
Is there any fancy way to implement
debounce
logic with Kotlin Android?I'm not using Rx in project.
There is a way in Java, but it is too big as for me here.
-
Alexey Romanov almost 6 yearsAre you looking for a coroutine-based solution?
-
Kyryl Zotov almost 6 years@AlexeyRomanov Yes, as I understood it would be very efficient.
-
EpicPandaForce almost 6 yearsPossible duplicate of Throttle onQueryTextChange in SearchView
-
EpicPandaForce almost 6 yearsstackoverflow.com/a/50007453/2413303 this guy had a co-routine answer on the question i marked duplicate.
-
Kyryl Zotov almost 6 years@EpicPandaForce Would try out and update, but the question seems different then that.
-
-
Joshua King over 5 yearsUnfortunately this seems to be out of date now that 1.3.x has been released.
-
CoolMind over 5 years@JoshuaKing, yes. Maybe medium.com/@pro100svitlo/… will help. I will try later.
-
Joshua King over 5 yearsThanks! Because this is super useful, but I need to update. Thanks.
-
EpicPandaForce over 5 yearsAre you sure that Channels are considered stable?
-
CoolMind about 5 yearsSorry, I understood, that if we run several queries, they would return asynchronously. So, it is not guaranteed, that the last request will return last data and you will update a view with correct data.
-
CoolMind almost 5 yearsAlso, as I understood,
textChangedJob?.cancel()
won't cancel a request in Retrofit. So, be ready that you will get all responses from all requests in random sequence. -
CoolMind almost 5 yearsPossibly a new version of Retrofit (2.6.0) will help.
-
Kebab Krabby over 4 yearsit would be nice with some output.
-
CommonsWare over 4 yearsNote that
onTextChanged()
is not in the Android SDK, though this post contains an implementation that is compatible. -
tech_human over 4 yearsI am following your logic for my debounce code. However in my case, doStuff() accepts params. Is there a way I can pass params while calling "handleClickEventsDebounced" which then gets passed down to doStuff()?
-
Patrick Jackson over 4 yearsSure, this snippet supports it. In the example above
handleClickEventsDebounced(Unit)
Unit
is the param. It can be any type that you would like since we using generics. For a String for example do this: val handleClickEventsDebounced = debounce<String> = debounce<String>(500, coroutineContext) { doStuff(it) } Where 'it' is the string passed. Or name it with { myString -> doStuff(myString) } -
GuilhE over 4 yearsI'm calling like this:
protected fun throttleClick(clickAction: (Unit) -> Unit) { viewModelScope.launch { throttleFirst(scope = this, action = clickAction) } }
but nothing happens, it just returns, the throttleFirst returningfun
doesn't fire. Why? -
GuilhE over 4 yearsHello @Patrick, can you help me with this: stackoverflow.com/q/59413001/1423773? I bet it's missing a minor detail, but I can't find it.
-
Terenfear over 4 years@GuilhE These three functions are not suspending, so you don't have to call them inside a coroutine scope. As for throttling clicks, I usually handle it on the fragment/activity side, something like
val invokeThrottledAction = throttleFirst(lifecycleScope, viewModel::doSomething); button.setOnClickListener { invokeThrottledAction() }
Basically you have to first create a function object and then call it whenever you need. -
GuilhE over 4 yearsOk @Terenfear I got it now, thanks for your explanation. Regarding the coroutine scope, since we have a delay we need a coroutine scope, I've just abstracted that from the ViewModel caller (I'm using Data Binding) the clicks aren't created in the View) and that code is in a "BaseViewModel". Or you are saying that instead of
launch
to get a scope I could just callval ViewModel.viewModelScope: CoroutineScope
since I'm already inside a ViewModel (if so, you are correct)? Btw, really helpful functions, thanks! -
Machado about 4 yearsThis remembers me of JavaScript's Underscore implementation, which I really like for the simplicity. Congrats and thanks!
-
I.Step over 3 yearsWhy 2nd, 3rd or 4th call etc. does not see Job as null?
-
Terenfear over 3 years@I.Step let's take
fun <T> debounce(...)
for example, it's the same with other functions. We call it once and it creates a function object of type(T) -> Unit
, which is kinda similar to to an object of anonymous Java class with one method. A Job reference is contained inside that object (kinda like inside a private field of an anonymous Java class). Each time something happens we use (call) the same function object. So, it doesn't matter how many times we call, it is the same object that has the same Job reference. I might be slightly wrong with the terms, but that's how I understand it. -
Alan Nelson about 3 yearsI'm having trouble getting viewLifecycleOwner.lifecycleScope inside an Adapter (or even in my fragment!) Where does this come from? I could find viewLifecycleOwner.lifecycle in the fragment, but there is no LifecycleScope.
-
Terenfear about 3 years@AlanNelson
viewLifecyleOwner.lifecycleScope
comes from theandroidx.lifecycle:lifecycle-runtime-ktx:2.2.0
. Here's more info: developer.android.com/topic/libraries/architecture/… -
Tenfour04 about 3 yearsYes! I don't know why so many answers here are complicating this with coroutines.
-
Simon about 3 yearsThis implementation does not debounce but it throttles.
-
Mabsten almost 3 yearsA curiosity: why almost all the solutions propose the use of coroutines, forcing among the rest to add a specific dependency (the question does not say that the coroutines are already in use)? For such a simple operation, don't they add unnecessary overhead? Isn't it better to use
System.currentTimeMillis()
or similar? -
gmk57 almost 2 yearsThis
throttleLatest
, in contrast to RxJava's, does not emit the first value immediately. This is more similar tothrottleLast
, see the difference. BTW,debounce
andsample
(=throttleLast
) are already implemented in Kotlin Flows, butthrottleLatest
is tricky to implement. -
gmk57 almost 2 yearsJust found a simple implementation of
throttleLatest
. And if your Flow is already conflated (e.g.StateFlow
), you can justdelay
at the end of the collector.