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)
}
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
)
}
}
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)
}
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()
}
}
}
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)