DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Countdown Timer & Stopwatch in Compose — Time Display UI

Countdown Timer & Stopwatch in Compose — Time Display UI

Time-based UIs are essential in many apps. Let me show you how to build a countdown timer and stopwatch with smooth animations and lap recording in Jetpack Compose.

Countdown Timer with LaunchedEffect

Create a simple countdown timer with start/stop/reset controls:

@Composable
fun CountdownTimer(
    initialSeconds: Int = 60,
    modifier: Modifier = Modifier
) {
    var secondsRemaining by remember { mutableStateOf(initialSeconds) }
    var isRunning by remember { mutableStateOf(false) }

    LaunchedEffect(isRunning) {
        if (isRunning) {
            while (secondsRemaining > 0) {
                delay(1000)
                secondsRemaining--
            }
            isRunning = false
        }
    }

    Column(
        modifier = modifier
            .padding(16.dp)
            .fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = formatTime(secondsRemaining),
            style = MaterialTheme.typography.displayLarge,
            fontSize = 48.sp
        )

        Spacer(modifier = Modifier.height(24.dp))

        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
            Button(onClick = { isRunning = !isRunning }) {
                Text(if (isRunning) "Pause" else "Start")
            }
            Button(onClick = {
                isRunning = false
                secondsRemaining = initialSeconds
            }) {
                Text("Reset")
            }
        }
    }
}

private fun formatTime(seconds: Int): String {
    val minutes = seconds / 60
    val secs = seconds % 60
    return String.format("%02d:%02d", minutes, secs)
}
Enter fullscreen mode Exit fullscreen mode

Circular Progress Timer with Canvas

Visualize countdown progress with an animated circular arc:

@Composable
fun CircularProgressTimer(
    totalSeconds: Int = 60,
    modifier: Modifier = Modifier
) {
    var secondsRemaining by remember { mutableStateOf(totalSeconds) }
    var isRunning by remember { mutableStateOf(false) }

    val progress = animateFloatAsState(
        targetValue = secondsRemaining.toFloat() / totalSeconds,
        animationSpec = tween(1000, easing = LinearEasing)
    )

    LaunchedEffect(isRunning) {
        if (isRunning) {
            while (secondsRemaining > 0) {
                delay(1000)
                secondsRemaining--
            }
            isRunning = false
        }
    }

    Box(
        modifier = modifier.size(200.dp),
        contentAlignment = Alignment.Center
    ) {
        Canvas(modifier = Modifier.fillMaxSize()) {
            drawArc(
                color = Color(0xFF6C63FF),
                startAngle = -90f,
                sweepAngle = 360f * progress.value,
                useCenter = false,
                style = Stroke(width = 8.dp.toPx(), cap = StrokeCap.Round)
            )
        }

        Text(
            text = formatTime(secondsRemaining),
            style = MaterialTheme.typography.headlineMedium
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Stopwatch with Lap Recording

Build a stopwatch that tracks elapsed time and records laps:

data class LapTime(val lapNumber: Int, val time: Long)

@Composable
fun Stopwatch(
    modifier: Modifier = Modifier
) {
    var elapsedTime by remember { mutableStateOf(0L) }
    var isRunning by remember { mutableStateOf(false) }
    var laps by remember { mutableStateOf(listOf<LapTime>()) }
    var lapCounter by remember { mutableStateOf(0) }
    val startTime = remember { System.currentTimeMillis() }

    LaunchedEffect(isRunning) {
        if (isRunning) {
            while (isRunning) {
                elapsedTime = System.currentTimeMillis() - startTime
                delay(10)
            }
        }
    }

    Column(
        modifier = modifier
            .padding(16.dp)
            .fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = formatStopwatch(elapsedTime),
            style = MaterialTheme.typography.displayLarge,
            fontSize = 48.sp
        )

        Spacer(modifier = Modifier.height(24.dp))

        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
            Button(onClick = { isRunning = !isRunning }) {
                Text(if (isRunning) "Stop" else "Start")
            }
            Button(onClick = {
                lapCounter++
                laps = laps + LapTime(lapCounter, elapsedTime)
            }) {
                Text("Lap")
            }
            Button(onClick = {
                elapsedTime = 0L
                laps = emptyList()
                lapCounter = 0
            }) {
                Text("Reset")
            }
        }

        Spacer(modifier = Modifier.height(24.dp))

        LazyColumn(modifier = Modifier.fillMaxWidth()) {
            items(laps) { lap ->
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(8.dp),
                    horizontalArrangement = Arrangement.SpaceBetween
                ) {
                    Text("Lap ${lap.lapNumber}")
                    Text(formatStopwatch(lap.time))
                }
            }
        }
    }
}

private fun formatStopwatch(millis: Long): String {
    val totalSeconds = millis / 1000
    val minutes = totalSeconds / 60
    val seconds = totalSeconds % 60
    val centiseconds = (millis / 10) % 100
    return String.format("%02d:%02d.%02d", minutes, seconds, centiseconds)
}
Enter fullscreen mode Exit fullscreen mode

Complete Usage

@Composable
fun TimerAppScreen() {
    var selectedTab by remember { mutableStateOf(0) }

    Column(modifier = Modifier.fillMaxSize()) {
        TabRow(selectedTabIndex = selectedTab) {
            Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
                Text("Countdown")
            }
            Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) {
                Text("Circular")
            }
            Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) {
                Text("Stopwatch")
            }
        }

        when (selectedTab) {
            0 -> CountdownTimer()
            1 -> CircularProgressTimer()
            2 -> Stopwatch()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

These implementations handle all the common time display patterns you'll need. The LaunchedEffect hook ensures proper lifecycle management, and the animations provide smooth visual feedback.


8 Android app templates with custom UI designs available: Gumroad

Top comments (0)