DEV Community

Cover image for The Last Frame Matters: Finishing Android Flows with transformWhile
Android Expert
Android Expert

Posted on Originally published at Medium

The Last Frame Matters: Finishing Android Flows with transformWhile

A field guide to ending a Kotlin Flow on your terms, without losing the one value users actually care about.

Every Android codebase eventually grows a screen that watches something finish: an upload, a payment, a sync, a background job. The data source is a stream, the stream has an ending, and the ending is the whole point. Yet the first operator most of us reach for to stop that stream, takeWhile, quietly throws the ending away.

This post is about the operator that fixes it, transformWhile from kotlinx.coroutines.flow, and about the production failure modes that show up once you start relying on it. The behavior described here follows the official transformWhile API reference (documented against kotlinx.coroutines 1.11.0 at the time of writing). Everything else comes from patterns I would want a reviewer to catch.

Version baseline. transformWhile was introduced as an experimental operator in kotlinx.coroutines 1.4.0 and promoted to stable in 1.6.0. Target 1.6.0 or newer, or you will hit @ExperimentalCoroutinesApi opt-in errors on older enterprise codebases. The samples also use data object (Kotlin 1.9+) and trailing commas (Kotlin 1.4+).

1. The bug that ships: the finish line gets swallowed

Here is the model we will use throughout. It is deliberately small.

sealed interface UploadState {
    data object Queued : UploadState
    data class Progress(val percent: Int) : UploadState
    data class Success(val remoteUrl: String) : UploadState
    data class Failed(val reason: Throwable) : UploadState
}

val UploadState.isTerminal: Boolean
    get() = this is UploadState.Success || this is UploadState.Failed
Enter fullscreen mode Exit fullscreen mode

A developer wants the collector to stop once the upload settles, so they write the obvious thing:

// Looks right. Is wrong.
fun Flow<UploadState>.untilSettled(): Flow<UploadState> =
    takeWhile { !it.isTerminal }
Enter fullscreen mode Exit fullscreen mode

takeWhile stops before the first value that fails the predicate. The Success or Failed value is exactly the one that fails it, so the UI sees a progress bar frozen at 70% and a flow that ends politely, with no result. In a debug build with a fast fake, you may never notice. In production, the last value is the one carrying the URL or the error.

boundary-element

The fix is to emit first and decide afterwards:

fun Flow<UploadState>.untilSettled(): Flow<UploadState> =
    transformWhile { state ->
        emit(state)            // deliver the value, including the terminal one
        !state.isTerminal      // keep going only while the upload is unsettled
    }
Enter fullscreen mode Exit fullscreen mode

2. The contract in one minute

transformWhile takes a suspending lambda whose receiver is a FlowCollector<R>. The lambda runs for each upstream value and returns a Boolean: true means "keep collecting upstream", false means "stop now". That is the whole protocol, and three consequences follow from it:

  • You control emission. Inside the lambda you may emit nothing, one value, or many, and the output type R can differ from the input type T.
  • Emission and termination are decoupled. The value you emit and the value you return are independent decisions, which is why the boundary element can be delivered.
  • takeWhile is a special case. The reference describes transformWhile as a generalization of takeWhile and a handy building block for other operators.

Use this to choose between the options:

operator-decision

And here is how it compares with the other common way to wait for a terminal value:

operator-matrix

first { } is a good fit when you only need the single settled result as a suspend call. Reach for transformWhile when downstream still needs the journey (progress, intermediate states) and the destination.

3. A production-shaped example: watching a WorkManager upload

Real uploads run in WorkManager so they survive process death. getWorkInfoByIdFlow gives you a stream of WorkInfo, and it has a natural ending: a succeeded, failed or cancelled state. Map it to the domain model and finish on the terminal state.

import androidx.work.WorkInfo
import androidx.work.WorkManager
import java.util.UUID
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

class UploadCancelled : Exception("Upload was cancelled")

private fun WorkInfo.toUploadState(): UploadState = when (state) {
    WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> UploadState.Queued
    WorkInfo.State.RUNNING -> UploadState.Progress(progress.getInt("percent", 0))
    WorkInfo.State.SUCCEEDED -> UploadState.Success(outputData.getString("url").orEmpty())
    WorkInfo.State.FAILED ->
        UploadState.Failed(IllegalStateException(outputData.getString("error") ?: "Upload failed"))
    WorkInfo.State.CANCELLED -> UploadState.Failed(UploadCancelled())
}

fun WorkManager.observeUpload(id: UUID): Flow<UploadState> =
    getWorkInfoByIdFlow(id)
        .map { it.toUploadState() }
        .untilSettled()
Enter fullscreen mode Exit fullscreen mode

In current work-runtime releases, getWorkInfoByIdFlow returns a non-null Flow<WorkInfo>, so no null filtering is needed and adding filterNotNull() would only be a redundant call that strict lint or -Werror setups may flag. If you are pinned to an older release whose element type is nullable, add .filterNotNull() before the map; check the signature in your version.

4. More than a stopper: skip, expand, retype

Because you own the collector inside the lambda, one operator can do the jobs of three.

