# 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](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/transform-while.html) (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.

```kotlin
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
```

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

```kotlin
// Looks right. Is wrong.
fun Flow<UploadState>.untilSettled(): Flow<UploadState> =
    takeWhile { !it.isTerminal }
```

`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](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/f7933375-cb7e-4602-b448-fc138a227933.png align="center")

The fix is to emit first and decide afterwards:

```kotlin
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
    }
```

## 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](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/b4648fb1-9169-4f09-8c44-ac30a7feb027.png align="center")

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

![operator-matrix](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/a81e7471-6625-44ca-8357-12c22f19fa42.png align="center")

`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.

```kotlin
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()
```

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.

```kotlin
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
    }
```

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

```kotlin
transformWhile { state ->
    if (state.isTerminal) return@transformWhile false   // value vanishes silently
    emit(state)
    true
}
```

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`

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

`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:

```kotlin
// 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
    }
}
```

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:

```kotlin
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
            }
        )
    }
}
```

![state-ownership](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/ad010651-b993-4ff2-a065-2339c99b4eda.png align="center")

## 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:

```kotlin
// 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
        }
    }
}
```

`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](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/52702e3a-34a3-4922-98f7-4d59f530c6b3.png align="center")

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:

```kotlin
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) }
}
```

```kotlin
// 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
            }
        }
    }
}
```

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:

```kotlin
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() }
```

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:

```kotlin
fun Flow<UploadState>.safeUntilSettled(): Flow<UploadState> =
    untilSettled().catch { emit(UploadState.Failed(it)) }
```

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.

```kotlin
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())
    }
}
```

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.

- **E-book:** [**Available on Google Play**](https://play.google.com/store/books/details?id=2wYiEgAAQBAJ)
- **Kindle Edition:** [**Available on Amazon**](https://www.amazon.com/dp/B0HP9G8MGK)
- **Topmate:** [**Available on Topmate**](https://topmate.io/sivavishnu_r/2377993)
- Also available in **Paperback & Hardcover.**

---

*Originally published on* [*Medium*](https://medium.com/@sivavishnu0705/the-last-frame-matters-finishing-android-flows-with-transformwhile-b417c111b77e)*.*
