๐Ÿง  Android AI Projects and Production

Streaming AI Responses in Jetpack Compose

Streaming makes AI feel fast: users see the first words in a fraction of a second instead of waiting for the full answer. Doing it well in Compose means modelling streaming state, appending efficiently, auto-scrolling politely, and letting users stop.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Intermediate
On this page
  1. Streaming states
  2. ViewModel: accumulate and cancel
  3. Compose UI
  4. Auto-scroll politely
  5. Performance tips
  6. Markdown and partial content
  7. Testing
  8. Frequently asked questions
  9. Conclusion

A streamed response is a Flow of text chunks. The ViewModel accumulates them into UI state, and Compose renders the growing text. The details make the difference: what happens when users stop, rotate, scroll up or hit an error halfway.

Streaming states

State machine for a streamed response Idle moves to Waiting when the user sends. Waiting moves to Streaming when the first chunk arrives. Streaming moves to Done when the stream completes, Stopped when the user cancels, or Failed on error. Failed and Stopped can retry, returning to Waiting. Idle Waiting Streaming Done Stopped (partial kept) Failed (partial + retry) Show "Stop" while Waiting/Streaming; "Retry" after Stopped/Failed.
Model every state explicitly, including partial results.

ViewModel: accumulate and cancel

StreamingViewModel.ktKotlin
sealed interface ReplyState {
    data object Idle : ReplyState
    data object Waiting : ReplyState
    data class Streaming(val text: String) : ReplyState
    data class Done(val text: String) : ReplyState
    data class Stopped(val text: String) : ReplyState
    data class Failed(val partial: String, val message: String) : ReplyState
}

class StreamingViewModel(private val ai: TextStreamer) : ViewModel() {   // TextStreamer: (String) -> Flow<String>
    private val _state = MutableStateFlow<ReplyState>(ReplyState.Idle)
    val state: StateFlow<ReplyState> = _state.asStateFlow()

    private var job: Job? = null
    private var lastPrompt = ""

    fun send(prompt: String) {
        lastPrompt = prompt
        job?.cancel()
        _state.value = ReplyState.Waiting
        job = viewModelScope.launch {
            val buffer = StringBuilder()
            try {
                ai.stream(prompt).collect { chunk ->
                    buffer.append(chunk)
                    _state.value = ReplyState.Streaming(buffer.toString())
                }
                _state.value = ReplyState.Done(buffer.toString())
            } catch (e: CancellationException) {
                _state.value = ReplyState.Stopped(buffer.toString())
                throw e                                           // always rethrow cancellation
            } catch (e: Exception) {
                _state.value = ReplyState.Failed(buffer.toString(), "Response interrupted")
            }
        }
    }

    fun stop() { job?.cancel() }                // cancels the Flow, which cancels the network call
    fun retry() = send(lastPrompt)
}
Cancellation saves money

Cancelling the collecting coroutine cancels the underlying HTTP stream, so the provider stops generating (and billing) tokens. Make sure your client implementation propagates cancellation.

Compose UI

ReplyCard.ktKotlin
@Composable
fun ReplyCard(state: ReplyState, onStop: () -> Unit, onRetry: () -> Unit) {
    val text = when (state) {
        is ReplyState.Streaming -> state.text
        is ReplyState.Done -> state.text
        is ReplyState.Stopped -> state.text
        is ReplyState.Failed -> state.partial
        else -> ""
    }
    Column(Modifier.fillMaxWidth().padding(16.dp)) {
        when (state) {
            ReplyState.Waiting -> LinearProgressIndicator(Modifier.fillMaxWidth())
            else -> Text(
                text = text,
                modifier = Modifier.semantics {
                    // Announce once when complete rather than on every chunk
                    if (state is ReplyState.Done) liveRegion = LiveRegionMode.Polite
                },
            )
        }
        Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            if (state is ReplyState.Waiting || state is ReplyState.Streaming)
                OutlinedButton(onClick = onStop) { Text("Stop") }
            if (state is ReplyState.Failed || state is ReplyState.Stopped)
                TextButton(onClick = onRetry) { Text("Retry") }
        }
        if (state is ReplyState.Failed) Text(state.message, color = MaterialTheme.colorScheme.error)
    }
}

Auto-scroll politely

In a chat list, follow the stream only if the user is already at the bottom. Don't yank them back while they're reading earlier messages:

Follow the streamKotlin
val listState = rememberLazyListState()
val atBottom by remember {
    derivedStateOf {
        val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()
        last == null || last.index >= listState.layoutInfo.totalItemsCount - 1
    }
}
LaunchedEffect(lastMessageText.length) {
    if (atBottom && messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex, Int.MAX_VALUE / 2)
}

Performance tips

  • Chunks can arrive many times per second. If rendering markdown is expensive, throttle updates (for example with sample(50)) or render plain text while streaming and format when done.
  • Use a StringBuilder in the ViewModel, not repeated string concatenation in composables.
  • Give list items stable keys.

Markdown and partial content

Models often emit markdown. Partial markdown (an unclosed code block) can render oddly mid-stream. Options: render plain text while streaming and markdown on Done, or use a renderer tolerant of incomplete input. Never render model output as HTML in a WebView without sanitisation.

Testing

StreamingViewModelTest.ktKotlin
@Test
fun stop_keepsPartialText() = runTest {
    val chunks = Channel<String>()
    val vm = StreamingViewModel(TextStreamer { chunks.consumeAsFlow() })
    vm.send("hi")
    chunks.send("Hel"); chunks.send("lo")
    runCurrent()
    vm.stop()
    runCurrent()
    assertEquals(ReplyState.Stopped("Hello"), vm.state.value)
}

Set the Main dispatcher to a test dispatcher in tests (for example with a MainDispatcherRule). See Testing ViewModels.

Frequently asked questions

Should I add a fake typing animation?

Real streaming already looks like typing. Artificial delays slow users down. Show text as it arrives.

What survives rotation?

The ViewModel keeps collecting across configuration changes, so the stream continues. Process death loses it; persist completed messages.

Conclusion

Stream AI responses by accumulating chunks in a ViewModel, modelling waiting, streaming, done, stopped and failed states, supporting Stop through cancellation, auto-scrolling only when users are at the bottom, and keeping rendering efficient.

Next step: build a complete project with an AI Document Summarizer.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Every AI feature still runs inside an Android app that has to be fast, reliable and well structured. Strengthen the Kotlin, Jetpack Compose and architecture skills that AI integrations depend on, then apply these AI concepts with confidence.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp