Kotlin Android debounce
E

16

76

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.

Ezzo answered 14/6, 2018 at 13:27 Comment(5)
Are you looking for a coroutine-based solution?Olmos
@AlexeyRomanov Yes, as I understood it would be very efficient.Ezzo
Possible duplicate of Throttle onQueryTextChange in SearchViewForewoman
https://mcmap.net/q/266681/-throttle-onquerytextchange-in-searchview this guy had a co-routine answer on the question i marked duplicate.Forewoman
@Forewoman Would try out and update, but the question seems different then that.Ezzo
E
8

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.

Emelineemelita answered 16/6, 2018 at 16:28 Comment(5)
Unfortunately this seems to be out of date now that 1.3.x has been released.Redundancy
@JoshuaKing, yes. Maybe medium.com/@pro100svitlo/… will help. I will try later.Beeck
Thanks! Because this is super useful, but I need to update. Thanks.Redundancy
Are you sure that Channels are considered stable?Forewoman
A 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?Kaohsiung
A
78

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(text: String) {
    searchJob?.cancel()
    searchJob = viewModelScope.launch {
        delay(500)
        search(text)
    }
}
Accidental answered 24/10, 2020 at 23:37 Comment(2)
Once you reach after the "delay", doesn't it mean it might be cancelled?Britneybritni
You can use currentCoroutineContext().isActive to check if the coroutine is still active or not.Intercom
A
69

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)
Alphonsa answered 29/7, 2019 at 11:22 Comment(14)
it would be nice with some output.Ewens
Note that onTextChanged() is not in the Android SDK, though this post contains an implementation that is compatible.Moitoso
I'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 returning fun doesn't fire. Why?Incurrent
@Incurrent 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.Alphonsa
Ok @Alphonsa 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 call val ViewModel.viewModelScope: CoroutineScope since I'm already inside a ViewModel (if so, you are correct)? Btw, really helpful functions, thanks!Incurrent
Why 2nd, 3rd or 4th call etc. does not see Job as null?Indicatory
@Indicatory 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.Alphonsa
I'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.Mismanage
@AlanNelson viewLifecyleOwner.lifecycleScope comes from the androidx.lifecycle:lifecycle-runtime-ktx:2.2.0. Here's more info: developer.android.com/topic/libraries/architecture/…Alphonsa
This throttleLatest, in contrast to RxJava's, does not emit the first value immediately. This is more similar to throttleLast, see the difference. BTW, debounce and sample (=throttleLast) are already implemented in Kotlin Flows, but throttleLatest is tricky to implement.Belicia
Just found a simple implementation of throttleLatest. And if your Flow is already conflated (e.g. StateFlow), you can just delay at the end of the collector.Belicia
In this cases I realize how bad I am at real thinking in functions. Thank you, very insightful.Carthusian
The Emperor's New Clothes. This solution is unreadable and overcomplicated. + throttleLatest not taking the last value. KISS.Vernacularism
What about race conditions? If a debounced method is called from 2 different threads, they could result in creating 2 jobs and only one of them is eventually stored in var debounceJob. The other job will keep running unless I'm missing something that will delete the job without a reference. Otherwise, I think a Channel is needed to make these debounce calls sequential.Prevalent
R
25

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) }
}
Ripe answered 23/4, 2019 at 0:57 Comment(5)
I 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()?Fossick
Sure, 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) }Ripe
Hello @Patrick, can you help me with this: https://mcmap.net/q/266682/-coroutine-doens-39-t-start/1423773? I bet it's missing a minor detail, but I can't find it.Incurrent
This remembers me of JavaScript's Underscore implementation, which I really like for the simplicity. Congrats and thanks!Sigridsigsmond
This implementation does not debounce but it throttles.Helsa
C
25

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)
Capacity answered 25/2, 2020 at 3:5 Comment(3)
This solution seems did not work goodly. you must handle the first click without delay requestMoria
@JavidSattar, I'm not sure what you mean? The first click will proceed as normal but following events are ignored for the duration of the timeout. kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/…Capacity
After testing, I realized that it does not manage the first click correctlyMoria
B
14

I have created a single extension function from the old answers of stack overflow:

