Android CountDownTimer is good, but it can be better. This article covers few tweaks to the timer and in general how to decouple certain logic from activity.
- 📚 Background
- 👓 Reading between lines
- ⏲ CountDownTimer
- ⚙️ Implementation
- 🧹 Decluttering activity
- 📔 Endnote
📚 Background
CountDownTimer is a convenient API in android when it comes to implementing a timer. However, it lacks few features that have to be filled in by the host Activity /Fragment. Let's draft a user story.
❓ As a user, I should see a countdown timer that expires at an absolute time.
There are multiple ways to achieve this in Android. You can use any of the below APIs.
- Coroutines-with-delay
- Thread-handler-sleep
- CountdownTimer
I'm picking the CountdownTimer
, when we reach the end of the article, it'll be clear why we're not using the first two.
...
👓 Reading between lines
The user story says it should expire at an absolute time. Let's assume an offer will expire at 10:00 am. It will expire at 10 whether the activity is running or not. i.e timeout is not bound to the activity or fragment launch time. The timer is merely an attempt to highlight user that he has xx time left till expiry.
No need to explain what a timer is — for our use-case, it ticks per second.
...
⏲ CountDownTimer
CountDownTimer is a simple API that makes use of android's Handler.sendMessageDelayed
to emit elapsed value in a given interval. This is an abstract class where we have to provide the implementation for the following methods:
-
onTick
— once every xx-interval -
onFinish
— time out
object: CountDownTimer(eta, interval) {
override fun onTick(millisUntilFinished: Long) {}
override fun onFinish() {}
}
...
⚙️ Implementation
Implementing it in our activity/fragment is plain and simple. Compute eta and interval, create a timer, start it and update UI on each tick and then finish it when it's complete.
class OfferActivity: AppCompatActivity() {
val expiresAt = // 10 am in millis
var timer: CountDownTimer? = null
fun onCreate() {
startTimer()
}
private fun startTimer() {
// If timer already running cancel it
timer?.cancel()
val eta = expiresAt - System.currentTimeMillis()
val interval = 1000 // every 1 sec
timer = object : CountDownTimer(eta, interval) {
override fun onTick(millisUntilFinished: Long) {
// oversimplified version
timerLabel = "${millisUntilFinished/1000} seconds left"
}
override fun onFinish() {
timerLabel = "offer expired"
}
}
timer?.start()
}
fun onDestroy() {
timer?.cancel()
}
}
This is an okayish implementation, but it's flawed. Even when the app is in the background, the countdown still runs. So, naturally, like any android developer, we resort to lifecycle methods.
fun onPause() {
timer?.cancel()
}
fun onResume() {
startTimer()
}
This will work fine for one activity, but when we need the timer in multiple places, it just clutters the activity with a bunch of lifecycle methods, and missing one of them will end up in a timer that runs in the background or something that doesn't resume when activity is foreground. Fortunately, we have a way to remove this clutter with a delegate.
...
🧹 Decluttering activity
LifecycleObserver is an interface from androidx which can register to the activity lifecycle and act on behalf of it. This way, the activity will live clean, yet the concerned class can react to the activity lifecycle. In further sections, we'll create a wrapper for the countdown timer and register for lifecycle events.
Registering to lifecycle
class LifecycleAwareTimer(stopAt: Long, interval: Long)) {
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun onResume() {}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun onPause() {}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {}
}
class OfferActivity: AppCompatActivity() {
private fun startTimer() {
// ...
lifecycle.addObserver(LifecycleAwareTimer(stopAt, interval))
}
}
Now we have a skeleton of LifecycleAwareTimer which is connected to our activity and subscribed to lifecycle events. Next thing is to move our CountDownTimer to the wrapper.
...
Moving countdown timer
Create a CountDownTimer inside the wrapper and start/cancel it as per the lifecycle callback. Also, consider the case when the timer expires and don't start it.
class LifecycleAwareTimer(stopAt: Long, interval: Long) {
private var timer: CountDownTimer? = null
private val expired: Boolean
get() = (stopAt - System.currentTimeMillis()) <= 0
fun startTimer() {
timer?.cancel()
timer = null
val eta = stopAt - System.currentTimeMillis()
timer = object : CountDownTimer(eta, interval) {
override fun onTick(millisUntilFinished: Long) {}
override fun onFinish() {}
}
timer?.start()
}
fun discardTimer() {
timer?.cancel()
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun onResume() {
if (expired) {
discardTimer()
} else {
startTimer()
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun onPause() {
timer?.cancel()
}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
discardTimer()
}
So far, the timer connects to the activity and internally starts/cancels the countdown. But it doesn't deliver results to the host. Let's wire it up.
...
Callbacks to the host
Create a callback interface to update events from the underlying timer to the host activity and implement the same in Activity.
interface TimerCallback: LifecycleOwner {
fun onTick(millisUntilFinished: Long)
fun onTimeOut()
}
class OfferActivity : AppCompatActivity(), TimerCallback {
fun onTick(millisUntilFinished: Long) { /** seconds ticking **/ }
fun onTimeOut() { /** expired**/ }
}
LifecycleAwareTimer takes in the callback and delivers the result to it. Add the callback to the constructor argument and forward values to the activity.
class LifecycleAwareTimer(
private val stopAt: Long,
private val interval: Long,
private val callback: TimerCallback
) {
fun startTimer() {
timer = object : CountDownTimer(eta, interval) {
override fun onTick(millisUntilFinished: Long) {
callback.onTick(millisUntilFinished)
}
override fun onFinish() {
callback.onTimeOut()
}
}
timer?.start()
...
Offhooking the observer
Since our TimerCallback
is also LifecycleOwner, registering/unregistering for lifecycle can be done right within the LifecycleAwareTimer. It registers for a callback when created and removes itself when activity is destroyed or the timer is expired.
class LifecycleAwareTimer(
private val stopAt: Long,
private val interval: Long,
private val callback: TimerCallback
) {
init {
callback.lifecycle.addObserver(this)
}
fun discardTimer() {
timer?.cancel()
callback.lifecycle.removeObserver(this)
}
...
timer = object : CountDownTimer {
override fun onFinish() {
callback.onTimeOut()
discardTimer()
}
}
...
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() { discardTimer() }
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun onResume() {
if (expired) {
callback.onTimeOut()
discardTimer()
} else {
// Try to resume timer
startTimer()
}
}
Activity side implementation
Now pretty much the timer implementation is complete, let's have a look at the activity. It holds a timer reference (to avoid duplicate timers) initializes and starts it. Receives callback from onTick & onTimeOut.
// Activity
private var timer: LifecycleAwareTimer? = null
fun startTimer(){
timer?.discardTimer()
timer = LifecycleAwareTimer(stopAt, interval, this)
timer?.startTimer()
}
fun onTick(millisUntilFinished: Long) { /** seconds ticking **/ }
fun onTimeOut() { /** expired**/ }
📔 Endnote
This might look like an exaggerated version of the timer. But we have a lot of benefits with this approach:
- Timer depends on
LifecycleOwner
which means, it can be used with both fragment and activity - Timer reacts to lifecycle without explicitly writing much at the host activity/fragment
- When activity destroyed, the timer kills itself and unregister from lifecycle callbacks
- Timer pauses when activity hits background and resumes or deliver timeout result when the host hits the surface back
Why didn't we use coroutine?
The same behavior can be emulated using a coroutine that dispatches to the Main thread. But a disadvantage is we'll end up owning the elapsed time computation. We still have to use the lifecycle callbacks to manage the coroutine job. And the coroutine itself will contain a while loop with delay and dispatch. When it comes to CountDownTimer, it dispatches messages at a given interval which is organic to the android system.
Comple code is available as gist.
🧑💻 Happy coding 🧑💻
Top comments (0)