Streaming @Generable Snapshots into SwiftUI Without Flicker
- Author
- Ehsan Azish · 3NSOFTS
- Updated
- June 2026
- Read time
- 13 min read
- Level
- Intermediate
- Platform
- iOS 26+, Foundation Models, SwiftUI, async/await
Implementation Notes
- ~/ What broke: Streaming structured output flickers and jumps in SwiftUI.
- ~/ What to do: Render partial snapshots with stable identity and a final-state handoff.
Foundation Models can stream partially-generated structured output: as the model fills in a @Generable type, you receive a sequence of progressively-complete snapshots. Done well, this gives a UI that fills in live, field by field. Done naively, it flickers, jumps layout on every snapshot, and feels broken. This guide covers the clean implementation.
How streaming structured output works
Instead of awaiting one final result, you consume an async stream of snapshots. Each snapshot is the partially-populated @Generable type — fields the model hasn't produced yet are absent/nil; fields it has are filled. The last snapshot is the complete value.
@Generable
struct ArticleSummary {
@Guide(description: "A one-line headline")
var headline: String
@Guide(description: "Three key takeaways")
var takeaways: [String]
@Guide(description: "A short closing note")
var closing: String
}
// Consume the stream of partial snapshots.
let session = LanguageModelSession()
let stream = session.streamResponse(
to: "Summarize this article: \(text)",
generating: ArticleSummary.self
)
for try await partial in stream {
// `partial` is an increasingly-complete ArticleSummary.
await MainActor.run { self.summary = partial }
}
Confirm the exact streaming API name and the partial/snapshot element type against your SDK — Apple adjusted these across iOS 26 point releases. The model is stable: you iterate snapshots, each more complete than the last.
Why naive streaming flickers
Three things make a streaming UI feel broken:
- Layout jumps — as
takeawaysgrows from 0 to 3 items, the view's height leaps on each snapshot, shoving everything below it. - Flicker on re-render — replacing the whole view's data every snapshot can re-create subviews, dropping and re-adding them visibly.
- Half-words appearing and rewriting — text fields update mid-token, so the user sees text rewrite itself.
The fixes are all about stable identity and smooth transitions.
Fix 1: animate snapshot updates
Wrap the state update in an animation so growth eases instead of snapping:
for try await partial in stream {
await MainActor.run {
withAnimation(.easeOut(duration: 0.2)) {
self.summary = partial
}
}
}
easeOut over a short duration smooths the height change as fields fill, so a growing list slides in rather than teleporting.
Fix 2: stable identity for list items
When streaming a growing array, give items stable identity so SwiftUI animates insertions instead of rebuilding the list. Use a stable key, not the array index:
ForEach(Array(summary.takeaways.enumerated()), id: \.offset) { _, item in
Text(item)
.transition(.opacity.combined(with: .move(edge: .top)))
}
For genuinely stable identity across snapshots, prefer a content-derived or model-provided id over offset when items can reorder; for append-only streaming, offset is acceptable since earlier items don't change.
Fix 3: reserve space to prevent jumps
If you know the rough final shape (e.g. "about three takeaways"), reserve layout space so the container doesn't resize dramatically as content arrives. A minHeight on the section, or placeholder rows that fade out as real content fills in, keeps surrounding UI still:
VStack(alignment: .leading) {
ForEach(...) { ... }
}
.frame(minHeight: 120, alignment: .top) // reserve space; avoids shove
The goal is that content fills into a stable frame rather than pushing the layout around.
Fix 4: handle the partial-to-final transition
The last snapshot is the complete value, but you should still handle stream completion explicitly — to stop a loading indicator, enable actions that only make sense on complete data, and catch errors that can arrive mid-stream:
do {
for try await partial in stream {
await MainActor.run { withAnimation { self.summary = partial } }
}
await MainActor.run { self.isComplete = true } // enable Save, stop spinner
} catch {
await MainActor.run { self.handleStreamError(error) }
}
Errors during streaming are the same GenerationError cases as non-streaming — including exceededContextWindowSize and guardrailViolation — so route them through your standard handler (see the GenerationError reference).
When to stream vs. await
Streaming is a UX choice, not a default:
- Stream when generation takes long enough that a blank wait feels broken, and progressive reveal is pleasant (summaries, longer structured content). It also makes the model feel faster.
- Await when the output is small/fast, or when partial states would be confusing or misleading (a single number, a yes/no, anything where a half-formed value reads as wrong). For these, one clean final update beats flickering partials.
Production checklist
- Animate snapshot updates with a short
easeOutto smooth field-fill. - Give list items stable identity; animate insertions, don't rebuild.
- Reserve layout space so streaming fills a stable frame instead of shoving UI.
- Handle stream completion explicitly — enable actions, stop indicators.
- Route mid-stream errors through your standard
GenerationErrorhandler. - Choose streaming deliberately — await small/fast or ambiguous-partial outputs.
- Confirm the streaming API name against your SDK; isolate it.
Why this matters for shipped apps
Progressive generation is one of the most compelling things about on-device AI — content materializing live feels magical — but the line between "magical" and "broken" is entirely layout stability and transition smoothness. The framework hands you a stream of partials; turning that into a calm, flicker-free fill is SwiftUI craft the tutorials don't cover, and it's what makes the feature feel finished.
Polishing a streaming AI UI to production quality is the kind of detail we sweat in our on-device AI integration work at 3NSOFTS.