UPDATE: Since I posted the original answer I have learnt about lifecycle-aware effects in compose. Here is a simpler approach that works well when you calculate time in relation to a start or target time:
@Composable
fun Countdown(targetTime: Long, content: @Composable (remainingTime: Long) -> Unit) {
var remainingTime by remember(targetTime) {
mutableLongStateOf(targetTime - System.currentTimeMillis())
}
content.invoke(remainingTime)
var isRunning by remember { mutableStateOf(false) }
LifecycleResumeEffect(Unit) {
isRunning = true
onPauseOrDispose { isRunning = false }
}
LaunchedEffect(isRunning) {
while (isRunning) {
remainingTime = targetTime - System.currentTimeMillis()
delay(1000)
}
}
}
The loop starts running when the lifecycle enters the resumed state and stops when it goes into the paused or disposed state.
This approach also prevents the while loop from running even after your view is in the background, which was what I was trying to achieve with my original approach, while being simpler to understand.
ORIGINAL ANSWER:
I just wanted to share an alternative I have experimented with in case someone else thinks of it and encounters the same issues I have. Here is the naive implementation:
@Composable
fun Countdown(targetTime: Long, content: @Composable (remainingTime: Long) -> Unit) {
var remainingTime by remember(targetTime) {
mutableStateOf(targetTime - System.currentTimeMillis())
}
content.invoke(remainingTime)
LaunchedEffect(remainingTime) {
delay(1_000L)
remainingTime = targetTime - System.currentTimeMillis()
}
}
Assuming you want a precision of up to a second, this snippet will cause the LaunchedEffect
to update a second after remainingTime
is updated, updating remainingTime
in relation with the current time in milliseconds. This basically creates a loop. Wrapping this logic in a @Composable
is good as it prevents the excessive re-composition this would cause if you embedded your LaunchedEffect
in a large component tree.
This works, but there is a catch: you will eventually notice your timer is skipping seconds. This happens because there will be some extra delay between the assignment of a new value to the remainingTime
variable and the re-execution of LaunchedEffect
which will essentially mean there is more than a second between updates.
Here is an improved implementation of the above:
@Composable
fun Countdown(targetTime: Long, content: @Composable (remainingTime: Long) -> Unit) {
var remainingTime by remember(targetTime) {
mutableStateOf(targetTime - System.currentTimeMillis())
}
content.invoke(remainingTime)
LaunchedEffect(remainingTime) {
val diff = remainingTime - (targetTime - System.currentTimeMillis())
delay(1_000L - diff)
remainingTime = targetTime - System.currentTimeMillis()
}
}
We simply subtract the time it took for LaunchedEffect
to re-execute from the intended delay time. This will avoid your timer skipping seconds.
The extra delay should not be an issue for the implementation in the accepted answer. The only advantage of this approach I noticed is that the loop will stop running once we navigate away from the screen. In my tests, the while loop with a true
condition kept running when navigating to another Activity.
DisposableEffect(null) { onDispose { timer.cancel() timer.purge() } }
– Fonz