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
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 thatdelay(n)
will return resume after exactlyn
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. :)