On this page
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
ViewModel: accumulate and cancel
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)
}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
@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:
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
StringBuilderin 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
@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.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
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.