Swift Markdown Kit · DOCS

Rendering

Rendering

For anything that is one document rather than a conversation: an article, a release note, a help page, a single model answer.

SwiftUI

import SwiftUI
import SwiftMarkdownKit

struct ArticleView: View {
    let article: String

    var body: some View {
        MarkdownRenderView(markdown: article)
    }
}

Binding a string that grows is enough to stream it. When the new value extends the old one, the view appends the delta instead of re-rendering; anything else is a full re-render.

To observe what the renderer is doing, pass a handler:

MarkdownRenderView(markdown: article) { event in
    switch event {
    case .ready:
        break
    case .rendered(let height), .heightChanged(let height):
        print("height", height)
    case .action(let action):
        print("action", action)
    case .error(let error):
        print(error.code, error.message)
    @unknown default:
        break
    }
}

UIKit

import UIKit
import SwiftMarkdownKit

final class ArticleViewController: UIViewController {
    private let renderer = MarkdownRenderViewController()

    override func viewDidLoad() {
        super.viewDidLoad()

        addChild(renderer)
        renderer.view.frame = view.bounds
        renderer.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(renderer.view)
        renderer.didMove(toParent: self)

        renderer.render("# Hello\n\nIt **works**.")
    }
}

render(_:) replaces the content. clear() empties it, and reload() rebuilds the renderer from scratch — you will not normally need the last one, because the SDK reloads and replays the content by itself if WebKit reclaims the content process under memory pressure.

Streaming one document

renderer.startStreaming()
renderer.appendChunk("Streaming **markdown")
renderer.appendChunk("** with math $x^2$", isFinal: true)

Or hand it the accumulated string each time and let it work out the delta:

renderer.renderStream(everythingSoFar)

Both take the incremental path: rendered HTML is cached up to the last completed block boundary and only the unstable tail is re-rendered, so the total cost over a whole stream grows with the length of the answer, not with its square.

Laying it out

A renderer scrolls itself by default. Inside your own ScrollView, turn that off and let it report its height instead:

struct EmbeddedArticle: View {
    let markdown: String
    @State private var height: CGFloat = 0

    var body: some View {
        ScrollView {
            VStack {
                Text("Anything above")

                MarkdownRenderView(
                    markdown: markdown,
                    options: MarkdownRenderOptions(isScrollEnabled: false)
                ) { event in
                    if case .rendered(let measured) = event { height = measured }
                    if case .heightChanged(let measured) = event { height = measured }
                }
                .frame(height: height)
            }
        }
    }
}

heightChanged is debounced while a message is streaming so it does not drive a layout pass per frame; the interval is heightChangeDebounceInterval.

What the parser accepts

Model output is not textbook markdown, and the parser is built for that. A $ next to a price, a bracketed citation, a formula whose closing brace has not streamed in yet: none of these turn red. Anything KaTeX cannot parse falls back to the characters the model actually wrote, in the surrounding text colour.

var options = MarkdownRenderOptions()
options.markdown.math = true          // KaTeX, inline and display
options.markdown.taskLists = true     // - [x] / - [ ]
options.markdown.emoji = true         // :smile:
options.markdown.linkify = true       // bare URLs become links
options.markdown.typographer = true   // smart quotes, dashes, ellipses
options.markdown.breaks = false       // single newline → <br>
options.markdown.allowRawHTML = false // keep off for model output

allowRawHTML is off by default and should stay off for anything a model wrote. Reasoning folds do not need it — <think> is recognised without opening raw HTML.

var options = MarkdownRenderOptions()
options.markdown.reasoning.completedLabel = "Thought process"

That configures the fold for document rendering. A conversation is configured through MarkdownChatRenderOptions.reasoning instead, which also drives the live fold while the model is still thinking.