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.
— 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
0state 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 thatdelay(n)will return resume after exactlynms.
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. :)