MarkdownView

A pure UIKit/AppKit framework for rendering Markdown with real-time parsing and rendering. Battle tested in FlowDown.

IMPORTANT

MarkdownView is not a full-spec CommonMark renderer. It deliberately trades spec completeness for the best possible typography and layout on phone-sized screens: complex elements (tables, code blocks) are lifted out of lists and rendered as first-class blocks, line spacing and fonts follow platform text styles, and rendering stays smooth while streaming tokens from an LLM. If you need byte-exact CommonMark output, use a spec-focused renderer instead.

Preview

Features

  • πŸš€ Real-time Rendering: designed for streaming β€” updates are throttled and views are reused, so calling it on every token is fine
  • πŸ“± Mobile-first Layout: complex elements are extracted from lists and laid out for readability on small screens
  • 🎨 Syntax Highlighting: code blocks coloured in Xcode's palette by a built-in, dependency-free lexer covering about 25 language families. It is approximate by design β€” keywords, strings, comments, numbers and types, not a parser β€” chosen to read well and stay fast (tens of microseconds for a typical block)
  • πŸ“Š Math Rendering: LaTeX formulas rendered with SwiftMath, with tap-to-preview
  • πŸ–₯️ Cross-Platform: native iOS, macOS, Mac Catalyst, visionOS, and watchOS (via WatchMarkdownView)

Supported Markdown

GitHub-flavored basics: headings, paragraphs, emphasis, lists (ordered/unordered/task), blockquotes, fenced code blocks, tables, links, images-as-links, inline and block math ($...$, $$...$$). HTML blocks and other long-tail CommonMark constructs are rendered as plain text or simplified β€” by design.

Installation

Add the following to your Package.swift file:

dependencies: [
    .package(url: "https://github.com/Lakr233/MarkdownView", from: "4.1.0"),
]

Platform compatibility:

  • iOS 16.0+
  • macOS 13.0+
  • Mac Catalyst 16.0+
  • visionOS 1.0+
  • watchOS 8.0+ (WatchMarkdownView product)

Usage

SwiftUI

import MarkdownView

struct ContentView: View {
    var body: some View {
        MarkdownView("# Hello World")
    }
}

With custom theme:

MarkdownView("# Hello World", theme: .default)

UIKit / AppKit

One-shot rendering:

import MarkdownView

let markdownTextView = MarkdownTextView()
markdownTextView.setMarkdown("# Hello World")

Streaming (parse off the main path you control, display throttled):

import MarkdownView
import MarkdownParser

let markdownTextView = MarkdownTextView()

// on every incoming chunk:
let result = MarkdownParser().parse(accumulatedText)
let content = MarkdownContent(parserResult: result, theme: .default)
markdownTextView.setContent(content) // coalesced by throttleInterval (default 20 fps)

Immediate, unthrottled replacement:

markdownTextView.setContentImmediately(content)

Streaming with animation

MarkdownStreamView is a MarkdownTextView for an answer that is still being written. While isStreaming is on, setContent(_:) rebuilds at a pace set by what a rebuild costs, the new text fades in, in the body, inside code blocks and in table cells, and new code blocks and tables fade in whole. Everything else appears at once, and with isStreaming off the view behaves exactly like MarkdownTextView.

let view = MarkdownStreamView()
view.streamIdentity = message.id      // a reused row never replays another item's text
view.isStreaming = message.isGenerating
view.setContentImmediately(content)   // the first fill appears at once
view.setContent(nextContent)          // more of the stream fades in

In a list, turn streaming on only for the row that is being written. A view that is not in a window, such as one measuring a row's height, never animates. In SwiftUI, use MarkdownView(text).streaming(isGenerating).

Set animator to change the effect. Code blocks and table cells fade in the way the body does, each with an animator of its own: an LTXFadeInAnimator configured like animator, or with the default configuration when animator is another effect. Only text appended to what a block showed fades in, so a block view the stream reuses for another block shows it at once, and turning isStreaming off lets the text in flight finish. To give blocks an effect of their own, subclass and override makeBlockAnimator():

final class AnswerView: MarkdownStreamView {
    override func makeBlockAnimator() -> (any LTXTextAnimator)? {
        LTXFadeUpAnimator()                // nil shows text inside blocks at once
    }
}

The unfinished end of a stream

Parsed as it arrives, a half-written answer flickers: **bo shows its asterisks until they close, a lone - flashes as an empty list item, and ```sw names a language from half its name. Parse with isStreaming and the end is closed the way it is heading instead, so **bo already shows as bold bo and grows into bold:

let result = MarkdownParser().parse(accumulatedText, isStreaming: true)
// or
let content = MarkdownContent(markdown: accumulatedText, isStreaming: true)

It holds back a last line of bare markers and a partial fence line, closes emphasis, strikethrough and code spans opened on the last line, closes an unfinished link destination, drops an unfinished image, and gives a table header its delimiter row early. It only cuts and appends at the very end, so every earlier block parses to the same bytes and is reused by the next rebuild. It leaves fenced code and unclosed $$ math alone. MarkdownView(text).streaming(true) does this for you, and parses the text again without it when the stream ends. Build the final update of a stream without it, so the document lands on exactly what the text says.

Subclassing

MarkdownTextView is open the way Litext's TextLabelView is: its handlers, theme, throttleInterval, the content setters and its TextLabelViewDelegate methods can be overridden (call super). To change how the body is drawn, pass your own label:

let view = MarkdownTextView(textLabelView: MyLabel()) // any TextLabelView subclass, LTXAnimatableLabel included

Inline code is drawn on its pill by an InlineCodeLineRenderer, a Litext line renderer. A label that arrives without a lineRenderer is given one, so the pill survives a label that returns a layout of its own; subclass InlineCodeLineRenderer to draw more behind the lines.

watchOS

import WatchMarkdownView

WatchMarkdownView(markdown: "# Hello World")

Migrating to 4.0

MarkdownView 4.0 adopts Litext 2.0 and renames the core API. Deprecated shims are provided for one release:

3.x4.0
MarkdownTextView.PreprocessedContentMarkdownContent
setMarkdown(_: PreprocessedContent)setContent(_:)
setMarkdownManually(_:)setContentImmediately(_:)
bindContentOffset(from:)trackedScrollView = ...
markdownTextView.textViewmarkdownTextView.textLabelView
ParseResult.render(theme:) (two overloads)renderedContent(theme:) / highlightMaps(theme:)

New: setMarkdown(_ text: String) parses and displays a markdown string in one call; MarkdownContent(markdown:theme:locale:) does the same for content objects.

Example

Check out the included example project to see MarkdownView in action:

open Example.xcworkspace

License

This project is licensed under the MIT License. See the LICENSE file for details.

Acknowledgments

This project includes code adapted from swift-markdown-ui by Guillermo Gonzalez, used under the MIT License.

Sponsor

LookInside helps you inspect a running iOS or macOS app UI from your Mac.


Copyright 2025 Β© Lakr Aream. All rights reserved.