Is there any way to "throat" or "debounce" when us...
# coroutines
p
Is there any way to "throat" or "debounce" when user click on a button (in Android) to avoid multi-clicks? I've found something like this
Copy code
fun <T> debounce(
    delayMillis: Long = 300L,
    scope: CoroutineScope,
    action: (T) -> Unit
): (T) -> Unit {
    var debounceJob: Job? = null
    return { param: T ->
        if (debounceJob == null) {
            debounceJob = scope.launch {
                action(param)
                delay(delayMillis)
                debounceJob = null
            }
        }
    }
}
Where I can do
Copy code
fun setDebounceListener(view: Button, onClickListener: View.OnClickListener) {
    val clickWithDebounce: (view: View) -> Unit =
        debounce(scope = MainScope()) {
            onClickListener.onClick(it)
        }
    view.setOnClickListener(clickWithDebounce)
}
Does it make sense to use debounce here? It's just to avoid double click on a Button
l
You can also disable the button on click and re-enable it only at the right time.
See this function from Splitties View Coroutines: https://github.com/LouisCAD/Splitties/blob/1936c6c7e445c048077d8b4b7bb1c13b99e0ca0[…]idMain/kotlin/splitties/views/coroutines/VisibilityAndClicks.kt If you every need to turn it into a flow, it's pretty easy as well, you just need an infinite loop that calls
awaitOneClick()
in the
flow { … }
builder.
p
How do I cancel it?
I've tried your code and it says no value passed for onCancellation
l
It's a suspending function, you cancel it like another one.
p
Then do I have to create a coroutineScope to launch it, right?
l
Yep, or use the
xxxLatest
operators if called from a flow operator
p
But I mean, I have this continuation.resume(Unit) and it's complaining because I have to set a new parameter for onCancellation
How would be the debouncing with flow? Because AFAIK there's a debounce method in flow, right?
l
You'd not debounce, you'd simply not call
awaitOneClick()
again until your app wants to allow clicks again
Look at the imports in that file, that'll help you fix the complaint of the file.
Example usage:
Copy code
suspend handleStuff() {
    while (true) {
        btn.awaitOneClick()
        doStuff()
    }
}
No need for magic timers 🙌🏼
p
sorry I copied the wrong thing
l
(Given
doStuff()
is a suspending function)
p
Copy code
fun View.clicks(): Flow<Unit> = callbackFlow {
    setOnClickListener {
        offer(Unit)
    }
    awaitClose { setOnClickListener(null) }
   }
