DEV Community

loading...
Cover image for Kotlin Flow - Implementing an Android Timer

Kotlin Flow - Implementing an Android Timer

aniketsmk profile image Aniket Kadam ・5 min read

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

A purple ring, with a black background. It starts with a - inside the ring. Once it's tapped, the - turns into the number 60 and begins counting down. It's tapped again and turns into a - again.

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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"

}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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) }
Enter fullscreen mode Exit fullscreen mode

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))
            }
Enter fullscreen mode Exit fullscreen mode

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.

  1. When the Flow is engaged, we immediately emit the total seconds as the first value on the countdown. Which means emitting totalSeconds with onStart.
.onStart { emit(totalSeconds) }
Enter fullscreen mode Exit fullscreen mode

Then, the flow actually emits its first delayed value. The one for the next second. That's why the flow starts with

(totalSeconds - 1 downTo 0).asFlow()
Enter fullscreen mode Exit fullscreen mode

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))
            }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
Enter fullscreen mode Exit fullscreen mode

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) }
            }
Enter fullscreen mode Exit fullscreen mode

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))
        }
}
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

And finally it's used in the MainActivity like so:

val vm = viewModel<TimerVm>()
val timerState = vm.timerStateFlow.collectAsState()
TimerDisplay(timerState.value, vm::toggleStart)
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)

pic
Editor guide