Countdown timer with Jetpack Compose | Anton Danshin

Countdown timer with Jetpack Compose

September 28, 2021, updated September 29, 2021 | 5 min read

In my app I need to display some sort of timers and clocks in several places, so I thought it would be nice to make a stateful component for it. I looked online and found several good and bad implementations of it. So I decided to share my views and how I would go about this problem.

Countdown timer

— Design vector created by pikisuperstar www.freepik.com

The problem

Let’s set our goal first. We want to implement a component that allows you to display a countdown timer.

  • There shouldn’t be any assumption on how it will be displayed: it can be a Text(), Canvas() or anything.
  • The first displayed state should be initial state
  • The last displayed state must be 0 (never negative).

Simple solution – Naive approach

To achieve that, we can create a MutableState<Long> that would update itself in a LaunchedEffect. The users will just need to render it and the UI will automatically be updated on change.

Here is how it can be used:

@Composable
fun MyTimer() {
    val timeLeftMs by rememberCountdownTimerState(5_000)
    Text("$timeLeftMs ms left")
}

A simple implementation would look like this:

@Composable
fun rememberCountdownTimerState(
    initialMillis: Long,
    step: Long = 1000
): MutableState<Long> {
    val timeLeft = remember {mutableStateOf(initialMillis)}
    LaunchedEffect(initialMillis, step) {
        while (isActive && timeLeft.value > 0) {
            timeLeft.value = (timeLeft.value - step).coerceAtLeast(0)
            delay(step)
        }
    }
    return timeLeft
}

It will work, but it has several obvious problems:

  • The last delay could be longer than actually needed, so 0 state will be rendered with up to 99 ms delay.
  • There is not account of time that took to execute onTick(), and there is no guarantee that delay(n) will return resume after exactly n ms.

The first problem can be quickly fixed by making sure we never delay by more than timeLeft:

@Composable
fun rememberCountdownTimerState(
    initialMillis: Long,
    step: Long = 1000
): MutableState<Long> {
    val timeLeft = remember { mutableStateOf(initialMillis) }
    LaunchedEffect(initialMillis, step) {
        while (isActive && timeLeft.value > 0) {
            timeLeft.value = (timeLeft.value - step).coerceAtLeast(0)
            delay(step.coerceAtMost(timeLeft.value))
        }
    }
    return timeLeft
}

To fix second problem we need to use system clock.

Better solution – Using SystemClock

Instead of System.currentTimeMillis() available in Java, for this particular task it is recommented to use some monotonic time source, such as Android SystemClock.uptimeMillis().

The idea is to remember when we started our timer in startTime and on each cycle of the loop check how much time has actually passed (duration).

@Composable
fun rememberCountdownTimerState(
    initialMillis: Long,
    step: Long = 1000
): MutableState<Long> {
    val timeLeft = remember { mutableStateOf(initialMillis) }
    LaunchedEffect(initialMillis, step) {
        val startTime = SystemClock.uptimeMillis()
        while (isActive && timeLeft.value > 0) {
            // how much time actually passed
            val duration = (SystemClock.uptimeMillis() - startTime).coerceAtLeast(0)
            timeLeft.value = (initialMillis - duration).coerceAtLeast(0)
            delay(step.coerceAtMost(timeLeft.value))
        }
    }
    return timeLeft
}

The solution solution works perfectly fine, but if we were to publish it as a library and let other people use it, we would probably want to add some tests to it (e.g. screenshot tests). To make it testable we can pass a lambda as our time source, which would default to SystemClock.uptimeMillis().

Alternative solution – Using withFrameMillis

If we dig into how animated components are tested, we can find that compose has its own built-in monotonic time source – MonotonicFrameClock. It is an abstraction over Choreographer, if we provide another implementation in unit-tests, it will allow to control time there.

Here is how we can rewrite our component to use this withFrameMillis():

@Composable
@OptIn(ExperimentalComposeApi::class)
fun rememberCountdownTimerState(
    initialMillis: Long,
    step: Long = 1000
): MutableState<Long> {
    val timeLeft = remember { mutableStateOf(initialMillis) }
    LaunchedEffect(initialMillis, step) {
        val startTime = withFrameMillis { it }
        while (isActive && timeLeft.value > 0) {
            val duration = withFrameMillis { time ->
                (time - startTime).coerceAtLeast(0)
            }
            timeLeft.value = (initialMillis - duration).coerceAtLeast(0)
            delay(step.coerceAtMost(timeLeft.value))
        }
    }
    return timeLeft
}

The advantage of this implementation is that it can be tested using ComposeTestRule just like other components with animations. Hovever, I also see several issues with this approach:

  • The timer is tied to the display refresh rate, which means no mater how small we set the step, the loop will suspend on each frame (this could be a good thing). However, for that reason, the timer will probably be less presize.
  • It probably introduces more overhead due to abstractions and callback (compared to system call to SystemClock.uptimeMillis()).

It would probably be an overkill to write a timer this way, but inspite of all this, I still had a lot of fun digging into it.

Conclusion

We’ve looked into several implementations of a countdown timer in Jetpack Compose. I am sure there are more and we just scratched the surface. I would personally use the approach with SystemClock.uptimeMillis(), but I think that withFrameMillis() isn’t such a bad idea either. Let me know what you think. :)


Profile picture

Written by Anton Danshin 🧑‍💻 Android developer, ☕️ Starbucks coffee addict

© 2022, Built with Gatsby