l
Then delete the message 😉
p
Copy code
button.clicks().debounce(1000).onEach { println("clicked") }.launchIn(GlobalScope)
l
The correct way to transform it to a
Flow
is the following:
Copy code
fun View.clicks(): Flow<Unit> = flow {
    while (true) {
        awaitOneClick()
        emit(Unit)
    }
}
It handles backpressure, so the view will stay disabled while
emit
is suspending (which is when the lambda of
collect
is executing)
p
Ah but this is mixing your solution with flow, right?
What's the difference between the code I put and using your solution?
l
Mine disables the view (e.g. button) when the app is busy and not ready to accept new requests from the user
p
ah okok
l
It all boils down to the implementation of
awaitOneClick()
p
Let me try then
And the way to call this function from the view?
l
You don't call it from the View istelf. You call it from the code that controls the View(s)
Can be a custom class, or even a suspending function that can be top-level
p
The thing is that I have problems from a RecyclerView adapter (View Holder)
So I'd need to control the onClickListener there
p
Ah ok
Uh, guess it's overkill for an easy think I want
Do all this stuff just to avoid double clicks on a button
l
I don't think it's that overkill personally, but I might be biased 😉
p
It's bad praxis if I pass the CoroutineScope to adapter and then launch{button.onSingleClick()}?
l
bad practice? I don't think so, you just need to make sure the scope is from the view lifecycle or a sub-scope
I'm not convinced by the naming of your onSingleClick function though, it looks like a callback.
p
yeye sorry bad naming
So the issue now is, in continuation.resume(Unit)
is waiting for a cancellation callback but you do not have that, why?
l
I already told you to look at the imports in my original snippet
You're missing one import
There's an extension function. Did you find it?
p
import kotlin.coroutines.resume
👌 1
So I have to pass as a parameter a lambda? to do my Unit thing when onClickListener, right?
imagine I had button.setOnClickListener { itemClicked.invoke(position) }
I did it like :
Copy code
scope.launch {
  singleClick().onEach {
    itemClicked.invoke(position)
  }.launchIn(this)
}
it's redundant I guess I'm already launching it from scope, so no need to add launchIn right? 😕
l
You likely want to use
collect
instead, yup. Also, having a flow that actually gives only one value ever is weird, name-wise, and use-wise, just using
awaitOneClick()
would make more sense here.
p
true
If I use awaitOneClick() can I pass a lambda then? Like I wrote before?
Also what I've found is: From recyclerView click > it goes to detail (can not do multi click) but when going back to the list I can not click anymore, and also I'm doing this to do the click and not sure if it's ok scope.launch{awaitOneClick() onItemClicked.invoke()}
l
That's why I was using a loop in my example.
p
This you mean?
Copy code
fun View.clicks(): Flow<Unit> = flow {
    while (true) {
        awaitOneClick()
        emit(Unit)
    }
}
But then I got your point to not use flow for 1 emit and 1 collect
p
And I want to use just the awaitOneClick() with a lambda (it's an unit)
I don't get why I have to do a while(true) there 😕
l
Because the function handles only one click ever
So you need to call it again
But if that doesn't suit your use case, don't use it 🤷🏼
p
Well I just wanted to try to avoid double click on a button (passing a lambda or something) do the action just once and that's it, then if I tap the button again, work the same, but I see that in your example don't check this "timing" stuff so that's why it's not working
the problem is that my button can do a multiple click on a item and it starts 2 activities at least (the same one)
So I wanted to avoid that, and since I'm starting with coroutines/flow was looking for a cool solution of doing this
l
If you are starting an activity, you probably want to call
delay(timeMillis = 700)
or alike after doing it before calling
awaitOneClick()
again
p
Sure but I wanted to encapsulate this everything to an extensionFunction so I don't have to do the while(true) delay() etc on every button
l
You can if you want.
p
Neither create a SuspendButton View and change all the buttons at least from now
Creating an extension function of coroutineScope?
Or in the same extensionFunction? (awaitOneClick)
l
Probably more an extension on
View
that takes a
CoroutineScope
p
And then the while true and everything inside the suspendcancelable?
l
No
That extension would use
awaitOneClick()
.
suspendCancellable
is an implementation detail of
awaitOneClick
p
From now I have this
Copy code
suspend fun View.awaitOneClick(
    disableAfterClick: Boolean = true,
    hideAfterClick: Boolean = false,
    onClick: (View) -> Unit,
) = try {
    if (disableAfterClick) isEnabled = true
    if (hideAfterClick) isVisible = true
    suspendCancellableCoroutine<Unit> { continuation ->
        setOnClickListener {
            onClick.invoke(it)
            continuation.resume(Unit)
        }
    }
} finally {
    setOnClickListener(null)
    if (disableAfterClick) isEnabled = false
    if (hideAfterClick) isVisible = false
}
l
Yes, I know that code, I wrote it 😛
p
I've pased the lambda so I can call this function as
Copy code
awaitOneClick { //do something }
l
You can leave it like that, and use it intor your own function that takes whatever lambda you want
And, please, with a different name, I trademarked
awaitOneClick()
(kidding about the TM, but you can find a clearer name to the function that'd use
awaitOneClick()
)
p
And now if I pass coroutineScope via parameter, then I don't know where to but what I was doing in the ViewHolder
Copy code
scope.launch{
  while(true){
    delay(700)
    awaitOnClick{
       onClick.invoke(position)
    }
}
l
You see the code you want to pass in the non existing lambda of
awaitOneClick()
? Just put if after calling
awaitOneClick()
.
Also, the
delay
should be after that code, not before.
Otherwise, you're just slowing your app down and annoying all your users.
I spent too much time on this now, I hope I was helpful 🙂
p
So to add it in the extensionFunction shall I create a new one? And then use yours? it's unclear where to put this while(true) How would be the pseudo? Not the real code...
You see the code you want to pass in the non existing lambda of 
awaitOneClick()
?
I add it in your code when I said "I have this from now"
1
l
Oh, sorry, you should not edit that and use the one I linked
I didn't notice that onClick extra parameter.
into an extension function
launchLoop
is
launch
on a scope +
while (isActive)
p
Cool, I think I got it, ignoring the naming, this should be like a good option?
Copy code
fun View.handleSingleClick(scope: CoroutineScope, block: () -> Unit) {
scope.launch{
  while(true){
   awaitOneClick()
   block()
   delay(700)
 } 
}
}
l
Ignoring the naming, yes.
p
any difference my while(true) with your launchLoop extension? Mine could cause leaks?
l
They are the same, but if you want to be sure there's zero leaks, you need the right scope, AND to use suspend-bind that I linked before from another conversation.
1
That's why I was suggesting to use that for RecyclerView from the beginning 🙂
1
182 Views