Kotlin Flow - Implementing an Android Timer
Aniket Kadam
Posted on April 13, 2021
How complex could a timer be? We're about to find out in this dive into understanding Kotlin Flows by implementing one.
We'll be building out the logic with Kotlin Flows in a ViewModel and showing the timer with a Jetpack Compose, Composable. State will be represented with StateFlow.
What's in a timer?
- You have an initial state, when the timer's inactive.
- You have it counting down after it's tapped.
- It resets when it's done.
If you want a jump start by looking at the code here it is.
The UI
The UI part of the timer will be represented by a CircularProgressIndicator and a Text that shows the value of the countdown numerically. The timer only starts when it's tapped and another tap resets it.
Here's the UI code.
@Composable
fun TimerDisplay(timerState: TimerState, toggleStartStop: () -> Unit) {
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator(
timerState.progressPercentage,
Modifier.clickable { toggleStartStop() })
Text(timerState.displaySeconds)
}
}
TimerState is a helper class. All it contains is the progress percentage (When 30 seconds elapses on a 60 second timer, that's 0.5% progress for the CircularProgressIndicator), and the text to show for seconds remaining.
With TimerState, you can provide the remaining seconds and total seconds and it calculates the rest of the information for the Composable.
Here's the code for TimerState
data class TimerState(
val secondsRemaining: Int? = null,
val totalSeconds: Int = 60,
val textWhenStopped: String = "-"
) {
val displaySeconds: String =
(secondsRemaining ?: textWhenStopped).toString()
// Show 100% if seconds remaining is null
val progressPercentage: Float =
(secondsRemaining ?: totalSeconds) / totalSeconds.toFloat()
// Always implement toString from Effective Java Item 9
override fun toString(): String = "Seconds Remaining $secondsRemaining, totalSeconds: $totalSeconds, progress: $progressPercentage"
}
There are some nuances about it you can read or skip. The nuances are, what do you show when the timer is stopped? For this data class, stopped is represented by the int for remaining seconds being null. By providing only secondsRemaining and totalSeconds the rest of the information which our TimerDisplay needs is calculated.
The Flow of Logic
I'm going to encapsulate the logic for the timer in a class called a TimerUseCase.
Here's how it's going to work.
(totalSeconds downTo 0).asFlow()
Effectively creates a list of numbers from the total number of seconds to 0 and emits them one by one as a Flow.
If totalSeconds was 5, we'd get 5,4,3,2,1,0 emitted.
In the final code we'd subtract this by 1 but we'll see why in a bit. Psst, it's related on the onStart.
(totalSeconds downTo 0).asFlow()
.onEach { delay(1000) }
Means whenever an item is emitted from this Flow, first we'll wait for 1 second and then let it proceed down the chain.
This is how the ticking of the timer is implemented.
.transform { remainingSeconds: Int ->
emit(TimeState(remainingSeconds))
}
This could've just been the next in the chain however there's a problem if we write it that way.
Here the only thing we're doing is creating a TimeState but if there was a more complex operation to be performed, it could take several milliseconds and now we're forcing time drift in the flow chain.
Here's an example. If it takes 1 second to emit the next remaining second but another 200ms to create an object like TimeState, then 1200ms have passed before the next item can be emitted. If this cycle repeats many times over the timer wouldn't be accurate anymore.
So we need something in between. Here's the actual code with 'conflate' being used to run the transform function concurrently (at the same time) on a separate thread from the one that ticks for time.
Also if the code was left as it was, you'd only see the timer begin to tick one second after you tapped it. We want it immediately showing the full time and then begin to tick so we make two modifications.
- When the Flow is engaged, we immediately emit the total seconds as the first value on the countdown. Which means emitting totalSeconds with onStart. ```kotlin
.onStart { emit(totalSeconds) }
Then, the flow actually emits its first delayed value. The one for the next second. That's why the flow starts with
```kotlin
(totalSeconds - 1 downTo 0).asFlow()
Here's the code:
/**
* The timer emits the total seconds immediately.
* Each second after that, it will emit the next value.
*/
fun initTimer(totalSeconds: Int): Flow<TimeState> =
(totalSeconds - 1 downTo 0).asFlow() // Emit total - 1 because the first was emitted onStart
.onEach { delay(1000) } // Each second later emit a number
.onStart { emit(totalSeconds) } // Emit total seconds immediately
.conflate() // In case the creating of State takes some time, conflate keeps the time ticking separately
.transform { remainingSeconds: Int ->
emit(TimeState(remainingSeconds))
}
Additional conditions
We're not done yet!
As it is, the timer can't handle being cancelled nor resetting to a default value once it's done counting. For that we'll need to set onCompletion when it's launched.
Here it is:
private var _timerStateFlow = MutableStateFlow(TimerState())
val timerStateFlow: StateFlow<TimerState> = _timerStateFlow
We'll need to create a private MutableStateFlow to emit the TimerState and a public StateFlow to be bound to the composeable.
private var job: Job? = null
fun toggleTime(totalSeconds: Int) {
if (job == null) {
job = timerScope.launch {...}
} else {
job?.cancel()
job = null
}
}
Once the toggleTime function is called, if there was no Coroutine Job running earlier, it begins a new one.
If this is tapped again, it will cancel the currently running job.
job = timerScope.launch {
initTimer(totalSeconds)
.onCompletion { _timerStateFlow.emit(TimerState()) }
.collect { _timerStateFlow.emit(it) }
}
The onCompletion block is like a 'finally'. Whether the Flow completed normally or with an error, the onCompletion block will be called and reset the TimeState so the UI can be reset.
Also in the collect, we put the received TimeState into the TimerStateFlow for the UI to observe.
Here's it all together.
class TimerUseCase(private val timerScope: CoroutineScope) {
private var _timerStateFlow = MutableStateFlow(TimerState())
val timerStateFlow: StateFlow<TimerState> = _timerStateFlow
private var job: Job? = null
fun toggleTime(totalSeconds: Int) {
if (job == null) {
job = timerScope.launch {
initTimer(totalSeconds)
.onCompletion { _timerStateFlow.emit(TimerState()) }
.collect { _timerStateFlow.emit(it) }
}
} else {
job?.cancel()
job = null
}
}
/**
* The timer emits the total seconds immediately.
* Each second after that, it will emit the next value.
*/
private fun initTimer(totalSeconds: Int): Flow<TimerState> =
// generateSequence(totalSeconds - 1 ) { it - 1 }.asFlow()
(totalSeconds - 1 downTo 0).asFlow() // Emit total - 1 because the first was emitted onStart
.onEach { delay(1000) } // Each second later emit a number
.onStart { emit(totalSeconds) } // Emit total seconds immediately
.conflate() // In case the operation onTick takes some time, conflate keeps the time ticking separately
.transform { remainingSeconds: Int ->
emit(TimerState(remainingSeconds))
}
}
}
The ViewModel
Doing all that work in the UseCase frees up the ViewModel to be very clean like so.
class TimerVm : ViewModel() {
private val timerIntent = TimerUseCase(viewModelScope)
val timerStateFlow: StateFlow<TimerState> = timerIntent.timerStateFlow
fun toggleStart() = timerIntent.toggleTime(60)
}
And finally it's used in the MainActivity like so:
val vm = viewModel<TimerVm>()
val timerState = vm.timerStateFlow.collectAsState()
TimerDisplay(timerState.value, vm::toggleStart)
You've now got a great timer that'll always behave! You might have learned a few things about encapsulation, coroutines and Jetpack Compose along the way too!
I'm on the lookout for a Senior Android position with great customer impact, a great team and great compensation. Let me know if you're hiring! Also open to consulting contracts.
Posted on April 13, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.