MarkdownStreamer renders streaming LLM Markdown in SwiftUI with block-level incremental processing.
The current pipeline avoids doing Markdown parsing or Splash highlighting inside SwiftUI body. Tokens are processed by a background StreamProcessor actor, then MarkdownReader publishes updates to the UI block by block.
Keep one MarkdownReader for the message that is actively streaming:
import SwiftUI
import MarkdownStreamer
struct MessageView: View {
@StateObject private var reader = MarkdownReader()
@Environment(\.markdownTheme) private var theme
var body: some View {
ScrollView {
StreamingMarkdownView(blocks: reader.blocks)
.padding()
}
.task {
for await token in streamLLMResponse() {
await reader.append(token, theme: theme)
}
await reader.finish(theme: theme)
}
}
}MarkdownReader throttles @Published updates to at most 30 FPS by default:
let reader = MarkdownReader(maximumFramesPerSecond: 30)For completed chat messages, use the stateless string initializer:
StreamingMarkdownView(markdown: message.text)Completed strings are parsed synchronously in the view initializer through a shared cache and use deterministic block IDs. This avoids the empty-first-layout flash that can make parent scroll views jump.
You can also precompute blocks yourself:
let blocks = MarkdownReader.readSync(message.text, theme: theme)The async cached reader remains available for non-UI precomputation:
let blocks = await MarkdownReader.read(message.text, theme: theme)Full accumulated message updates are supported through the appendAccumulated(_:theme:) method:
await reader.appendAccumulated("# Gre", theme: theme)
await reader.appendAccumulated("# Greetings\nThe", theme: theme)
await reader.appendAccumulated("# Greetings\nThe assistant said", theme: theme)Only the new suffix is processed.
Custom code and image views are passed through the StreamingMarkdownView initializer:
StreamingMarkdownView(
blocks: reader.blocks,
codeView: { block in
MyCodeBlockView(
language: block.language,
code: block.code,
highlightedCode: block.highlightedCode
)
},
imageView: { image in
MyRemoteImageView(url: image.source, alt: image.alt)
}
)The default image provider supports caching and remote image loading. It reserves a stable 16:9 layout by default while loading, then uses the decoded image dimensions once cached or loaded data is available. Pass reservedAspectRatio: to DefaultMarkdownImageView when a different initial footprint is better for your UI.
CodeBlock.highlightedCode is precomputed by StreamProcessor; custom code views do not need to run Splash on the main thread.
Create a MarkdownTheme to control typography and colors for rendered Markdown:
let chatTheme = MarkdownTheme(
bodyFont: .body,
bodyColor: .primary,
headingFonts: [
1: .title.bold(),
2: .title2.bold(),
3: .headline
],
headingColor: .primary,
boldFont: .body.bold(),
inlineCodeFont: .system(.body, design: .monospaced),
inlineCodeForeground: .pink,
inlineCodeBackground: .secondary.opacity(0.14),
quoteHighlightFont: .body.italic(),
quoteHighlightForeground: .blue,
codeBlockBackground: .black.opacity(0.88),
regexHighlights: [
.standardQuotedSpeech
]
)quoteHighlightFont and quoteHighlightForeground control the style of quoted speech highlights. This is only applied if regexHighlights includes .standardQuotedSpeech.
.standardQuotedSpeech is a built-in regex highlight that highlights text wrapped in straight double quotes or smart curly double quotes. Commonly used in conversational AI.
Pass the same theme to the reader while streaming:
await reader.append(token, theme: chatTheme)
await reader.appendAccumulated(message.text, theme: chatTheme)to apply the theme after stremaing use the markdownTheme(_:) modifier:
StreamingMarkdownView(blocks: reader.blocks)
.markdownTheme(chatTheme)For synchronous completed-string rendering, pass the theme at init time:
StreamingMarkdownView(markdown: message.text, theme: chatTheme)The theme is used by StreamProcessor for headings, bold text, inline code, code block containers, and regex highlights.
Regex highlights let you style custom inline patterns during parsing. They are disabled by default; enable quoted speech by adding .standardQuotedSpeech to a custom theme. It highlights text wrapped in straight double quotes or smart curly double quotes:
var theme = MarkdownTheme.default
theme.regexHighlights = [
.standardQuotedSpeech
]You can add additional app-specific highlights:
let commandTheme = MarkdownTheme(
regexHighlights: [
RegexHighlight(
pattern: #"@[A-Za-z0-9_]+"#,
font: .body.bold(),
foreground: .purple
),
.standardQuotedSpeech
]
)Because completed markdown is parsed synchronously at init time, pass custom themes directly to StreamingMarkdownView(markdown:theme:). For streaming messages, pass the same theme into MarkdownReader.append(_:theme:) and finish(theme:).
Animations are opt-in:
StreamingMarkdownView(blocks: reader.blocks)
.markdownTokenAnimation(.fade)Custom policy:
StreamingMarkdownView(blocks: reader.blocks)
.markdownTokenAnimation(
MarkdownTokenAnimation(
animation: .spring(response: 0.24, dampingFraction: 0.9),
initialOpacity: 0.15,
minimumUpdateInterval: 0.24
)
)minimumUpdateInterval limits how often text updates are presented while token animations are enabled. Fast streams are buffered to the latest received text so the active animation can complete smoothly instead of being restarted for every incoming token.
- Paragraphs
- Headings using
#through###### - Fenced code blocks
- Images in the form
