How can I implement a timer in a portable way in Jetpack Compose?
Asked Answered
S

4

19

There's applications I'd like to write where I'd like some things to occur on a schedule.

Polling a URL for updates every few minutes seems to be a fairly common use case. In this particular case, though, I'm just trying to implement a clock.

This works:

@Composable
fun App() {
    var ticks by remember { mutableStateOf(0) }

    // Not 100% happy about this unused variable either
    val timer = remember {
        Timer().apply {
            val task = object : TimerTask() {
                override fun run() {
                    ticks++
                }
            }
            scheduleAtFixedRate(task, 1000L, 1000L)
        }
    }

    MaterialTheme {
        Text(
            // A real application would format this number properly,
            // but that's a different question
            text = "$ticks"
        )
    }
}

But I had to import java.util.Timer, so it's not going to be portable.

Jetpack Compose can do animation, so it surely has its own timer somewhere, implying there should be some portable way to do this as well, but I can't seem to figure it out.

Is there a cross-platform way to get a timer for this purpose?

Sonora answered 20/2, 2022 at 4:17 Comment(2)
I should also throw in that I have found existing questions here asking how to implement a timer in Jetpack Compose, but the answer involved using an Android-specific class, which embeds this exact same problem.Sonora
You should cancel timer: DisposableEffect(null) { onDispose { timer.cancel() timer.purge() } }Fonz
R
50

In Compose you can use LaunchedEffect - it's a side effect which is run on a coroutine scope, so you can use delay inside, like this:

var ticks by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
    while(true) {
        delay(1.seconds)
        ticks++
    }
}
Roguish answered 20/2, 2022 at 4:42 Comment(6)
That's an interesting solution to the problem. Obviously calculating the actual delay is nontrivial, but it does give me an idea of how I could scaffold up a useful timer class.Sonora
@Sonora what exactly do you need to calculate? I don't know Timer well enough, but scheduleAtFixedRate seems running once a second in your example just like in mineRoguish
@Sonora if you need to sync it with time seconds, it's pretty easy: delay(1000 - Date().time % 1000)Roguish
Well, I think I have to avoid Date() as well but I'm not entirely sure. What I might have to do is make my own multiplatform function to get the time. Or, pull in the entirety of kotlinx-datetime just to get the time, and then use delay(1.seconds - Clock.System.now().nanosecondsOfSecond.nanoseconds).Sonora
How to restart the timer. I need on the OTP page. Resending code.Beekeeper
@SardorbekMuhammadjonov you can reset launched effect using key parameter, e.g. something like thisRoguish
M
8

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.

Mesmerize answered 16/12, 2022 at 1:5 Comment(0)
W
1
@Composable
fun TimerTicks(
    initTick: Long = 1_000L,
    interval: Long = 1_000L,
    content: @Composable (tickTime: Long) -> Unit
) {

    var ticks by remember(initTick) {
        mutableStateOf(initTick)
    }

    content.invoke(ticks)

    LaunchedEffect(ticks) {
        val diff = ticks + interval
        delay(interval)
        ticks = diff
    }
}
Wire answered 16/2, 2023 at 2:35 Comment(0)
O
0

Try this

@Composable
fun TimerScreen() {
    val timerConverter = remember {
        mutableStateOf("")
    }
    val lastConnection = remember {
        mutableStateOf(System.currentTimeMillis())
    }
    LaunchedEffect(key1 = timerConverter.value) {
        delay(1000)
        timerConverter.value =
            converting(1000 * 60 * 60 * 12L - (System.currentTimeMillis() - lastConnection.value))
    }
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(MaterialTheme.colorScheme.background)
    ) {

        Text(
            modifier = Modifier
                .fillMaxWidth()
                .padding(
                    top = 15.dp,
                    bottom = 10.dp,
                ),
            text = timerConverter.value,
            style = TextStyle(
                textAlign = TextAlign.Center,
                fontWeight = FontWeight.ExtraBold,
                fontSize = 25.sp,
                color = MaterialTheme.colorScheme.onBackground
            )
        )
    }
}

fun converting(millis: Long): String =
    String.format(
        "Hours %02d : Minutes %02d : Seconds %02d",
        TimeUnit.MILLISECONDS.toHours(millis),
        TimeUnit.MILLISECONDS.toMinutes(millis) - TimeUnit.HOURS.toMinutes(
            TimeUnit.MILLISECONDS.toHours(millis)
        ),
        TimeUnit.MILLISECONDS.toSeconds(millis) - TimeUnit.MINUTES.toSeconds(
            TimeUnit.MILLISECONDS.toMinutes(millis)
        )
    )

enter image description here

Oleander answered 6/10, 2023 at 2:53 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.