Debounce user’s input in android without using rx or coroutines

There are myriads of blog posts that showcase how to debounce the user’s input by using either rxjava or coroutines. The question is how to implement such a functionality when the project does not have these dependencies?

Debounce

First of all, what is debounce? In essence debounce is a pattern that helps in preventing the repeated execution of a block of code. It does that by adding a delay between two consecutive calls to the block and by cancelling the first call when the second is requested. For example:

When the user types in her keyboard, every key stroke results in calling a block of code that renders what she typed:

without debounce

By having debounce when the user types, each call gets delayed and cancelled if a new one gets requested resulting in rendering the entire text when the user is finished typing:

with debounce

Before starting

We are going to add the debounce functionality to the example above. The initial code is very simple:

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val bindings = ActivityMainBinding.inflate(layoutInflater)
setContentView(bindings.root)
with(bindings) {
userInput.doAfterTextChanged { text ->
userResult.text = text
}
}
}
}
view raw debounce_initial.kt hosted with ❤ by GitHub

where userInput is the EditText that the user writes in and userResult is the TextView that the user’s input gets rendered.

Adding the debounce functionality

There are two ways to do this. The first uses java’s Timer and TimerTask and the second android’s Handler. Both of them help in implementing the same algorithm:

  1. on every key stroke we first cancel any previous call request
  2. then we setup some kind of timer for our delay and the code that needs to be called
  3. and finally, when the time passes, we make the call
Timer and TimerTask

We can use Timer to schedule the execution of a block of code after a given delay. The provided block must be a TimerTask which will be added to a queue and when the time comes it will be executed in a background thread. This last part is very important since we cannot set anything UI related there. That’s why we use the Timer just for the delay part and then we use the view’s Handler to execute the actual code to the main thread:

class MainActivity : AppCompatActivity() {
private var timer = Timer()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val bindings = ActivityMainBinding.inflate(layoutInflater)
setContentView(bindings.root)
with(bindings) {
userInput.doAfterTextChanged { text ->
timer.cancel() // cancel any previous delay
timer = Timer() // schedule a new one
timer.schedule(500L) {
// we are in a background thread here
userInput.handler.post { userResult.text = text }
}
}
}
}
}
view raw debounce_timer.kt hosted with ❤ by GitHub

Recommended when there is a need to do some intensive work before returning back to the main thread but besides that it could be an overkill. That’s why it might be better to use the view’s Handler for both the delay and the execution.

Handler

The Handler class packs the same functionality as the Timer. We can use it to add a block of code (being added as a callback in a Message instance) in a queue (the handler’s MessageQueue) and when the time comes the message gets removed from the queue and its callback gets executed in the thread that the handler was created in. In our case, since we are using the view’s handler we can be sure that the execution will take place in the main thread.

The Handler class provides methods for both adding and removing from the queue. So what we end up with is something like this:

class MainActivity : AppCompatActivity() {
private var counter = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val bindings = ActivityMainBinding.inflate(layoutInflater)
setContentView(bindings.root)
with(bindings) {
userInput.doAfterTextChanged { text ->
userInput.handler.removeCallbacksAndMessages(counter)
userInput.handler.postDelayed(500L, ++counter) { userResult.text = text }
}
}
}
}
view raw debounce_handler.kt hosted with ❤ by GitHub

One note about the counter variable. For removing a particular message we need to identify it and to do that we can use what is called a token. When posting for delay, we also provide an id in the form of a counter so that we can request its deletion later on.

Debounce extension

Since we are in Kotlin land and to avoid having the above code duplicated with various global counters for identification we can create an extension function that will pack everything together and help us in having a reusable component and more readable code:

fun EditText.debounce(delay: Long, action: (Editable?) -> Unit) {
doAfterTextChanged { text ->
var counter = getTag(id) as? Int ?: 0
handler.removeCallbacksAndMessages(counter)
handler.postDelayed(delay, ++counter) { action(text) }
setTag(id, counter)
}
}

So our code ends up looking like this:

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val bindings = ActivityMainBinding.inflate(layoutInflater)
setContentView(bindings.root)
with(bindings) {
userInput.debounce(500L) { text -> userResult.text = text }
}
}
}

which is very similar to the original code but provides the debounce functionality!