fun View.clickWithDebounce(debounceTime: Long = 600L, action: () -> Unit) {
    this.setOnClickListener(object : View.OnClickListener {
        private var lastClickTime: Long = 0

        override fun onClick(v: View) {
            if (SystemClock.elapsedRealtime() - lastClickTime < debounceTime) return
            else action()

            lastClickTime = SystemClock.elapsedRealtime()
        }
    })
}

View onClick using below code:

buttonShare.clickWithDebounce { 
   // Do anything you want
}
Burnedout answered 5/6, 2019 at 14:28 Comment(4)
Yes! I don't know why so many answers here are complicating this with coroutines.Yanez
This is a throttling method, not a debouncing method. Throttling keeps the first input. Debouncing only keeps the last input. This answer is useful for buttons, but isn't as useful for toggle switches or anything else with state.Numbat
This is by far the simplest way to create a throttle extension in Kotlin without using coroutines, rx, etc. I needed to modify the function, because I believe every click should count, so even though a click was throttled it still should set "lastClickTime". So before you return from the function without running "action()" set "lastClickTime = SystemClock.elapsedRealtime()".Ulrikeulster
This isn't good in case you have a callback of some DB change, and you need to check what changed in the background, because you might miss new changes as it ignores them.Britneybritni
B
9

Thanks to https://medium.com/@pro100svitlo/edittext-debounce-with-kotlin-coroutines-fd134d54f4e9 and https://mcmap.net/q/266681/-throttle-onquerytextchange-in-searchview I wrote this code:

private var textChangedJob: Job? = null
private lateinit var textListener: TextWatcher

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                          savedInstanceState: Bundle?): View? {

    textListener = object : TextWatcher {
        private var searchFor = "" // Or view.editText.text.toString()

        override fun afterTextChanged(s: Editable?) {}

        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
            val searchText = s.toString().trim()
            if (searchText != searchFor) {
                searchFor = searchText

                textChangedJob?.cancel()
                textChangedJob = launch(Dispatchers.Main) {
                    delay(500L)
                    if (searchText == searchFor) {
                        loadList(searchText)
                    }
                }
            }
        }
    }
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    editText.setText("")
    loadList("")
}


override fun onResume() {
    super.onResume()
    editText.addTextChangedListener(textListener)
}

override fun onPause() {
    editText.removeTextChangedListener(textListener)
    super.onPause()
}


override fun onDestroy() {
    textChangedJob?.cancel()
    super.onDestroy()
}

I didn't include coroutineContext here, so it probably won't work, if not set. For information see Migrate to Kotlin coroutines in Android with Kotlin 1.3.

Beeck answered 10/12, 2018 at 10:37 Comment(3)
Sorry, 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.Beeck
Also, 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.Beeck
Possibly a new version of Retrofit (2.6.0) will help.Beeck
E
8

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.

Emelineemelita answered 16/6, 2018 at 16:28 Comment(5)
Unfortunately this seems to be out of date now that 1.3.x has been released.Redundancy
@JoshuaKing, yes. Maybe medium.com/@pro100svitlo/… will help. I will try later.Beeck
Thanks! Because this is super useful, but I need to update. Thanks.Redundancy
Are you sure that Channels are considered stable?Forewoman
A 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?Kaohsiung
M
5

Using tags seems to be a more reliable way especially when working with RecyclerView.ViewHolder views.

e.g.

fun View.debounceClick(debounceTime: Long = 1000L, action: () -> Unit) {
    setOnClickListener {
        when {
            tag != null && (tag as Long) > System.currentTimeMillis() -> return@setOnClickListener
            else -> {
                tag = System.currentTimeMillis() + debounceTime
                action()
            }
        }
    }
}

Usage:

debounceClick {
    // code block...
}
Micaelamicah answered 3/2, 2020 at 1:52 Comment(0)
C
5

@masterwork,

