Android Click Debounce with Databinding + Kotlin Coroutines

Android Click Debounce

Aleksander Kubista
The Startup
Published in
5 min readOct 7, 2020

--

Sooner or later every developer comes across a problem with too many function calls in a short amount of time. A perfect example is user mashing the very same button which triggers multiple click events. The most popular solution is to introduce debouncing mechanism, which basically omits any calls in given time after previous invoke.

As android platform is quite mature at this point, there are several approaches. For managing click events we can use external libraries or create our own implementation. Unfortunately ready solutions require us to use different api than basic onClickListener.

Databinding

There is a simple trick for those who use databinding. We can override android:onClick to make it use our own debouncing.

BindingAdapters.kt@BindingAdapter("android:onClick")
fun setDebouncedListener(view: Button, onClickListener: View.OnClickListener) {
val clickWithDebounce: (view: View) -> Unit = {

/**
* add debounce logic here
*/

onClickListener.onClick(it)
}
view.setOnClickListener(clickWithDebounce)
}

Create binding between activity and layout

MainActivity.ktoverride fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = DataBindingUtil.setContentView<ActivityMainBinding>(
this, R.layout.activity_main
)
binding.activity = this
}

And in our xml button declaration, every android:onClick will be wrapped with our binding adapter.

activity_main.xml<data>    <variable
name="activity"
type="com.example.clickdebouncer.MainActivity" />
</data>***<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onClick="@{ () -> activity.exampleFunction()}"
android:text="Click with debounce" />

You won’t need to remember about setting any custom listener as long as you will work with databinded layouts.

As you can see we can bind other solutions there but there is no need as creating our own is just a matter of minutes.

Debounce

After function call, we want to ignore next invocations for certain amount of time. For that we will use Kotlin coroutines. Main reason is they can be lifecycle aware. This is very important when working with asynchronous functions that works on view.

Let’s create empty function that will take three arguments. First two are pretty self explanatory. Action is our original function that takes generic T parameter and returns Unit. It is that we want to have possibility to pass arguments and typically debounced function like onClick doesn’t return value.

Debounce.ktfun <T> debounce(
delayMillis: Long = 800L,
scope: CoroutineScope,
action: (T) -> Unit
): (T) -> Unit {
return {}
}

To make our debouncing convenient to use, we created extension method. But before implementing anything lets write tests to check if our future solutions works properly.

Unit Tests

To test coroutines we will need to take care of dispatcher. There are several articles that explain it quite good so I wont do it here. For mocking we will use Mockito.

DebounceKtTest.ktprivate val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun clear() {
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
}

Alternatively you can extract it to separate test rule.

CoroutineTestRule.kt@ExperimentalCoroutinesApi
class CoroutineTestRule(
val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {
override fun starting(description: Description?) {
super.starting(description)
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description?) {
super.finished(description)
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
}
}

Now we can add tests

DebounceKtTest.kt@Test
fun `should call only once when time between calls is shorter than debounce delay`() =
testDispatcher.runBlockingTest {
// given
val debounceTime = 500L
val timeBetweenCalls = 200L
val firstParam = 1
val secondParam = 2
val testFun = mock<(Int) -> Unit> {
onGeneric { invoke(any()) } doReturn Unit
}
val debouncedTestFun = debounce(
debounceTime,
MainScope(),
testFun
)
// when
debouncedTestFun(firstParam)
advanceTimeBy(timeBetweenCalls)
debouncedTestFun(secondParam)
// then
verify(testFun, times(1)).invoke(any())
}
@Test
fun `should call every time with proper params when time between calls is longer than debounce delay`() =
testDispatcher.runBlockingTest {
// given
val debounceTime = 500L
val timeBetweenCalls = 1000L
val firstParam = 1
val secondParam = 2
val testFun = mock<(Int) -> Unit> {
onGeneric { invoke(any()) } doReturn Unit
}
val debouncedTestFun = debounce(
debounceTime,
MainScope(),
testFun
)

// when
debouncedTestFun(firstParam)
advanceTimeBy(timeBetweenCalls)
debouncedTestFun(secondParam)
// then
verify(testFun).invoke(firstParam)
verify(testFun).invoke(secondParam)
}

For now they don’t pass. But hopefully soon it will change.

Implementation

Now it’s high time to get back to our empty block and fill it with proper code.

Debounce.ktfun <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
}
}
}
}

If debounceJob is null, action is launched in new coroutine and until delay time passes there will be no more invocations.

Finally we can add our custom debouncer to binding adapter.

BindingAdapters.kt@BindingAdapter("android:onClick")
fun setDebounceListener(view: Button, onClickListener: View.OnClickListener) {
val clickWithDebounce: (view: View) -> Unit =
debounce(scope = MainScope()) {
onClickListener.onClick(it)
}
view.setOnClickListener(clickWithDebounce)
}

Some of you might noticed that we use MainScope() which is not cleared at any point. As our debounceDelay is very short, chances of leak are rather small. We have three options:

  1. We leave it with MainScope() → works automatically with databinded onClick / possible leak.
  2. We use lifecycleScope passed by databindingsafe from leaks / requires additional line of code
  3. Thanks to Pavel Shchahelski for mentioning. We can now obtain lifecycleScope from view with use of ViewTreeLifecycleOwner. It has advantages of first and second option and has no drawbacks.

If you don’t want to remember about adding some additional lines you have the possibility. In my opinion we should always write safe code and it requires adding only one more line to our activity.

BindingAdapters.kt@BindingAdapter("android:onClick")
fun setDebounceListener(view: Button, onClickListener: View.OnClickListener) {
val scope = ViewTreeLifecycleOwner.get(view)!!.lifecycleScope
val clickWithDebounce: (view: View) -> Unit =
debounce(scope = scope) {
onClickListener.onClick(it)
}

view.setOnClickListener(clickWithDebounce)
}

We also need to decor our activity layout

MainActivity.ktoverride fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = DataBindingUtil.setContentView<ActivityMainBinding>(
this, R.layout.activity_main
)

binding.activity = this
ViewTreeLifecycleOwner.set(window.decorView, this)
}

Variations

As its our own code we can with ease change this to behave differently. For example imagine we want to trigger action only once after user finish mashing button.

fun <T> debounceUnitlLast(
delayMillis: Long = 300L,
scope: CoroutineScope,
action: (T) -> Unit
): (T) -> Unit {
var debounceJob: Job? = null
return { param: T ->
debounceJob?.cancel()
debounceJob = scope.launch {
delay(delayMillis)
action(param)
}
}
}

We can adjust it and implement any debouncing logic we wish by changing just few lines.

Closing

My goal was to encourage more developer into writing more of their own solutions. Sometimes its very little code but in return we get full control over code and deep understanding what’s underneath. That’s all about. Hope this helps, thanks.

Links

--

--

Aleksander Kubista
The Startup

Mobile Software Engineer — passionate for creating and sharing knowledge. Nature lover. aleksanderkubista@gmail.com