Currently, I'm working on a Jetpack Compose Android app which amongst other things allows the user to start multiple stopwatches.
This is the first part where I show a simplified version of the implementation which will only handle one stopwatch. The second part will include the full implementation for multiple stopwatches along with testing.
State
To represent the stopwatch state I'm using a sealed class with two sub-types:
sealed class StopwatchState {
data class Paused(
val elapsedTime: Long
) : StopwatchState()
data class Running(
val startTime: Long,
val elapsedTime: Long
) : StopwatchState()
}
The Paused state contains the elapsed time used in order to preserve the time between stopwatch state changes.
The Running state contains information about when the stopwatch was started and what the current elapsed time is. At the beginning the elapsed time will be 0, but every time the stopwatch is paused and started this value changes.
The Stopped state is not required because if the stopwatch is stopped then the state cannot be further changed. The final time can be calculated from either the Paused or Running state.
The current timestamp of the system is provided with the TimestampProvider interface:
interface TimestampProvider {
fun getMilliseconds(): Long
}
State changes between Running and Paused are calculated in this class:
class StopwatchStateCalculator(
private val timestampProvider: TimestampProvider,
private val elapsedTimeCalculator: ElapsedTimeCalculator,
) {
fun calculateRunningState(oldState: StopwatchState): StopwatchState.Running =
when (oldState) {
is StopwatchState.Running -> oldState
is StopwatchState.Paused -> {
StopwatchState.Running(
startTime = timestampProvider.getMilliseconds(),
elapsedTime = oldState.elapsedTime
)
}
}
fun calculatePausedState(oldState: StopwatchState): StopwatchState.Paused =
when (oldState) {
is StopwatchState.Running -> {
val elapsedTime = elapsedTimeCalculator.calculate(oldState)
StopwatchState.Paused(elapsedTime = elapsedTime)
}
is StopwatchState.Paused -> oldState
}
}
If the state remains unchanged, the parameter is just returned.
Paused -> Running:
Saves the current timestamp as the stopwatch start time. The elapsed time is reused from the previous state because in the paused state the stopwatch should not be "running".
Running -> Paused:
Calculates the elapsed time based on the stopwatch start time and the current elapsed time. This calculation is explained in the next section
Calculating the elapsed time
class ElapsedTimeCalculator(
private val timestampProvider: TimestampProvider,
) {
fun calculate(state: StopwatchState.Running): Long {
val currentTimestamp = timestampProvider.getMilliseconds()
val timePassedSinceStart = if (currentTimestamp > state.startTime) {
currentTimestamp - state.startTime
} else {
0
}
return timePassedSinceStart + state.elapsedTime
}
}
The condition check (currentTimestamp > state.startTime) should always be true, but you never know. Calculating the elapsed time consists of subtracting the stopwatch start time from the current timestamp and then adding the result to the existing elapsed time. This elapsed time is responsible for preserving the time between the stopwatch state changes.
Here's an accompanying test showing how the diagram looks in code, the TimestampProvider is a mock however the ElapsedTimeCalculator is the real implementation:
@Test
fun `Article example test`() {
val timestampProvider: TimestampProvider = mockk()
val elapsedTimeCalculator = ElapsedTimeCalculator(timestampProvider)
val stopwatchStateCalculator = StopwatchStateCalculator(
timestampProvider = timestampProvider,
elapsedTimeCalculator = elapsedTimeCalculator
)
every { timestampProvider.getMilliseconds() } returns 0
val initialState = StopwatchState.Paused(0L)
val firstStart = stopwatchStateCalculator.calculateRunningState(initialState)
expectThat(firstStart.startTime).isEqualTo(0L)
expectThat(firstStart.elapsedTime).isEqualTo(0L)
every { timestampProvider.getMilliseconds() } returns 100
val firstPause = stopwatchStateCalculator.calculatePausedState(firstStart)
expectThat(firstPause.elapsedTime).isEqualTo(100L)
every { timestampProvider.getMilliseconds() } returns 1000
val secondStart = stopwatchStateCalculator.calculateRunningState(firstPause)
expectThat(secondStart.startTime).isEqualTo(1000L)
expectThat(secondStart.elapsedTime).isEqualTo(100L)
every { timestampProvider.getMilliseconds() } returns 1500
val secondPause = stopwatchStateCalculator.calculatePausedState(secondStart)
expectThat(secondPause.elapsedTime).isEqualTo(600L)
}
Formatting the stopwatch time
The formatter takes in a timestamp and produces a human-readable time for the UI:
internal class TimestampMillisecondsFormatter() {
companion object {
const val DEFAULT_TIME = "00:00:000"
}
fun format(timestamp: Long): String {
val millisecondsFormatted = (timestamp % 1000).pad(3)
val seconds = timestamp / 1000
val secondsFormatted = (seconds % 60).pad(2)
val minutes = seconds / 60
val minutesFormatted = (minutes % 60).pad(2)
val hours = minutes / 60
return if (hours > 0) {
val hoursFormatted = (minutes / 60).pad(2)
"$hoursFormatted:$minutesFormatted:$secondsFormatted"
} else {
"$minutesFormatted:$secondsFormatted:$millisecondsFormatted"
}
}
private fun Long.pad(desiredLength: Int) = this.toString().padStart(desiredLength, '0')
}
The initial hour in the stopwatch is more dynamic, because the milliseconds are shown but after one hour the milliseconds are omitted in favor of showing the hour. Additionally, the numbers are padded with one or two leading zeros.
Managing the stopwatch state
Because the states are self-contained and do not contain any behavior, an additional class is needed for managing the state changes:
internal class StopwatchStateHolder(
private val stopwatchStateCalculator: StopwatchStateCalculator,
private val elapsedTimeCalculator: ElapsedTimeCalculator,
private val timestampMillisecondsFormatter: TimestampMillisecondsFormatter,
) {
var currentState: StopwatchState = StopwatchState.Paused(0)
private set
fun start() {
currentState = stopwatchStateCalculator.calculateRunningState(currentState)
}
fun pause() {
currentState = stopwatchStateCalculator.calculatePausedState(currentState)
}
fun stop() {
currentState = StopwatchState.Paused(0)
}
fun getStringTimeRepresentation(): String {
val elapsedTime = when (val currentState = currentState) {
is StopwatchState.Paused -> currentState.elapsedTime
is StopwatchState.Running -> elapsedTimeCalculator.calculate(currentState)
}
return timestampMillisecondsFormatter.format(elapsedTime)
}
}
This class provides an API for managing the stopwatch state. For convenience, this class also exposes a method for retrieving the formatted elapsed time of the stopwatch.
Using Kotlin Coroutines with Flow
The StopwatchStateHolder provides all the required functions for interacting with the stopwatch, however the stopwatch updates should not be running on the main thread. Doing so would result in an unusable UI where the only thing happening would be the stopwatch changes.
An additional abstraction needs to be created which offloads the stopwatch updates to a background thread:
internal class StopwatchListOrchestrator(
private val stopwatchStateHolder: StopwatchStateHolder,
private val scope: CoroutineScope,
) {
private var job: Job? = null
private val mutableTicker = MutableStateFlow("")
val ticker: StateFlow<String> = mutableTicker
fun start() {
if (job == null) startJob()
stopwatchStateHolder.start()
}
private fun startJob() {
scope.launch {
while (isActive) {
mutableTicker.value = stopwatchStateHolder.getStringTimeRepresentation()
delay(20)
}
}
}
fun pause() {
stopwatchStateHolder.pause()
stopJob()
}
fun stop() {
stopwatchStateHolder.stop()
stopJob()
clearValue()
}
private fun stopJob() {
scope.coroutineContext.cancelChildren()
job = null
}
private fun clearValue() {
mutableTicker.value = ""
}
}
Keep in mind that this StopwatchListOrchestrator implementation was simplified for this article. The real implementation is able to handle multiple stopwatches at the same time. The reasoning for injecting a CoroutineScope is related to testing, but it will be explained more in-depth in the next part.
The start function creates a coroutine job which will be responsible for updating the stopwatch time. It consists of a while loop with a condition that checks whether the coroutine job is still active. Inside the loop the stopwatch time is updated in a 20 milliseconds interval. The time update happens through a StateFlow which ends up being collected in the UI.
The pause and stop functions stop the coroutine job because the stopwatch time will not be updated anymore. Additionally, the stop function resets the stopwatch and sets the default value for the stopwatch time (an empty strings prevents the stopwatch from being shown in the UI).
Please note that the coroutine job is stopped using cancelChildren() instead of cancel(). Canceling the children preserves the scope and makes it possible to launch other coroutines. Using the cancel method would prevent the stopwatch from working after pausing or stopping it. The reason for this is that cancel() cancels the whole coroutine scope, preventing any new coroutine from being launched in that scope.
How it looks in the App
Summary
The code shown here and the rest of the application is available on my GitHub.
Kotlin coroutines provide an easy way of starting and stopping background operations, provided all the coroutine gotchas are taken care of (like using cancelChildren instead of cancel).
Flows provide a nice and simple API for creating and consuming asynchronous flows which play nicely with the rise of declarative UIs.
Combining these concepts shows how easy it is to implement a simple stopwatch (A timer could also be implemented in a similar way). No need for working with bare threads or creating RxJava streams with an interval.
The next article will build upon what was described here and add support for multiple simultaneous stopwatches.