Great answer. This is my implementation for a dynamic searchbar with an EditText. This provides great performance improvements so the search query is not performed immediately on user text input.

    fun AppCompatEditText.textInputAsFlow() = callbackFlow {
        val watcher: TextWatcher = doOnTextChanged { textInput: CharSequence?, _, _, _ ->
            offer(textInput)
        }
        awaitClose { [email protected](watcher) }
    }
        searchEditText
                .textInputAsFlow()
                .map {
                    val searchBarIsEmpty: Boolean = it.isNullOrBlank()
                    searchIcon.isVisible = searchBarIsEmpty
                    clearTextIcon.isVisible = !searchBarIsEmpty
                    viewModel.isLoading.value = true
                    return@map it
                }
                .debounce(750) // delay to prevent searching immediately on every character input
                .onEach {
                    viewModel.filterPodcastsAndEpisodes(it.toString())
                    viewModel.latestSearch.value = it.toString()
                    viewModel.activeSearch.value = !it.isNullOrBlank()
                    viewModel.isLoading.value = false
                }
                .launchIn(lifecycleScope)
    }
Coxa answered 19/5, 2020 at 8:19 Comment(0)
P
2

@masterwork's answer worked perfectly fine. Here it is for ImageButton with compiler warnings removed:

@ExperimentalCoroutinesApi // This is still experimental API
fun ImageButton.onClicked() = callbackFlow<Unit> {
    setOnClickListener { offer(Unit) }
    awaitClose { setOnClickListener(null) }
}

// Listener for button
val someButton = someView.findViewById<ImageButton>(R.id.some_button)
someButton
    .onClicked()
    .debounce(500) // 500ms debounce time
    .onEach {
        clickAction()
    }
    .launchIn(lifecycleScope)
Paramagnetic answered 18/4, 2020 at 8:14 Comment(1)
This solution seems did not work goodly. you must handle the first click without delay requestMoria
F
2

Tested. This works for me.

Create a class

class DebounceJob {
    private var job: Job? = null

    fun debounce(
        delayMs: Long = 500L,
        scope: CoroutineScope,
        func: () -> Unit,
    ): () -> Unit {
        job?.cancel()
        return {
            job = scope.launch {
                delay(delayMs)
                func()
            }
        }
    }
}

How to use

let debouceA = DebounceJob()

debouceA.debounce(2000, scope) {
    anotherFunc()
}.invoke(Unit)
Fermat answered 22/3, 2023 at 7:19 Comment(1)
Advice: This should be a class instead if you're planning to use it from multiple places. Doing so, job will be localised to an instance, and not overall app/program.Phraseogram
B
1
object ActionDelayer {

var isActionTriggered = false

fun delay(milliseconds: Long = 10000, scope: CoroutineScope = CoroutineScope(Dispatchers.IO), action: () -> Unit) {
    if (!isActionTriggered) {
        scope.launch {
            action()
            isActionTriggered = true

            Executors.newSingleThreadScheduledExecutor().schedule({
                isActionTriggered = false
            }, milliseconds, TimeUnit.MILLISECONDS)
        }
    }
}
}

Usage:

ActionDelayer.delay { println("Repeating...") }

Brittbritta answered 25/10, 2022 at 11:47 Comment(0)
F
1

I've created a simple class to encapsulate the global variable and provide a convenience way to achieve debounce effect.

class Debouncer(
    private val scope: CoroutineScope,
    private val delay: Long,
    factory: Debouncer.() -> DebounceAction,
) {
    private var job: Job? = null
    private var onAction: DebounceAction = factory()

    fun startDebounce() {
        job?.cancel()
        job = scope.launch {
            delay(delay)
            onAction.run()
        }
    }
}

fun interface DebounceAction {
    fun run()
}

Here i'm separating the startDebounce() to better control when the debounce should happen. The usage:

Debouncer(lifecycleScope, 3000) {
    var count = 0
    binding.btn.setOnClickListener {
        count++
        startDebounce()
    }

    // action on debounce
    DebounceAction {
        count = 0
    }
}

With the extension, the usage is more natural:

fun CoroutineScope.debounce(
    delay: Long,
    factory: Debouncer.() -> DebounceAction,
): Debouncer {
    return Debouncer(this, delay, factory)
}

lifecycleScope.debounce(3000) {
    var count = 0
    binding.btn.setOnClickListener {
        count++
        startDebounce()
    }

    // action on debounce
    DebounceAction {
        count = 0
    }
}

You can also add more functionality, such debounce listener like this:

private val onDebounceListeners: MutableList<DebounceListener> = mutableListOf()
fun startDebounce() {
    onDebounceListeners.onEach { it.onDebounce() }
    ...
    ...
}
fun addOnDebounceListener(block: () -> Unit): Debouncer {
    onDebounceListeners.add(block)
    return this
}
fun removeOnDebounceListener(listener: DebounceListener): Debouncer {
    onDebounceListeners.remove(listener)
    return this
}
fun debounceImmediate() = onAction

And then..

lifecycleScope.debounce(3000) {
    var count = 0
    binding.btn.setOnClickListener {
        count++
        startDebounce()
        if (count == 10) {
            // do something
            ..
            ..
            // when you wanna trigger it manually
            debounceImmediate()
        }
    }

    // action on debounce
    DebounceAction {
        count = 0
    }
}.addOnDebounceListener {
    // do when debounce happen
}.addOnDebounceListener { 
    // do other thing
}
Fluorinate answered 9/3 at 4:45 Comment(0)
B
0

If anyone needs more easy kotlin extension here it is

fun EditText.debounce(delay: Long, action: (CharSequence?) -> Unit) {

 doAfterTextChanged { text ->
    val counter = getTag(id) as? Int ?: 0
    handler.removeCallbacksAndMessages(counter)
    handler.postDelayed(delay, ++counter) { action(text) }
    setTag(id, counter)
   }
 }

Uses

val editText = findViewById<EditText>(R.id.editText)

editText.debounce(500) {
   if (it.isNotEmpty()) {
      // Submit the form
     }
  }
Bernstein answered 30/5, 2023 at 13:42 Comment(0)
B
0

Based on what others have written (especially here), I've made a bit simpler solution:

class DebounceJob(private val @IntRange(from = 0L) debounceDelayMs: Long = 500L) {
    private var job: Job? = null

    @UiThread
    fun debounce(delayMs: Long = debounceDelayMs, scope: CoroutineScope, runnable: Runnable) {
        job?.cancel()
        job = scope.launch {
            delay(delayMs)
            runnable.run()
        }
    }
}

Example usage in ViewModel:

private val debounceJob = DebounceJob()
...
debounceJob.debounce(scope = viewModelScope, runnable = {
    viewModelScope.launch {
        runInterruptible(Dispatchers.IO) {
            //do something here in background thread
        }
    }
})

Full example usage here:

https://mcmap.net/q/266684/-how-to-retrieve-last-edited-contact-from-contactsprovider

If you want for LiveData, I've found this solution to be a nice one (from here):

fun <T> LiveData<T>.debounce(duration: Long = 1000L, coroutineScope: CoroutineScope): MediatorLiveData<T> {
    val mediatorLiveData = MediatorLiveData<T>()
    val source = this
    var job: Job? = null
    mediatorLiveData.addSource(source) {
        job?.cancel()
        job = coroutineScope.launch {
            delay(duration)
            mediatorLiveData.value = source.value
        }
    }
    return mediatorLiveData
}

Usage:

liveData.debounce(500L, CoroutineScope(Dispatchers.Main))
    .observe(viewLifecycleOwner) { ... }
Britneybritni answered 30/4 at 12:27 Comment(0)
P
0

This question is quite old and I think it deserves a new solution.

This solution using a StateFlow. It can be done by a SharedFlow. Both StateFlow and StateFlow has the following properties:

  • Emitting states/values can be done from outside the flow executor. Actually they don't have a flow executor.
  • Both support the debounce method which does exactly what it says. Quoting docs:

    Returns a flow that mirrors the original flow, but filters out values that are followed by the newer values within the given timeout. The latest value is always emitted.

You can find the solution in this gist.

Here is a Debouncer class that makes debouncing work easier:

class Debouncer(
    coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default),
    private val waitMs: Long = 5000L
) {
    class Work(val work: suspend () -> Unit)

    private val myFlow = MutableStateFlow<Work?>(null)

    init {
        coroutineScope.launch {
            myFlow.debounce(waitMs).collect {
                it?.let {
                    it.work()
                }
            }
        }
    }
    
    suspend fun debounce(work: suspend () -> Unit) {
        myFlow.emit(Work(work))
    }
}

Example of how to use it:

class Dog {
  private val debouncer = Debouncer()
  fun bark() {
    debouncer.debounce {
      // do the barking...
    }
  }
}
Prevalent answered 1/6 at 15:0 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.