SwiftUI Streaming Text Component
Renders a model response at a steady reading cadence no matter how erratically the tokens arrive. It keeps a reveal cursor and drains toward the received length with a bounded catch-up rate, so a burst of forty tokens does not snap onto the screen and a stall does not freeze it. The caret is solid while glyphs land and blinks only while waiting.
Assistant
Install the free SwiftUI streaming text
- Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
- Copy SKStreamingText.swift below into your project. It needs nothing else.
Usage
Usage.swift
SKStreamingText(reply, isStreaming: isGenerating)
SKStreamingText(reply, isStreaming: true, charactersPerSecond: 90)Full source · 219 lines · MIT
SKStreamingText.swift
//
// SKStreamingText.swift
// Swift Kit UI
//
// Requires: SwiftKitUI.swift
//
// Text that arrives the way a model actually sends it, without looking like it.
//
// This is the component people get wrong, and the reason is not obvious until
// you have watched a real stream: tokens do not arrive evenly. You get one
// token, then nothing for 300ms, then forty at once. Bind that straight to a
// `Text` and the reply lurches — a burst, a stall, a burst — which reads as a
// slow app even when the response is fast.
//
// So this does not render `text` directly. It keeps a reveal cursor and drains
// toward the received length at a smoothed rate, speeding up when it has
// fallen behind and easing off as it catches up. The output is a steady,
// readable cadence regardless of how erratic the input was, and because the
// drain rate is bounded by what is actually received it can never run ahead of
// the model.
//
// Pass the accumulated text on every update — not the delta. That makes the
// component idempotent: a dropped or duplicated chunk cannot desynchronise it.
//
// Usage:
// SKStreamingText(reply, isStreaming: isGenerating)
// SKStreamingText(reply, isStreaming: true, charactersPerSecond: 90)
//
import SwiftUI
public struct SKStreamingText: View {
private let text: String
private let isStreaming: Bool
private let charactersPerSecond: Double
private let font: Font
private let color: Color
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var revealed: Int = 0
/// Sub-character remainder. Without it, a rate slower than one character
/// per frame truncates to zero every frame and nothing ever appears.
@State private var carry: Double = 0
@State private var caretVisible = true
@State private var caretClock: Double = 0
/// A mirror of `text` that the drain loop can actually see.
///
/// `.task` captures the view value, so a `let` property is frozen at the
/// moment the task started — the loop would keep reading the empty string
/// it launched with and nothing would ever appear. `@State` reads go
/// through storage that survives view re-creation, so this one tracks.
@State private var source: String = ""
public init(
_ text: String,
isStreaming: Bool = true,
charactersPerSecond: Double = 55,
font: Font = SK.Type_.body,
color: Color = SK.Palette.label
) {
self.text = text
self.isStreaming = isStreaming
self.charactersPerSecond = charactersPerSecond
self.font = font
self.color = color
}
/// True once the cursor has consumed everything received so far.
private var caughtUp: Bool { revealed >= source.count }
private var visible: String {
guard revealed < text.count else { return text }
let end = text.index(text.startIndex, offsetBy: revealed, limitedBy: text.endIndex)
?? text.endIndex
return String(text[..<end])
}
/// Shown while more is expected. Once the stream closes it disappears, so a
/// finished reply is not left with a cursor blinking at the end of it.
private var showsCaret: Bool {
isStreaming && !reduceMotion
}
public var body: some View {
// Concatenated rather than placed in an HStack: the caret has to sit on
// the last line, after the final glyph, and only text composition can
// put it there. The glyph is always drawn and only its colour changes,
// so blinking never reflows the paragraph.
(
Text(visible)
.font(font)
.foregroundStyle(color)
+ Text(showsCaret ? "\u{258C}" : "")
.font(font)
.foregroundStyle(caretVisible ? SK.Palette.accent : Color.clear)
)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
.onChange(of: text, initial: true) { _, new in
// A replaced message (new turn, regenerate) restarts the reveal;
// an appended chunk continues it.
if !new.hasPrefix(source) { reset() }
source = new
if reduceMotion { revealed = new.count }
}
.task(id: reduceMotion) {
guard !reduceMotion else { return }
await drain()
}
.accessibilityLabel(text)
.accessibilityAddTraits(.updatesFrequently)
}
private func reset() {
revealed = 0
carry = 0
}
/// Advances the reveal cursor once per frame for as long as the view lives.
private func drain() async {
var last = ContinuousClock.now
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(16))
let now = ContinuousClock.now
let dt = seconds(from: last, to: now)
last = now
// A backgrounded app resumes with a huge dt; clamping stops the
// whole buffer flushing in a single frame on return.
step(dt: min(dt, 0.1))
}
}
private func step(dt: Double) {
advanceCaret(dt: dt)
let pending = source.count - revealed
guard pending > 0 else { return }
// Catch-up: the further behind the cursor is, the faster it drains.
// Capped at 5x so a large paste still reads as typing rather than
// snapping in, and floored at 1x so it never crawls.
let catchUp = 1 + min(Double(pending) / 40, 4)
carry += charactersPerSecond * catchUp * dt
let whole = Int(carry)
guard whole > 0 else { return }
carry -= Double(whole)
revealed = min(source.count, revealed + whole)
}
private func advanceCaret(dt: Double) {
guard showsCaret else {
caretVisible = true
return
}
// Solid while glyphs are landing, blinking only once the cursor has
// drained and is waiting on the model. The difference is what makes a
// stall read as "thinking" instead of "stuck".
guard caughtUp else {
caretClock = 0
caretVisible = true
return
}
caretClock += dt
if caretClock >= 0.53 {
caretClock = 0
caretVisible.toggle()
}
}
private func seconds(from start: ContinuousClock.Instant, to end: ContinuousClock.Instant) -> Double {
let d = end - start
return Double(d.components.seconds) + Double(d.components.attoseconds) * 1e-18
}
}
#Preview("SKStreamingText") {
@Previewable @State var received = ""
@Previewable @State var streaming = true
let full = """
Revenue grew 12% this quarter and churn fell to 1.8%. The gain is \
concentrated in annual plans, which now account for 61% of new subscriptions \
— up from 44% in Q2. Monthly conversion was flat, so the shift looks like \
pricing rather than demand.
"""
VStack(alignment: .leading, spacing: SK.Space.lg) {
SKStreamingText(received, isStreaming: streaming)
.frame(maxWidth: .infinity, alignment: .leading)
Button("Replay") {
received = ""
streaming = true
}
.font(SK.Type_.callout)
.foregroundStyle(SK.Palette.accent)
.buttonStyle(.skPress)
}
.padding(SK.Space.xl)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(SK.Palette.surface)
// Deliberately uneven chunks — this is what a real stream looks like, and
// the point of the component is that the output does not show it.
.task(id: received.isEmpty) {
guard received.isEmpty else { return }
var index = full.startIndex
while index < full.endIndex {
let burst = [1, 1, 2, 14, 1, 3, 28, 1, 1, 9].randomElement() ?? 3
let next = full.index(index, offsetBy: burst, limitedBy: full.endIndex) ?? full.endIndex
received += String(full[index..<next])
index = next
try? await Task.sleep(for: .milliseconds([40, 60, 90, 260].randomElement() ?? 60))
}
streaming = false
}
}
Questions
- Is this SwiftUI streaming text free for commercial apps?
- Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
- What does it depend on?
- SKStreamingText.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
- Which iOS versions does it support?
- iOS 17 and later, plus macOS 14.
Related components
Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.