31

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.

Zoe
  • 23,712
  • 16
  • 99
  • 132
Kyryl Zotov
  • 1,366
  • 2
  • 19
  • 38

11 Answers11

32

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)
Terenfear
  • 421
  • 5
  • 4
  • 1
    it would be nice with some output. – Kebab Krabby Aug 29 '19 at 07:41
  • 2
    Note that `onTextChanged()` is not in the Android SDK, though [this post](https://theengineerscafe.com/useful-kotlin-extension-functions-android/) contains an implementation that is compatible. – CommonsWare Sep 29 '19 at 13:41
  • 1
    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? – GuilhE Dec 19 '19 at 12:01
  • 1
    @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. – Terenfear Dec 19 '19 at 19:30
  • Ok @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 call `val ViewModel.viewModelScope: CoroutineScope` since I'm already inside a ViewModel (if so, you are correct)? Btw, really helpful functions, thanks! – GuilhE Dec 20 '19 at 11:53
  • Why 2nd, 3rd or 4th call etc. does not see Job as null? – I.Step Dec 16 '20 at 18:35
  • @I.Step let's take `fun 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. – Terenfear Dec 17 '20 at 11:14
  • 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. – Alan Nelson Feb 11 '21 at 20:15
  • @AlanNelson `viewLifecyleOwner.lifecycleScope` comes from the `androidx.lifecycle:lifecycle-runtime-ktx:2.2.0`. Here's more info: https://developer.android.com/topic/libraries/architecture/coroutines#lifecyclescope – Terenfear Feb 12 '21 at 12:30
15

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)
masterwok
  • 4,131
  • 3
  • 26
  • 37
14

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) }
}
Patrick
  • 14,618
  • 20
  • 77
  • 136
  • 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()? – tech_human Nov 15 '19 at 23:56
  • 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 = debounce(500, coroutineContext) { doStuff(it) } Where 'it' is the string passed. Or name it with { myString -> doStuff(myString) } – Patrick Nov 20 '19 at 18:05
  • Hello @Patrick, can you help me with this: https://stackoverflow.com/q/59413001/1423773? I bet it's missing a minor detail, but I can't find it. – GuilhE Dec 19 '19 at 16:24
  • This remembers me of JavaScript's Underscore implementation, which I really like for the simplicity. Congrats and thanks! – Machado Apr 19 '20 at 07:06
  • 1
    This implementation does not debounce but it throttles. – Simon Mar 09 '21 at 19:13
12

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)
    }
}
Chris Chute
  • 2,039
  • 22
  • 16
9

Thanks to https://medium.com/@pro100svitlo/edittext-debounce-with-kotlin-coroutines-fd134d54f4e9 and https://stackoverflow.com/a/50007453/2914140 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.

CoolMind
  • 16,738
  • 10
  • 131
  • 165
  • 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. – CoolMind Mar 27 '19 at 10:55
  • 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. – CoolMind Jun 06 '19 at 07:31
  • Possibly a new version of Retrofit (2.6.0) will help. – CoolMind Jun 06 '19 at 07:41
8

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
}
SANAT
  • 6,760
  • 45
  • 58
7

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.

Community
  • 1
  • 1
Diego Malone
  • 926
  • 8
  • 24
  • 2
    Unfortunately this seems to be out of date now that 1.3.x has been released. – Joshua King Nov 20 '18 at 02:47
  • 1
    @JoshuaKing, yes. Maybe https://medium.com/@pro100svitlo/edittext-debounce-with-kotlin-coroutines-fd134d54f4e9 will help. I will try later. – CoolMind Dec 10 '18 at 07:24
  • Thanks! Because this is super useful, but I need to update. Thanks. – Joshua King Dec 10 '18 at 16:51
  • Are you sure that Channels are considered stable? – EpicPandaForce Dec 19 '18 at 16:14
  • 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? – Mabsten May 23 '21 at 20:08
4

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...
}
Rodrigo Queiroz
  • 1,829
  • 18
  • 23
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)
Knight Forked
  • 993
  • 5
  • 11
2

@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 { this@textInputAsFlow.removeTextChangedListener(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)
    }
Rvb84
  • 425
  • 1
  • 5
  • 13
2

try this in your ViewModel

private var autoCompleteViewJob: Job? = null

fun searchFunction(searchQuery: String) {
    autoCompleteViewJob?.cancel()
    autoCompleteViewJob = viewModelScope.launch {
        delay(500) // time to debounce
        search(searchQuery) // call your api here
    }
}
Aalishan Ansari
  • 313
  • 1
  • 4