When building timer applications with Kotlin and Jetpack Compose, precision and performance are crucial. But how do you create a stopwatch that's not only accurate to the millisecond but also demonstrates advanced Kotlin features like coroutines, smart state management, and elegant UI patterns ?
In this comprehensive guide, I'll walk you through a production-ready Stopwatch application that showcases the power of Kotlin's concurrency model, Compose's reactive state management, and pragmatic architectural decisions. We'll explore advanced patterns like coroutine-based timers, state persistence, haptic feedback integration, and dual-mode functionality (stopwatch/countdown timer).
By the end of this article, you'll understand how to build precise, responsive timer applications that leverage Kotlin's strengths for real-time applications.
🏗️ Architecture Philosophy: Pragmatic Kotlin Design
@Composable
fun StopwatchScreen() {
// All state managed locally with Compose
var isRunning by rememberSaveable { mutableStateOf(false) }
var baseMs by rememberSaveable { mutableLongStateOf(0L) }
var elapsedMs by rememberSaveable { mutableLongStateOf(0L) }
// Timer logic with Kotlin coroutines
LaunchedEffect(isRunning, baseMs) {
if (!isRunning) return@LaunchedEffect
while (isRunning) {
val now = SystemClock.elapsedRealtime()
elapsedMs = now - baseMs
delay(50) // 20 FPS precision
}
}
}
Why This Architecture Choice ?
🎯 Appropriate Complexity:
- Timer Logic: Self-contained, no external data dependencies
- State Scope: All state is UI-related and local to the screen
- Performance: Direct Compose state updates are optimal for real-time updates
🔧 Kotlin-First Benefits:
- Coroutines: Native async support without callback complexity
-
Smart State:
rememberSaveable
survives configuration changes - Type Safety: Long timestamps prevent overflow issues
- Local Functions: Clean encapsulation of timer operations
⚡ When NOT to Use MVVM:
- No external data sources (API, database)
- State doesn't need sharing between screens
- Business logic is simple and UI-focused
- Real-time performance is critical
📊 State Management: Kotlin's Reactive Excellence
Let's explore how Kotlin and Compose handle complex timer state with precision and elegance:
@Composable
fun StopwatchScreen() {
// Core timer state with configuration survival
var isRunning by rememberSaveable { mutableStateOf(false) }
var baseMs by rememberSaveable { mutableLongStateOf(0L) }
var elapsedMs by rememberSaveable { mutableLongStateOf(0L) }
// Dual-mode functionality
var countdownMode by rememberSaveable { mutableStateOf(false) }
var countdownTotalMs by rememberSaveable { mutableLongStateOf(60_000L) }
// Lap tracking with immutable collections
var laps by rememberSaveable { mutableStateOf(listOf<Long>()) }
// System integration
val snack = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
}
Kotlin State Management Highlights:
🔒 Type-Safe State Declaration:
var baseMs by rememberSaveable { mutableLongStateOf(0L) } // Long for precision
var laps by rememberSaveable { mutableStateOf(listOf<Long>()) } // Immutable collections
💎 Kotlin Features at Work:
-
Property Delegates:
by rememberSaveable
creates clean, readable state - Type Inference: Compiler ensures correct types without verbose declarations
-
Default Values:
0L
andlistOf<Long>()
provide safe initial states -
Immutable Collections:
listOf()
prevents accidental mutations
📱 Configuration Survival:
-
rememberSaveable
automatically handles screen rotation - State persists across process death and recreation
- No manual save/restore logic needed
⏱️ Real-Time Timer Logic: Kotlin Coroutines Mastery
The heart of our stopwatch demonstrates advanced coroutine patterns for precise timing:
LaunchedEffect(isRunning, baseMs, countdownMode, countdownTotalMs) {
if (!isRunning) return@LaunchedEffect
while (isRunning) {
val now = SystemClock.elapsedRealtime() // System monotonic time
elapsedMs = now - baseMs
if (countdownMode) {
val remaining = (countdownTotalMs - elapsedMs).coerceAtLeast(0L)
if (remaining == 0L) {
isRunning = false
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
scope.launch { snack.showSnackbar("Minuteur terminé ⏰") }
}
}
delay(50) // 20 FPS update rate (smooth but efficient)
}
}
Advanced Kotlin Coroutine Techniques:
🎯 Smart Dependency Tracking:
LaunchedEffect(isRunning, baseMs, countdownMode, countdownTotalMs)
- Automatic Restart: Timer restarts when key dependencies change
- Efficient Cancellation: Previous coroutine cancelled when parameters change
- No Memory Leaks: Coroutine lifecycle tied to composable
⚡ Performance Optimizations:
delay(50) // 50ms = 20 FPS
- Balance: Smooth visuals without excessive CPU usage
- Battery Friendly: Lower frequency than 60 FPS reduces power consumption
- User Perception: 20 FPS feels fluid for timer displays
🔄 Cooperative Cancellation:
while (isRunning) {
// Coroutine checks isRunning each iteration
// Responds immediately to state changes
}
🎯 Precise Time Calculation:
val now = SystemClock.elapsedRealtime() // Monotonic clock
elapsedMs = now - baseMs
- Monotonic Time: Unaffected by system time changes
- Accuracy: Compensates for processing delays
- Reliability: Works during device sleep/wake cycles
🎛️ Dual-Mode Functionality: Kotlin's Expressive Power
Our stopwatch doubles as a countdown timer, showcasing Kotlin's ability to handle complex state transitions elegantly:
// Unified display logic
val displayMs = if (countdownMode)
(countdownTotalMs - elapsedMs).coerceAtLeast(0L)
else
elapsedMs
// Dynamic progress calculation
val progress = when {
countdownMode && countdownTotalMs > 0 ->
(displayMs.toFloat() / countdownTotalMs).coerceIn(0f, 1f)
!countdownMode && elapsedMs > 0 ->
((elapsedMs % 60_000).toFloat() / 60_000f).coerceIn(0f, 1f)
else -> 0f
}
// Mode switch with state reset
Switch(
checked = countdownMode,
onCheckedChange = { newMode ->
countdownMode = newMode
elapsedMs = 0L // Clean state transition
isRunning = false // Stop any running timer
}
)
Kotlin Expression Excellence:
🎯 When Expressions for Complex Logic:
val progress = when {
countdownMode && countdownTotalMs > 0 -> /* countdown logic */
!countdownMode && elapsedMs > 0 -> /* stopwatch logic */
else -> 0f // Safe default
}
🔧 Extension Functions for Safety:
(countdownTotalMs - elapsedMs).coerceAtLeast(0L) // Never negative
(displayMs.toFloat() / countdownTotalMs).coerceIn(0f, 1f) // Always 0-1 range
💡 Smart State Management:
- Clean Transitions: Mode changes reset relevant state
- Consistent Behavior: Same timer logic for both modes
- Type Safety: Float calculations with safe bounds
2. Intelligent Button States
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (!isRunning) {
Button(onClick = { start() }) { Text("Démarrer") }
} else {
Button(onClick = { pause() }) { Text("Pause") }
OutlinedButton(
onClick = { lap() },
enabled = !countdownMode // Laps only for stopwatch
) {
Text("Tour")
}
}
OutlinedButton(
onClick = { reset() },
enabled = elapsedMs > 0L // Only when there's something to reset
) {
Text("Reset")
}
}
🔧 Kotlin-Powered Button Logic:
-
Conditional Rendering:
if-else
expressions for button switching -
Smart Enabling:
enabled = !countdownMode
prevents invalid operations - State-Based UI: Buttons reflect current timer state automatically
3. Lap Tracking with Performance
if (!countdownMode && laps.isNotEmpty()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) {
itemsIndexed(laps) { index, lapTime ->
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Tour #${laps.size - index}") // Reverse numbering
Text(formatMs(lapTime))
}
}
}
}
}
⚡ Performance Optimizations:
- LazyColumn: Only renders visible lap items
- Conditional Rendering: Laps only shown when relevant
-
Immutable State:
listOf<Long>()
enables efficient diffing -
Smart Indexing:
laps.size - index
for reverse chronological order
🎯 Local Functions: Clean Kotlin Architecture
Our timer operations demonstrate Kotlin's local functions for clean encapsulation:
@Composable
fun StopwatchScreen() {
// ... state declarations ...
// Local functions with closure access
fun start() {
if (!isRunning) {
baseMs = SystemClock.elapsedRealtime() - elapsedMs
isRunning = true
}
}
fun pause() {
isRunning = false
}
fun reset() {
isRunning = false
elapsedMs = 0L
laps = emptyList()
}
fun lap() {
if (isRunning) laps = listOf(elapsedMs) + laps // Prepend new lap
}
// ... UI code uses these functions ...
}
Why Local Functions Excel Here:
🔒 Encapsulation Benefits:
- Scope Limitation: Functions only available within composable
- State Access: Direct access to local state variables
- No Boilerplate: No need for separate classes or interfaces
⚡ Performance Advantages:
- Inline Optimization: Compiler can inline these calls
- No Object Creation: Functions are compiled as local methods
- Direct State Mutation: No indirection through ViewModels
🎯 Readability Wins:
- Clear Intent: Each function has a single, obvious purpose
- Consistent Patterns: All functions follow same state mutation approach
- Easy Testing: Could be extracted to ViewModel if needed
🔧 Kotlin Time Formatting: Extension Function Excellence
Our time display showcases Kotlin's string formatting capabilities:
private fun formatMs(ms: Long): String {
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
val hundredths = (ms % 1000) / 10
return "%02d:%02d.%02d".format(minutes, seconds, hundredths)
}
Advanced Formatting Techniques:
🎯 Potential Kotlin Extensions:
// Could be enhanced with extension functions
fun Long.toTimerFormat(): String {
val totalSeconds = this / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
val hundredths = (this % 1000) / 10
return "%02d:%02d.%02d".format(minutes, seconds, hundredths)
}
// Usage: displayMs.toTimerFormat()
💡 Kotlin String Templates Alternative:
private fun formatMs(ms: Long): String {
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
val hundredths = (ms % 1000) / 10
return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${hundredths.toString().padStart(2, '0')}"
}
🧪 Testing Challenges & Kotlin Solutions
While our current StopwatchScreen doesn't include tests, let's explore how Kotlin would make testing elegant:
Testable Architecture Refactoring
// Extractable timer logic for testing
class StopwatchLogic {
private var isRunning = false
private var baseMs = 0L
private var elapsedMs = 0L
fun start(currentTimeMs: Long = SystemClock.elapsedRealtime()) {
if (!isRunning) {
baseMs = currentTimeMs - elapsedMs
isRunning = true
}
}
fun updateTime(currentTimeMs: Long): Long {
return if (isRunning) {
elapsedMs = currentTimeMs - baseMs
elapsedMs
} else elapsedMs
}
}
Kotlin Testing Advantages:
🧪 Coroutine Testing:
@Test
fun `timer updates correctly with virtual time`() = runTest {
// Kotlin's virtual time testing
val logic = StopwatchLogic()
logic.start(0L)
assertEquals(1000L, logic.updateTime(1000L))
assertEquals(2500L, logic.updateTime(2500L))
}
⚡ Property-Based Testing:
@Test
fun `timer is always monotonic`() {
val logic = StopwatchLogic()
var lastTime = 0L
logic.start()
repeat(100) { iteration ->
val currentTime = logic.updateTime(iteration * 100L)
assertTrue("Time should be monotonic", currentTime >= lastTime)
lastTime = currentTime
}
}
🚀 Production Considerations & Kotlin Best Practices
Performance Optimizations
⚡ Memory Efficiency:
-
Primitive Collections: Use
LongArray
for many laps instead ofList<Long>
- Object Pooling: Reuse formatting strings for frequent updates
- Coroutine Scoping: Proper cleanup prevents memory leaks
🔋 Battery Optimization:
// Adaptive update rate based on precision needs
val updateDelayMs = if (elapsedMs < 10_000) 50L else 100L // Higher precision for first 10 seconds
delay(updateDelayMs)
Kotlin Coroutine Excellence
🎯 Structured Concurrency:
-
Lifecycle Awareness:
LaunchedEffect
ties coroutine to composable lifecycle - Cancellation Propagation: Parent cancellation stops timer automatically
- Exception Handling: Coroutine exceptions don't crash the app
⚡ Advanced Patterns:
// Could use Flow for reactive timer updates
val timerFlow = flow {
while (currentCoroutineContext().isActive) {
emit(SystemClock.elapsedRealtime())
delay(50)
}
}.map { currentTime -> currentTime - baseMs }
🎓 Key Takeaways & Kotlin Insights
🏗️ Architecture Lessons
- Appropriate Complexity: Not every feature needs MVVM - choose architecture based on requirements
- Kotlin Coroutines: Perfect for real-time applications with precise timing needs
- Local State: Compose + Kotlin state management can handle complex scenarios elegantly
- Performance First: Direct state updates outperform complex abstractions for real-time UI
⚡ Kotlin Language Features
-
Property Delegates:
by rememberSaveable
creates clean state management - Coroutines: Natural async programming without callback complexity
- Local Functions: Clean encapsulation within composables
- Extension Functions: Enhance readability and reusability
- When Expressions: Handle complex conditional logic elegantly
🎯 Compose Integration
- Reactive State: Automatic UI updates with Kotlin property delegates
-
Lifecycle Integration:
LaunchedEffect
provides perfect coroutine scoping - Performance: Direct state updates enable smooth real-time interfaces
-
Configuration Survival:
rememberSaveable
handles Android lifecycle automatically
💭 Final Thoughts
This Stopwatch application demonstrates that architecture should match complexity. While MVVM excels for complex features with external dependencies, self-contained composables with Kotlin coroutines can be more appropriate for real-time, performance-critical features.
The combination of Kotlin's coroutines, Compose's reactive state management, and pragmatic architectural decisions creates a timer that's both precise and maintainable. By leveraging Kotlin's language features—from property delegates to when expressions—we've built a feature that feels native to both the language and the platform.
Top comments (0)