sealed interface UploadUi {
    data object Spinner : UploadUi
    data class Bar(val percent: Int) : UploadUi
    data class Done(val url: String) : UploadUi
    data class Error(val message: String) : UploadUi
}

fun Flow<UploadState>.asUiUntilSettled(): Flow<UploadUi> =
    transformWhile { state ->
        when (state) {
            UploadState.Queued -> emit(UploadUi.Spinner)
            is UploadState.Progress -> emit(UploadUi.Bar(state.percent))
            is UploadState.Success -> {
                emit(UploadUi.Bar(100))               // one input, two outputs
                emit(UploadUi.Done(state.remoteUrl))
            }
            is UploadState.Failed ->
                emit(UploadUi.Error(state.reason.message ?: "Upload failed"))
        }
        !state.isTerminal
    }
Enter fullscreen mode Exit fullscreen mode

Success turns into two UI events, the type changes from UploadState to UploadUi, and the stream still ends on the terminal value. Skipping is just as easy: return true without calling emit.

5. Failure modes that survive code review

5.1 Returning false before you emit

transformWhile { state ->
    if (state.isTerminal) return@transformWhile false   // value vanishes silently
    emit(state)
    true
}
Enter fullscreen mode Exit fullscreen mode

Nothing crashes. The flow completes and the final value never existed. Make it a habit: the emit call comes first, the Boolean is the last expression of the lambda, and an early return is a code smell.

5.2 Swallowing the stop signal around emit

transformWhile { state ->
    try {
        emit(state)
    } catch (e: Exception) {      // too broad
        log.warn("render failed", e)
    }
    !state.isTerminal
}
Enter fullscreen mode Exit fullscreen mode

emit can throw cancellation-type exceptions, including when something downstream decides to stop early or the collecting coroutine is cancelled. A blanket catch (e: Exception) eats those and lets the lambda keep running as if nothing happened, which breaks structured concurrency. If you must handle errors, catch narrow types, rethrow CancellationException, or move the error handling downstream with catch { } where it belongs.

5.3 Mutable state captured outside the flow

Say you want to throttle progress to steps of five percent. A counter feels natural:

// Broken: `lastShown` outlives the first collection.
fun Flow<UploadState>.coarseBroken(step: Int = 5): Flow<UploadState> {
    var lastShown = -step
    return transformWhile { state ->
        val skip = state is UploadState.Progress && state.percent - lastShown < step
        if (!skip) {
            if (state is UploadState.Progress) lastShown = state.percent
            emit(state)
        }
        !state.isTerminal
    }
}
Enter fullscreen mode Exit fullscreen mode

Flows are cold, so the returned flow can be collected more than once: a retry, a second screen, a test. The second collection starts from the first one’s leftovers and silently drops most of its progress. Scope the state to each collection by creating it inside a flow { } builder:

fun Flow<UploadState>.coarseUntilSettled(step: Int = 5): Flow<UploadState> {
    val upstream = this
    return flow {
        var lastShown = -step                      // fresh for every collector
        emitAll(
            upstream.transformWhile { state ->
                val skip = state is UploadState.Progress && state.percent - lastShown < step
                if (!skip) {
                    if (state is UploadState.Progress) lastShown = state.percent
                    emit(state)
                }
                !state.isTerminal
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

state-ownership

5.4 The replayed terminal state

This one bites on real devices, not in unit tests. If the source is a StateFlow, it keeps its latest value for as long as it lives. A terminal Success stays in the flow after the first collector has handled it, so any new collector receives it immediately:

// Fragment: runs again after rotation, and again every time we return to STARTED
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.state.untilSettled().collect { state ->
            render(state)
            if (state is UploadState.Success) navigator.openReceipt(state.remoteUrl)  // fires again
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

untilSettled() finished the first collection correctly. The problem is that repeatOnLifecycle starts a new collection from scratch each time the lifecycle reaches STARTED, and the new one is handed the old terminal value.

stateflow-replay

The fix is to stop treating navigation as an event and model it as state that the UI acknowledges. Rendering stays idempotent, and the pending action lives in the state until the UI reports it has been handled, so a replay can never fire it twice:

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.util.UUID
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

data class UploadScreenState(
    val upload: UploadState = UploadState.Queued,
    val receiptToOpen: String? = null,        // navigation modeled as state
)

class UploadViewModel(private val uploads: UploadRepository) : ViewModel() {

    private val _screen = MutableStateFlow(UploadScreenState())
    val screen: StateFlow<UploadScreenState> = _screen.asStateFlow()

    private var watchedId: UUID? = null
    private var watchJob: Job? = null

    fun start(id: UUID) {
        if (watchedId == id) return                        // same upload: safe on rotation, even after it settled
        watchJob?.cancel()                                 // a different upload: stop watching the old one
        watchedId = id
        _screen.value = UploadScreenState()                // fresh state for the new upload
        watchJob = viewModelScope.launch {
            uploads.observe(id)                            // cold source with a real ending
                .untilSettled()
                .collect { state ->
                    _screen.update { current ->
                        current.copy(
                            upload = state,
                            receiptToOpen =
                                if (state is UploadState.Success) state.remoteUrl
                                else current.receiptToOpen,
                        )
                    }
                }
        }
    }

    fun onReceiptOpened() = _screen.update { it.copy(receiptToOpen = null) }
}
Enter fullscreen mode Exit fullscreen mode
// Fragment: a plain collect of state; no terminal operator needed on the UI side
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.screen.collect { ui ->
            render(ui.upload)
            ui.receiptToOpen?.let { url ->
                navigator.openReceipt(url)
                viewModel.onReceiptOpened()                // acknowledge: replay is now harmless
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice that start is guarded by the upload id, not by whether the job is still active. An isActive check looks tempting, but once the stream settles the job completes, and the next rotation would relaunch the watcher and raise receiptToOpen again after the UI had already acknowledged it. Comparing ids keeps rotation idempotent while still allowing a new upload to take over; a retry should enqueue new work with a new id.

transformWhile still earns its place, but on the cold, finite source inside the ViewModel, not on a long-lived StateFlow that the UI re-subscribes to.

A note on the alternative many teams reach for: a Channel exposed with receiveAsFlow() delivers each event to one consumer, but it has no notion of lifecycle. An event can be consumed by a collector that is being torn down during a configuration change and never reach the new UI. Android's architecture guidance leans toward representing UI events as state for this reason. If you do choose a channel, treat that lost-delivery risk as a known trade-off and test rotation explicitly.

5.5 A terminal state that never arrives

transformWhile can only end the flow when your predicate says so. If the process dies, the job is evicted, or a bug means the terminal value is never produced, the collector suspends forever. Put a deadline on any wait that blocks a user-facing path:

import kotlin.time.Duration
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.withTimeoutOrNull

suspend fun Flow<UploadState>.awaitSettled(timeout: Duration): UploadState? =
    withTimeoutOrNull(timeout) { untilSettled().lastOrNull() }
Enter fullscreen mode Exit fullscreen mode

The Duration here is kotlin.time.Duration, which withTimeoutOrNull accepts directly on recent coroutines versions. A null means "no verdict in time, or the upstream ended empty". Treat that as its own UI case rather than as success or failure.

5.6 Upstream errors arrive before your terminal state

If the upstream throws, transformWhile does not get to see a terminal value. Decide deliberately whether an exception should become a domain value:

fun Flow<UploadState>.safeUntilSettled(): Flow<UploadState> =
    untilSettled().catch { emit(UploadState.Failed(it)) }
Enter fullscreen mode Exit fullscreen mode

Place catch after the terminating operator so that it only handles upstream failures, and never rethrow-swallow cancellation inside it.

6. Test the ending, not just the middle

Terminal behavior is easy to assert and easy to get wrong, so pin it down.

class UntilSettledTest {

    @Test
    fun `delivers the terminal state then stops`() = runTest {
        val source = flowOf(
            UploadState.Queued,
            UploadState.Progress(40),
            UploadState.Success("https://cdn.example/a.jpg"),
            UploadState.Progress(99),            // must never be seen
        )

        assertEquals(
            listOf(
                UploadState.Queued,
                UploadState.Progress(40),
                UploadState.Success("https://cdn.example/a.jpg"),
            ),
            source.untilSettled().toList(),
        )
    }

    @Test
    fun `two collections of the same flow behave identically`() = runTest {
        val flow = flowOf(
            UploadState.Progress(0), UploadState.Progress(2),
            UploadState.Progress(7), UploadState.Success("u"),
        ).coarseUntilSettled(step = 5)

        assertEquals(flow.toList(), flow.toList())
    }
}
Enter fullscreen mode Exit fullscreen mode

The second test fails against coarseBroken and passes against coarseUntilSettled, which is exactly the kind of regression it exists to catch.

7. Review checklist

  • Does downstream need the value that ends the stream? If yes, transformWhile, not takeWhile.
  • Is emit called before the lambda returns false?
  • Is any try/catch around emit rethrowing cancellation?
  • Is mutable state created inside a flow { } so every collection starts fresh?
  • Is the source a StateFlow or SharedFlow with replay? If so, is any one-shot action modeled as acknowledged state so a replay cannot fire it twice?
  • Are you on kotlinx.coroutines 1.6.0 or newer, where transformWhile is stable?
  • What happens if the terminal value never comes? Is there a timeout?
  • Is there a test that collects the flow twice?

🔚 Closing thought

takeWhile answers "when should I stop looking?". transformWhile answers the better question for most UI work: "what should I show, and when am I finished?" Treat the last value as part of the story, scope your state per collection, and keep replaying sources away from one-shot actions, and the operator becomes one of the most dependable tools in your Flow toolbox.

🚀 Master Android Architecture: Volume 2, Architecture at Scale

What happens when your app and your team grow? This volume takes NowReading into modularization, Gradle convention plugins, architecture for large teams and legacy migration. It then covers testing strategy, performance and security, and finishes with Kotlin Multiplatform, adaptive layouts, on-device AI and an end-to-end case study. The appendices collect all 26 ADRs, cheat sheets, a pattern comparison and a system design interview guide.


Originally published on Medium.

Top comments (0)