From db5ff5f5ed711a01bbb51652140f385e648adeb1 Mon Sep 17 00:00:00 2001
From: Phineas Guo <31276761+guoPhineas@users.noreply.github.com>
Date: Wed, 15 Jul 2026 22:33:16 +0800
Subject: [PATCH 1/5] Add support for GitHub-style quote alert blockquote
---
.../Examples/Support/ExampleMarkdown.swift | 25 +++++++
.../MarkdownView/MarkdownViewRenderer.swift | 74 ++++++++++++++++++-
.../Block Quotes/MarkdownQuoteAlert.swift | 60 +++++++++++++++
.../MarkdownQuoteAlertStyle.swift | 62 ++++++++++++++++
4 files changed, 220 insertions(+), 1 deletion(-)
create mode 100644 Sources/MarkdownView/Rendering/Shared/Block Quotes/MarkdownQuoteAlert.swift
create mode 100644 Sources/MarkdownView/Styles/Block Quotes/MarkdownQuoteAlertStyle.swift
diff --git a/Examples/Examples/Support/ExampleMarkdown.swift b/Examples/Examples/Support/ExampleMarkdown.swift
index d7b613f5..79585d2a 100644
--- a/Examples/Examples/Support/ExampleMarkdown.swift
+++ b/Examples/Examples/Support/ExampleMarkdown.swift
@@ -138,6 +138,31 @@ enum ExampleMarkdown {
Inline HTML also works in prose, such as highlighted text.
+ ---
+ ## GitHub Flavored Markdown Alerts
+
+ > [!Warning]
+ >
+ > This is a warning.
+ >
+ > Maybe you should solve it.
+
+ > [!NOTE]
+ >
+ > Note
+
+ > [!TIP]
+ >
+ > Tip
+
+ > [!IMPORTANT]
+ >
+ > Important
+
+ > [!CAUTION]
+ >
+ > Caution
+
---
## Custom Renderers
diff --git a/Sources/MarkdownView/Rendering/MarkdownView/MarkdownViewRenderer.swift b/Sources/MarkdownView/Rendering/MarkdownView/MarkdownViewRenderer.swift
index c9d60f98..8c0eb796 100644
--- a/Sources/MarkdownView/Rendering/MarkdownView/MarkdownViewRenderer.swift
+++ b/Sources/MarkdownView/Rendering/MarkdownView/MarkdownViewRenderer.swift
@@ -93,6 +93,19 @@ struct MarkdownViewRenderer: @preconcurrency MarkupVisitor {
}
func visitBlockQuote(_ blockQuote: BlockQuote) -> MarkdownNodeView {
+ let children = Array(blockQuote.children)
+ if let firstParagraph = children.first as? Paragraph,
+ let alert = MarkdownQuoteAlertType.detect(
+ from: Self.plainText(of: firstParagraph)
+ ) {
+ return visitQuoteAlertBlockQuote(
+ blockQuote,
+ children: children,
+ alertType: alert.type,
+ title: alert.title
+ )
+ }
+
let content = MarkdownBlockQuoteStyleConfiguration.Content {
VStack(alignment: .leading, spacing: configuration.componentSpacing) {
ForEach(Array(blockQuote.children.enumerated()), id: \.offset) { _, child in
@@ -110,6 +123,34 @@ struct MarkdownViewRenderer: @preconcurrency MarkupVisitor {
.tint(configuration.tintColors[.blockQuote, default: .accentColor])
}
}
+
+ func visitQuoteAlertBlockQuote(
+ _ blockQuote: BlockQuote,
+ children: [any Markup],
+ alertType: MarkdownQuoteAlertType,
+ title: String
+ ) -> MarkdownNodeView {
+ let bodyChildren = Array(children.dropFirst())
+ let content = MarkdownBlockQuoteStyleConfiguration.Content {
+ VStack(alignment: .leading, spacing: configuration.componentSpacing) {
+ ForEach(Array(bodyChildren.enumerated()), id: \.offset) { _, child in
+ MarkdownViewRenderer(
+ configuration: configuration,
+ mathContext: mathContext,
+ elementRenderers: elementRenderers
+ )
+ .makeBody(for: child)
+ }
+ }
+ }
+ return MarkdownNodeView {
+ MarkdownQuoteAlert(
+ alertType: alertType,
+ title: title,
+ content: AnyView(content)
+ )
+ }
+ }
func visitSoftBreak(_ softBreak: SoftBreak) -> MarkdownNodeView {
MarkdownNodeView(" ")
@@ -353,7 +394,7 @@ fileprivate extension MarkdownViewRenderer {
elementRenderers: elementRenderers
)
.makeBody(for: cell)
-
+
return MarkdownTableStyleConfiguration.Table.Cell(
horizontalAlignment: cell.horizontalAlignment,
textAlignment: cell.textAlignment,
@@ -362,3 +403,34 @@ fileprivate extension MarkdownViewRenderer {
)
}
}
+
+// MARK: - Utilities
+
+extension MarkdownViewRenderer {
+ /// Collects the plain text from a markup node and its inline children,
+ /// without any formatting prefixes from ancestral elements.
+ static func plainText(of markup: any Markup) -> String {
+ if let text = markup as? Markdown.Text {
+ return text.string
+ }
+ if let inlineCode = markup as? InlineCode {
+ return inlineCode.code
+ }
+ if let softBreak = markup as? SoftBreak {
+ return softBreak.plainText
+ }
+ if let lineBreak = markup as? LineBreak {
+ return lineBreak.plainText
+ }
+ if let inlineHTML = markup as? InlineHTML {
+ return inlineHTML.plainText
+ }
+ if let symbolLink = markup as? SymbolLink {
+ return symbolLink.plainText
+ }
+ if let customInline = markup as? CustomInline {
+ return customInline.plainText
+ }
+ return markup.children.reduce(into: "") { $0 += plainText(of: $1) }
+ }
+}
diff --git a/Sources/MarkdownView/Rendering/Shared/Block Quotes/MarkdownQuoteAlert.swift b/Sources/MarkdownView/Rendering/Shared/Block Quotes/MarkdownQuoteAlert.swift
new file mode 100644
index 00000000..284b272d
--- /dev/null
+++ b/Sources/MarkdownView/Rendering/Shared/Block Quotes/MarkdownQuoteAlert.swift
@@ -0,0 +1,60 @@
+//
+// MarkdownQuoteAlert.swift
+// MarkdownView
+//
+// Created by Phineas Guo on 2026/7/15.
+//
+
+import SwiftUI
+import Markdown
+
+/// The type of a GitHub-style quote alert blockquote.
+enum MarkdownQuoteAlertType: String, CaseIterable {
+ case note = "NOTE"
+ case tip = "TIP"
+ case important = "IMPORTANT"
+ case warning = "WARNING"
+ case caution = "CAUTION"
+
+ var systemImage: String {
+ switch self {
+ case .note: "info.circle"
+ case .tip: "lightbulb"
+ case .important: "exclamationmark.circle"
+ case .warning: "exclamationmark.triangle"
+ case .caution: "xmark.octagon"
+ }
+ }
+
+ var defaultTitle: String {
+ switch self {
+ case .note: "Note"
+ case .tip: "Tip"
+ case .important: "Important"
+ case .warning: "Warning"
+ case .caution: "Caution"
+ }
+ }
+
+ /// Detects a quote alert type from the text of a blockquote's first paragraph.
+ ///
+ /// Matches case-insensitively against `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`,
+ /// `[!WARNING]`, and `[!CAUTION]`. Text after the marker is used as a custom title.
+ static func detect(from text: String) -> (type: MarkdownQuoteAlertType, title: String)? {
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // `NOTE` or `Note` can be also matched
+ let uppercased = trimmed.uppercased()
+
+ for type in Self.allCases {
+ let prefix = "[!\(type.rawValue)]"
+ if uppercased.hasPrefix(prefix) {
+ let remaining = trimmed.dropFirst(prefix.count)
+ .trimmingCharacters(in: .whitespaces)
+ let title = remaining.isEmpty ? type.defaultTitle : String(remaining)
+ return (type, title)
+ }
+ }
+ return nil
+ }
+}
diff --git a/Sources/MarkdownView/Styles/Block Quotes/MarkdownQuoteAlertStyle.swift b/Sources/MarkdownView/Styles/Block Quotes/MarkdownQuoteAlertStyle.swift
new file mode 100644
index 00000000..caa52fd6
--- /dev/null
+++ b/Sources/MarkdownView/Styles/Block Quotes/MarkdownQuoteAlertStyle.swift
@@ -0,0 +1,62 @@
+//
+// MarkdownQuoteAlertStyle.swift
+// MarkdownView
+//
+// Created by Phineas Guo on 2026/7/15.
+//
+
+import SwiftUI
+
+extension MarkdownQuoteAlertType {
+ var color: Color {
+ switch self {
+ case .note: Color(red: 47 / 255, green: 129 / 255, blue: 247 / 255)
+ case .tip: Color(red: 87 / 255, green: 171 / 255, blue: 90 / 255)
+ case .important: Color(red: 163 / 255, green: 113 / 255, blue: 247 / 255)
+ case .warning: Color(red: 210 / 255, green: 153 / 255, blue: 34 / 255)
+ case .caution: Color(red: 248 / 255, green: 81 / 255, blue: 73 / 255)
+ }
+ }
+}
+
+struct MarkdownQuoteAlert: View {
+ var alertType: MarkdownQuoteAlertType
+ var title: String
+ var content: AnyView
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 6) {
+ Image(systemName: alertType.systemImage)
+ .imageScale(.small)
+ .foregroundStyle(alertType.color)
+ Text(title)
+ .font(.caption)
+ .fontWeight(.semibold)
+ .foregroundStyle(alertType.color)
+ }
+ content
+ .foregroundStyle(.primary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(12)
+ .background(
+ alertType.color.opacity(0.12),
+ in: RoundedRectangle(cornerRadius: 10)
+ )
+ .overlay(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(alertType.color)
+ .frame(width: 4)
+ }
+ .padding(.vertical, 4)
+ }
+}
+
+#Preview {
+ MarkdownQuoteAlert(
+ alertType: .warning,
+ title: "Warning",
+ content: AnyView(Text("Text"))
+ )
+}
From 927b1496ebf3b7978285a11bef900151b9d7d0aa Mon Sep 17 00:00:00 2001
From: Phineas Guo <31276761+guoPhineas@users.noreply.github.com>
Date: Wed, 15 Jul 2026 22:43:11 +0800
Subject: [PATCH 2/5] Add tests for MarkdownQuoteAlert
---
.../MarkdownQuoteAlertTests.swift | 197 ++++++++++++++++++
1 file changed, 197 insertions(+)
create mode 100644 Tests/MarkdownViewTests/MarkdownQuoteAlertTests.swift
diff --git a/Tests/MarkdownViewTests/MarkdownQuoteAlertTests.swift b/Tests/MarkdownViewTests/MarkdownQuoteAlertTests.swift
new file mode 100644
index 00000000..46fb2a88
--- /dev/null
+++ b/Tests/MarkdownViewTests/MarkdownQuoteAlertTests.swift
@@ -0,0 +1,197 @@
+//
+// MarkdownQuoteAlertTests.swift
+// MarkdownView
+//
+
+import Foundation
+import Markdown
+import SwiftUI
+import Testing
+
+@testable import MarkdownView
+
+@Suite("Markdown Quote Alert")
+struct MarkdownQuoteAlertTests {
+ @Test(
+ "Detects quote alert types from text",
+ arguments: QuoteAlertDetectionFixture.allCases
+ )
+ func detectsQuoteAlertTypesFromText(fixture: QuoteAlertDetectionFixture) {
+ let result = MarkdownQuoteAlertType.detect(from: fixture.input)
+ if let expectedType = fixture.expectedType {
+ #expect(result?.type == expectedType, "Expected \(expectedType) but got \(String(describing: result?.type))")
+ #expect(result?.title == fixture.expectedTitle, "Expected title '\(fixture.expectedTitle)' but got '\(String(describing: result?.title))'")
+ } else {
+ #expect(result == nil, "Expected nil but got \(String(describing: result))")
+ }
+ }
+
+ @Test(
+ "Detects quote alert types case-insensitively",
+ arguments: CaseInsensitiveFixture.allCases
+ )
+ func detectsQuoteAlertTypesCaseInsensitively(fixture: CaseInsensitiveFixture) {
+ let result = MarkdownQuoteAlertType.detect(from: fixture.input)
+ #expect(result?.type == fixture.expectedType, "Expected \(fixture.expectedType) but got \(String(describing: result?.type))")
+ }
+
+ @Test(
+ "Parses quote alert blockquote as blockquote",
+ arguments: QuoteAlertMarkdownFixture.allCases
+ )
+ func parsesQuoteAlertBlockquoteAsBlockquote(fixture: QuoteAlertMarkdownFixture) {
+ let description = MarkdownViewTestSupport.fullParseDocumentDescription(
+ markdown: fixture.markdown
+ )
+ #expect(description.contains("BlockQuote"), "Document should contain a BlockQuote")
+ }
+
+ @Test(
+ "Regular blockquote without alert marker is not detected as alert"
+ )
+ func regularBlockquoteIsNotDetectedAsAlert() {
+ let result = MarkdownQuoteAlertType.detect(from: "This is a regular quote")
+ #expect(result == nil)
+ }
+
+ @Test(
+ "Alert with custom title after marker is detected correctly"
+ )
+ func alertWithCustomTitleIsDetectedCorrectly() {
+ let result = MarkdownQuoteAlertType.detect(from: "[!NOTE] Custom Title Here")
+ #expect(result?.type == .note)
+ #expect(result?.title == "Custom Title Here")
+ }
+
+ @Test(
+ "Alert marker with extra whitespace is detected"
+ )
+ func alertMarkerWithExtraWhitespaceIsDetected() {
+ let result = MarkdownQuoteAlertType.detect(from: "[!WARNING] Extra spaces ")
+ #expect(result?.type == .warning)
+ #expect(result?.title == "Extra spaces")
+ }
+
+ // MARK: - Fixtures
+
+ enum QuoteAlertDetectionFixture: CaseIterable {
+ case note
+ case tip
+ case important
+ case warning
+ case caution
+ case unknownPrefix
+ case invalidFormat
+ case emptyString
+
+ var input: String {
+ switch self {
+ case .note: "[!NOTE]"
+ case .tip: "[!TIP]"
+ case .important: "[!IMPORTANT]"
+ case .warning: "[!WARNING]"
+ case .caution: "[!CAUTION]"
+ case .unknownPrefix: "[!UNKNOWN]"
+ case .invalidFormat: "Just some text"
+ case .emptyString: ""
+ }
+ }
+
+ var expectedType: MarkdownQuoteAlertType? {
+ switch self {
+ case .note: .note
+ case .tip: .tip
+ case .important: .important
+ case .warning: .warning
+ case .caution: .caution
+ case .unknownPrefix: nil
+ case .invalidFormat: nil
+ case .emptyString: nil
+ }
+ }
+
+ var expectedTitle: String {
+ switch self {
+ case .note: "Note"
+ case .tip: "Tip"
+ case .important: "Important"
+ case .warning: "Warning"
+ case .caution: "Caution"
+ case .unknownPrefix: ""
+ case .invalidFormat: ""
+ case .emptyString: ""
+ }
+ }
+ }
+
+ enum CaseInsensitiveFixture: CaseIterable {
+ case noteLower
+ case warningMixed
+ case cautionLower
+ case tipMixed
+ case importantLower
+
+ var input: String {
+ switch self {
+ case .noteLower: "[!note]"
+ case .warningMixed: "[!Warning]"
+ case .cautionLower: "[!caution]"
+ case .tipMixed: "[!Tip]"
+ case .importantLower: "[!important]"
+ }
+ }
+
+ var expectedType: MarkdownQuoteAlertType {
+ switch self {
+ case .noteLower: .note
+ case .warningMixed: .warning
+ case .cautionLower: .caution
+ case .tipMixed: .tip
+ case .importantLower: .important
+ }
+ }
+ }
+
+ enum QuoteAlertMarkdownFixture: CaseIterable {
+ case noteAllCaps
+ case tipAllCaps
+ case importantAllCaps
+ case warningMixed
+ case cautionMixed
+
+ var markdown: String {
+ switch self {
+ case .noteAllCaps:
+ """
+ > [!NOTE]
+ >
+ > This is a note.
+ """
+ case .tipAllCaps:
+ """
+ > [!TIP]
+ >
+ > This is a tip.
+ """
+ case .importantAllCaps:
+ """
+ > [!IMPORTANT]
+ >
+ > This is important.
+ """
+ case .warningMixed:
+ """
+ > [!Warning]
+ >
+ > Warning body content.
+ """
+ case .cautionMixed:
+ """
+ > [!Caution]
+ >
+ > Caution body content.
+ """
+ }
+ }
+ }
+}
From 20ac867179ac51801166c2086483ba15d4c24243 Mon Sep 17 00:00:00 2001
From: Phineas Guo <31276761+guoPhineas@users.noreply.github.com>
Date: Thu, 16 Jul 2026 12:25:14 +0800
Subject: [PATCH 3/5] fix: Silence the warning in Swift 6.4
---
.../MarkdownView/Styles/Code Blocks/DefaultCodeBlockStyle.swift | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Sources/MarkdownView/Styles/Code Blocks/DefaultCodeBlockStyle.swift b/Sources/MarkdownView/Styles/Code Blocks/DefaultCodeBlockStyle.swift
index 255194af..c1d03ba6 100644
--- a/Sources/MarkdownView/Styles/Code Blocks/DefaultCodeBlockStyle.swift
+++ b/Sources/MarkdownView/Styles/Code Blocks/DefaultCodeBlockStyle.swift
@@ -194,7 +194,7 @@ fileprivate struct DefaultMarkdownCodeBlock: View {
#elseif os(iOS) || os(visionOS)
UIPasteboard.general.string = codeBlockConfiguration.code
#endif
- Task {
+ _ = Task {
withAnimation {
codeCopied = true
}
From e6b22c85c01ad7de5f5f8652dd2778ba73015811 Mon Sep 17 00:00:00 2001
From: Phineas Guo <31276761+guoPhineas@users.noreply.github.com>
Date: Thu, 16 Jul 2026 12:38:01 +0800
Subject: [PATCH 4/5] Add support for streaming animation
---
Examples/Examples/ContentView.swift | 29 +++-
Examples/Examples/Views/MarkdownPreview.swift | 2 +
.../StreamingAnimationModifier.swift | 34 +++++
.../MarkdownNodes/_MarkdownText.swift | 17 ++-
.../Streaming/BlurStreamingAnimation.swift | 42 ++++++
.../GradientStreamingAnimation.swift | 48 ++++++
.../Streaming/OpacityStreamingAnimation.swift | 26 ++++
.../Streaming/StreamingAnimatedText.swift | 131 +++++++++++++++++
.../StreamingTextAnimation+Environment.swift | 12 ++
.../Streaming/StreamingTextAnimation.swift | 32 ++++
.../Streaming/StreamingTextRenderer.swift | 77 ++++++++++
.../StreamingTextAnimationTests.swift | 137 ++++++++++++++++++
12 files changed, 582 insertions(+), 5 deletions(-)
create mode 100644 Sources/MarkdownView/Modifiers/StreamingAnimationModifier.swift
create mode 100644 Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/BlurStreamingAnimation.swift
create mode 100644 Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/GradientStreamingAnimation.swift
create mode 100644 Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/OpacityStreamingAnimation.swift
create mode 100644 Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingAnimatedText.swift
create mode 100644 Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingTextAnimation+Environment.swift
create mode 100644 Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingTextAnimation.swift
create mode 100644 Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingTextRenderer.swift
create mode 100644 Tests/MarkdownViewTests/StreamingTextAnimationTests.swift
diff --git a/Examples/Examples/ContentView.swift b/Examples/Examples/ContentView.swift
index dcc8c2f0..31fee002 100644
--- a/Examples/Examples/ContentView.swift
+++ b/Examples/Examples/ContentView.swift
@@ -17,12 +17,13 @@ struct ContentView: View {
@State private var streamingTask: Task?
@State private var streamingIntervalMilliseconds = 1.0
@State private var charactersPerChunk = 1.0
+ @State private var animation: AnimationType = .blur
var body: some View {
NavigationStack {
MarkdownPreview(
source: source,
- rendererKind: rendererKind
+ rendererKind: rendererKind, animation: animation.animationType
)
.toolbar(content: toolbarContent)
}
@@ -102,6 +103,14 @@ struct ContentView: View {
in: 1 ... 32
)
}
+
+ VStack(alignment: .leading, spacing: 8) {
+ Picker(selection: $animation, label: Text("Streaming Animation")) {
+ Text("Blur").tag(AnimationType.blur)
+ Text("Gradient").tag(AnimationType.gradient)
+ Text("Opacity").tag(AnimationType.opacity)
+ }
+ }
}
.formStyle(.grouped)
.frame(idealWidth: 280)
@@ -138,6 +147,24 @@ struct ContentView: View {
}
}
+enum AnimationType: Hashable{
+ case blur
+ case gradient
+ case opacity
+
+ var animationType: any StreamingTextAnimation {
+ switch self {
+ case .blur:
+ .blur
+ case .gradient:
+ .gradient
+ case .opacity:
+ .opacity
+ }
+ }
+
+}
+
#Preview {
ContentView()
}
diff --git a/Examples/Examples/Views/MarkdownPreview.swift b/Examples/Examples/Views/MarkdownPreview.swift
index 31e0fc39..404e7cdd 100644
--- a/Examples/Examples/Views/MarkdownPreview.swift
+++ b/Examples/Examples/Views/MarkdownPreview.swift
@@ -9,6 +9,7 @@ import SwiftUI
struct MarkdownPreview: View {
var source: StreamingMarkdownSource
var rendererKind: MarkdownRendererKind
+ var animation: any StreamingTextAnimation
var body: some View {
ScrollView {
@@ -40,6 +41,7 @@ struct MarkdownPreview: View {
MarkdownView(parseResult)
}
}
+ .markdownStreamingAnimation(animation)
}
private static let showcaseBaseURL = URL(string: "https://developer.apple.com")!
diff --git a/Sources/MarkdownView/Modifiers/StreamingAnimationModifier.swift b/Sources/MarkdownView/Modifiers/StreamingAnimationModifier.swift
new file mode 100644
index 00000000..4d0fba50
--- /dev/null
+++ b/Sources/MarkdownView/Modifiers/StreamingAnimationModifier.swift
@@ -0,0 +1,34 @@
+//
+// StreamingAnimationModifier.swift
+// MarkdownView
+//
+// Created by Phineas Guo on 2026/7/15.
+//
+
+import SwiftUI
+
+public extension View {
+ /// Applies a streaming text animation to text content rendered within this view hierarchy.
+ ///
+ /// When a ``StreamingTextAnimation`` is set, text views inside `MarkdownView` will
+ /// progressively reveal new characters as they stream in, using the given animation.
+ ///
+ /// ```swift
+ /// MarkdownView(streamingContent)
+ /// .markdownStreamingAnimation(.blur)
+ /// ```
+ ///
+ /// Built-in animations include:
+ /// - ``OpacityStreamingAnimation/opacity`` — fade-in reveal
+ /// - ``BlurStreamingAnimation/blur`` — unblur reveal
+ /// - ``GradientStreamingAnimation/gradient`` — color-shift reveal
+ ///
+ /// - Parameter animation: The streaming text animation to apply, or `nil` to disable.
+ /// - Returns: A view with the streaming animation environment set.
+ @available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
+ nonisolated func markdownStreamingAnimation(
+ _ animation: (any StreamingTextAnimation)?
+ ) -> some View {
+ environment(\.streamingTextAnimation, animation)
+ }
+}
diff --git a/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/_MarkdownText.swift b/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/_MarkdownText.swift
index 6327b871..d3cab958 100644
--- a/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/_MarkdownText.swift
+++ b/Sources/MarkdownView/Rendering/MarkdownView/MarkdownNodes/_MarkdownText.swift
@@ -13,17 +13,26 @@ import SwiftUI
struct _MarkdownText: View {
var text: AttributedString
@State private var attributedString: RenderedState?
-
+ @Environment(\.streamingTextAnimation) private var streamingAnimation
+
init(_ text: AttributedString) {
self.text = text
}
var body: some View {
- Group {
+ let displayText: AttributedString = {
if let attributedString {
- Text(Self.visibleText(input: text, rendered: attributedString))
+ return Self.visibleText(input: text, rendered: attributedString)
+ }
+ return text
+ }()
+
+ Group {
+ if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *),
+ let streamingAnimation {
+ StreamingAnimatedText(displayText, animation: streamingAnimation)
} else {
- Text(text)
+ Text(displayText)
}
}
.task(id: text) {
diff --git a/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/BlurStreamingAnimation.swift b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/BlurStreamingAnimation.swift
new file mode 100644
index 00000000..899a9a86
--- /dev/null
+++ b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/BlurStreamingAnimation.swift
@@ -0,0 +1,42 @@
+//
+// BlurStreamingAnimation.swift
+// MarkdownView
+//
+// Created by Phineas Guo on 2026/7/15.
+//
+
+import SwiftUI
+
+/// A streaming text animation that unblurs characters as they are revealed.
+///
+/// Characters start at a configurable maximum blur radius and progressively sharpen
+/// to full clarity while fading in.
+///
+/// Use `.markdownStreamingAnimation(.blur)` for a soft reveal effect.
+@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
+public struct BlurStreamingAnimation: StreamingTextAnimation {
+ /// The maximum blur radius applied to characters before they are revealed.
+ public let radius: CGFloat
+
+ /// Creates a blur-based streaming text animation.
+ ///
+ /// - Parameter radius: The maximum blur radius for unrevealed characters. Default is `12`.
+ public init(radius: CGFloat = 12) {
+ self.radius = radius
+ }
+
+ public func apply(to context: inout GraphicsContext, characterProgress: Double) {
+ let progress = min(max(characterProgress, 0), 1)
+ context.opacity = progress
+ context.addFilter(.blur(radius: radius * (1 - progress)))
+ }
+}
+
+@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
+public extension StreamingTextAnimation where Self == BlurStreamingAnimation {
+ /// A streaming animation that unblurs characters with a default radius of `12`.
+ static var blur: Self { .init() }
+
+ /// A streaming animation that unblurs characters with a custom radius.
+ static func blur(radius: CGFloat) -> Self { .init(radius: radius) }
+}
diff --git a/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/GradientStreamingAnimation.swift b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/GradientStreamingAnimation.swift
new file mode 100644
index 00000000..76f3069c
--- /dev/null
+++ b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/GradientStreamingAnimation.swift
@@ -0,0 +1,48 @@
+//
+// GradientStreamingAnimation.swift
+// MarkdownView
+//
+// Created by Phineas Guo on 2026/7/15.
+//
+
+import SwiftUI
+
+/// A streaming text animation that reveals characters with a colored glow that fades as they settle.
+///
+/// New characters appear with a configurable tint glow and progressively sharpen
+/// to their natural color.
+///
+/// Use `.markdownStreamingAnimation(.gradient(.blue))` for a colored reveal effect.
+@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
+public struct GradientStreamingAnimation: StreamingTextAnimation {
+ /// The glow color applied to characters that have not yet been revealed.
+ public let tintColor: Color
+
+ /// The maximum glow radius for unrevealed characters.
+ public let radius: CGFloat
+
+ /// Creates a gradient-based streaming text animation with a colored glow.
+ ///
+ /// - Parameters:
+ /// - tintColor: The glow color applied to unrevealed characters. Default is `.accentColor`.
+ /// - radius: The maximum glow radius for unrevealed characters. Default is `6`.
+ public init(tintColor: Color = .accentColor, radius: CGFloat = 6) {
+ self.tintColor = tintColor
+ self.radius = radius
+ }
+
+ public func apply(to context: inout GraphicsContext, characterProgress: Double) {
+ let progress = min(max(characterProgress, 0), 1)
+ context.opacity = progress
+ context.addFilter(.shadow(color: tintColor, radius: radius * (1 - progress)))
+ }
+}
+
+@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
+public extension StreamingTextAnimation where Self == GradientStreamingAnimation {
+ /// A streaming animation with an accent-colored glow reveal.
+ static var gradient: Self { .init() }
+
+ /// A streaming animation with a custom tint color glow reveal.
+ static func gradient(_ tintColor: Color) -> Self { .init(tintColor: tintColor) }
+}
diff --git a/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/OpacityStreamingAnimation.swift b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/OpacityStreamingAnimation.swift
new file mode 100644
index 00000000..59c2532d
--- /dev/null
+++ b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/OpacityStreamingAnimation.swift
@@ -0,0 +1,26 @@
+//
+// OpacityStreamingAnimation.swift
+// MarkdownView
+//
+// Created by Phineas Guo on 2026/7/15.
+//
+
+import SwiftUI
+
+/// A streaming text animation that fades characters in from transparent to fully opaque.
+///
+/// Use `.markdownStreamingAnimation(.opacity)` to apply a simple fade-in effect
+/// as markdown content streams in.
+public struct OpacityStreamingAnimation: StreamingTextAnimation {
+ /// Creates an opacity-based streaming text animation.
+ public init() {}
+
+ public func apply(to context: inout GraphicsContext, characterProgress: Double) {
+ context.opacity = min(max(characterProgress, 0), 1)
+ }
+}
+
+public extension StreamingTextAnimation where Self == OpacityStreamingAnimation {
+ /// A streaming animation that fades characters from transparent to opaque.
+ static var opacity: Self { .init() }
+}
diff --git a/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingAnimatedText.swift b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingAnimatedText.swift
new file mode 100644
index 00000000..635690c4
--- /dev/null
+++ b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingAnimatedText.swift
@@ -0,0 +1,131 @@
+//
+// StreamingAnimatedText.swift
+// MarkdownView
+//
+// Created by Phineas Guo on 2026/7/15.
+//
+
+import SwiftUI
+
+/// A view that progressively reveals text characters using a streaming animation.
+///
+/// When the text content grows (as in streaming markdown), only the newly appended
+/// characters animate in. Previously revealed characters remain visible.
+@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
+struct StreamingAnimatedText: View {
+ private let plainText: String
+ private let textContent: TextContent
+ private let animation: any StreamingTextAnimation
+
+ @State private var revealedCount: Int = 0
+ @State private var animProgress: Double = 1
+ @State private var lastPlainText: String = ""
+ @State private var animationTarget: Int = 0
+
+ enum TextContent {
+ case plain(String)
+ case attributed(AttributedString)
+
+ var string: String {
+ switch self {
+ case .plain(let s): return s
+ case .attributed(let a): return String(a.characters)
+ }
+ }
+
+ func makeText() -> Text {
+ switch self {
+ case .plain(let s): return Text(s)
+ case .attributed(let a): return Text(a)
+ }
+ }
+ }
+
+ init(_ text: String, animation: any StreamingTextAnimation) {
+ self.plainText = text
+ self.textContent = .plain(text)
+ self.animation = animation
+ }
+
+ init(_ attributedString: AttributedString, animation: any StreamingTextAnimation) {
+ self.plainText = String(attributedString.characters)
+ self.textContent = .attributed(attributedString)
+ self.animation = animation
+ }
+
+ var body: some View {
+ AnimationLeaf(
+ progress: animProgress,
+ revealedCount: revealedCount,
+ plainText: plainText,
+ textContent: textContent,
+ animation: animation
+ )
+ .onAppear {
+ lastPlainText = plainText
+ if revealedCount == 0 && !plainText.isEmpty {
+ startAnimation()
+ }
+ }
+ .onChange(of: plainText) { newText in
+ handleTextChange(newText)
+ }
+ }
+
+ private func handleTextChange(_ newText: String) {
+ guard newText != lastPlainText else { return }
+
+ let isAppend = newText.hasPrefix(lastPlainText)
+ lastPlainText = newText
+
+ if isAppend && animProgress < 1 {
+ animationTarget = newText.count
+ return
+ }
+
+ if !isAppend {
+ revealedCount = 0
+ }
+ startAnimation()
+ }
+
+ private func startAnimation() {
+ animationTarget = plainText.count
+ guard animationTarget > revealedCount else { return }
+
+ animProgress = 0
+ withAnimation(.easeOut(duration: 0.3)) {
+ animProgress = 1
+ } completion: {
+ revealedCount = animationTarget
+ }
+ }
+}
+
+// MARK: - Animatable Leaf
+
+/// The leaf view that carries the `Animatable` conformance so SwiftUI drives
+/// `animatableData` interpolation directly on each animation frame.
+@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *)
+private struct AnimationLeaf: View, @MainActor Animatable {
+ var progress: Double
+ let revealedCount: Int
+ let plainText: String
+ let textContent: StreamingAnimatedText.TextContent
+ let animation: any StreamingTextAnimation
+
+ var animatableData: Double {
+ get { progress }
+ set { progress = newValue }
+ }
+
+ var body: some View {
+ textContent.makeText()
+ .textRenderer(StreamingTextRenderer(
+ progress: progress,
+ revealedCount: revealedCount,
+ totalCharacterCount: plainText.count,
+ animation: animation
+ ))
+ }
+}
diff --git a/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingTextAnimation+Environment.swift b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingTextAnimation+Environment.swift
new file mode 100644
index 00000000..dec8ef1c
--- /dev/null
+++ b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingTextAnimation+Environment.swift
@@ -0,0 +1,12 @@
+//
+// StreamingTextAnimation+Environment.swift
+// MarkdownView
+//
+// Created by Phineas Guo on 2026/7/15.
+//
+
+import SwiftUI
+
+extension EnvironmentValues {
+ @Entry var streamingTextAnimation: (any StreamingTextAnimation)? = nil
+}
diff --git a/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingTextAnimation.swift b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingTextAnimation.swift
new file mode 100644
index 00000000..68224644
--- /dev/null
+++ b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingTextAnimation.swift
@@ -0,0 +1,32 @@
+//
+// StreamingTextAnimation.swift
+// MarkdownView
+//
+// Created by Phineas Guo on 2026/7/15.
+//
+
+import SwiftUI
+
+/// A type that defines how text characters are progressively revealed during streaming output.
+///
+/// Implement this protocol to create custom streaming text animations.
+/// Each character receives a progress value from `0` (not yet revealed) to `1` (fully settled),
+/// and the animation mutates the ``GraphicsContext`` to apply visual effects.
+///
+/// ```swift
+/// struct MyAnimation: StreamingTextAnimation {
+/// func apply(to context: inout GraphicsContext, characterProgress: Double) {
+/// context.opacity = characterProgress
+/// }
+/// }
+/// ```
+public protocol StreamingTextAnimation: Sendable {
+ /// Apply the animation effect for a character at the given reveal progress.
+ ///
+ /// - Parameters:
+ /// - context: The graphics context to mutate. The context already contains the character's
+ /// text slice, ready to be drawn after mutations are applied.
+ /// - characterProgress: The reveal progress of this character, ranging from `0` (not yet
+ /// revealed) to `1` (fully settled).
+ func apply(to context: inout GraphicsContext, characterProgress: Double)
+}
diff --git a/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingTextRenderer.swift b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingTextRenderer.swift
new file mode 100644
index 00000000..52d4682b
--- /dev/null
+++ b/Sources/MarkdownView/Rendering/Shared/Text Animation/Streaming/StreamingTextRenderer.swift
@@ -0,0 +1,77 @@
+//
+// StreamingTextRenderer.swift
+// MarkdownView
+//
+// Created by Phineas Guo on 2026/7/15.
+//
+
+import SwiftUI
+
+/// A text renderer that progressively reveals text characters using a ``StreamingTextAnimation``.
+///
+/// Characters already revealed are drawn normally. Characters being revealed pass through
+/// the animation, and characters not yet revealed are invisible.
+@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
+struct StreamingTextRenderer: TextRenderer {
+ var progress: Double
+ let revealedCount: Int
+ let totalCharacterCount: Int
+ let animation: any StreamingTextAnimation
+
+ func draw(layout: Text.Layout, in context: inout GraphicsContext) {
+ let slices = layout.runSlices
+ let totalCount = max(totalCharacterCount, 1)
+ let transitionLength = max(totalCount - revealedCount, 1)
+
+ // Wavefront position: how many characters into the transition zone
+ // have been revealed. When progress=1, revealLength = transitionLength,
+ // all characters are behind the wavefront.
+ let revealLength = Double(transitionLength) * progress
+
+ var currentCharacterOffset = 0
+
+ for slice in slices {
+ let sliceCount = slice.characterIndices.count
+ let sliceRange = currentCharacterOffset..<(currentCharacterOffset + sliceCount)
+ defer { currentCharacterOffset += sliceCount }
+
+ // Zone 1: characters before revealedCount — fully revealed, draw normally.
+ if sliceRange.upperBound <= revealedCount {
+ context.draw(slice, options: .disablesSubpixelQuantization)
+ continue
+ }
+
+ // Zone 3: characters entirely past the wavefront — invisible.
+ if Double(sliceRange.lowerBound - revealedCount) >= revealLength {
+ continue
+ }
+
+ // Zone 2: slice overlaps the wavefront — apply animation.
+ let firstInZone = max(sliceRange.lowerBound, revealedCount)
+ let positionInZone = Double(firstInZone - revealedCount)
+
+ // characterProgress = 1 when the wavefront has passed this slice,
+ // fractional when the wavefront is passing through it.
+ let characterProgress = min(max(revealLength - positionInZone, 0), 1)
+
+ var sliceContext = context
+ animation.apply(to: &sliceContext, characterProgress: characterProgress)
+ sliceContext.draw(slice, options: .disablesSubpixelQuantization)
+ }
+ }
+}
+
+@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
+private extension Text.Layout {
+ var runSlices: [Text.Layout.RunSlice] {
+ var slices: [Text.Layout.RunSlice] = []
+ for line in self {
+ for run in line {
+ for slice in run {
+ slices.append(slice)
+ }
+ }
+ }
+ return slices
+ }
+}
diff --git a/Tests/MarkdownViewTests/StreamingTextAnimationTests.swift b/Tests/MarkdownViewTests/StreamingTextAnimationTests.swift
new file mode 100644
index 00000000..acd20707
--- /dev/null
+++ b/Tests/MarkdownViewTests/StreamingTextAnimationTests.swift
@@ -0,0 +1,137 @@
+//
+// StreamingTextAnimationTests.swift
+// MarkdownView
+//
+// Created by Phineas Guo on 2026/7/15.
+//
+
+import Testing
+import SwiftUI
+
+@testable import MarkdownView
+
+@Suite("Streaming Text Animation")
+struct StreamingTextAnimationTests {
+
+ // MARK: - Protocol Conformance
+
+ @Test("Opacity animation is Sendable")
+ func opacityAnimationIsSendable() {
+ let animation: any StreamingTextAnimation = OpacityStreamingAnimation()
+ #expect(animation is OpacityStreamingAnimation)
+ }
+
+ @Test("Blur animation is Sendable")
+ func blurAnimationIsSendable() {
+ let animation: any StreamingTextAnimation = BlurStreamingAnimation()
+ #expect(animation is BlurStreamingAnimation)
+ }
+
+ @Test("Gradient animation is Sendable")
+ func gradientAnimationIsSendable() {
+ let animation: any StreamingTextAnimation = GradientStreamingAnimation()
+ #expect(animation is GradientStreamingAnimation)
+ }
+
+ @Test("Static opacity shorthand returns correct type")
+ func staticOpacityShorthand() {
+ let animation: any StreamingTextAnimation = .opacity
+ #expect(animation is OpacityStreamingAnimation)
+ }
+
+ @Test("Static blur shorthand returns correct type")
+ func staticBlurShorthand() {
+ let animation: any StreamingTextAnimation = .blur
+ #expect(animation is BlurStreamingAnimation)
+ }
+
+ @Test("Static gradient shorthand returns correct type")
+ func staticGradientShorthand() {
+ let animation: any StreamingTextAnimation = .gradient
+ #expect(animation is GradientStreamingAnimation)
+ }
+
+ @Test("Custom blur radius is preserved")
+ func customBlurRadius() {
+ let animation = BlurStreamingAnimation(radius: 20)
+ #expect(animation.radius == 20)
+ }
+
+ @Test("Custom gradient tint color is preserved")
+ func customGradientTintColor() {
+ let animation = GradientStreamingAnimation(tintColor: .red, radius: 8)
+ #expect(animation.radius == 8)
+ }
+
+ // MARK: - StreamingTextRenderer Character Progress
+
+ @Test("Renderer computes zero progress for all unrevealed characters")
+ func rendererZeroProgress() {
+ let renderer = StreamingTextRenderer(
+ progress: 0,
+ revealedCount: 0,
+ totalCharacterCount: 10,
+ animation: OpacityStreamingAnimation()
+ )
+ #expect(renderer.revealedCount == 0)
+ #expect(renderer.totalCharacterCount == 10)
+ #expect(renderer.progress == 0)
+ }
+
+ @Test("Renderer with progress 1 has correct state")
+ func rendererFullProgress() {
+ let renderer = StreamingTextRenderer(
+ progress: 1,
+ revealedCount: 0,
+ totalCharacterCount: 10,
+ animation: OpacityStreamingAnimation()
+ )
+ #expect(renderer.progress == 1)
+ #expect(renderer.revealedCount == 0)
+ #expect(renderer.totalCharacterCount == 10)
+ }
+
+ @Test("Renderer preserves previously revealed count")
+ func rendererPreservesRevealedCount() {
+ let renderer = StreamingTextRenderer(
+ progress: 0.5,
+ revealedCount: 5,
+ totalCharacterCount: 10,
+ animation: BlurStreamingAnimation()
+ )
+ #expect(renderer.revealedCount == 5)
+ }
+
+ // MARK: - StreamingAnimatedText Text Change Detection
+
+ @Test("Prefix detection for streaming append")
+ func prefixDetection() {
+ // StreamingAnimatedText uses hasPrefix to detect streaming appends.
+ // "Hello" → "Hello World" should be detected as append.
+ #expect("Hello World".hasPrefix("Hello"))
+ // "Hello" → "Hello" should not trigger animation.
+ #expect("Hello".hasPrefix("Hello"))
+ }
+
+ @Test("Full replacement when text changes completely")
+ func fullReplacementDetection() {
+ // "Hello" → "World" should NOT be detected as append.
+ #expect(!"World".hasPrefix("Hello"))
+ }
+
+ // MARK: - Environment Key
+
+ @Test("Environment default value is nil")
+ func environmentDefaultIsNil() {
+ var env = EnvironmentValues()
+ #expect(env.streamingTextAnimation == nil)
+ }
+
+ @Test("Environment stores and retrieves animation")
+ func environmentStoresAnimation() {
+ var env = EnvironmentValues()
+ let animation: any StreamingTextAnimation = .blur
+ env.streamingTextAnimation = animation
+ #expect(env.streamingTextAnimation != nil)
+ }
+}
From b21982a4ef1c1c905d10c5e5512bcbe3c5a20d70 Mon Sep 17 00:00:00 2001
From: Phineas Guo <31276761+guoPhineas@users.noreply.github.com>
Date: Thu, 16 Jul 2026 13:29:20 +0800
Subject: [PATCH 5/5] Update README.md
---
README.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/README.md b/README.md
index e4cf80e9..3eeed963 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,17 @@ StreamingMarkdownReader(markdownSource) { parseResult in
Create one `StreamingMarkdownSource` for each response and keep it alive for the lifetime of that stream. Update `markdownSource.text` as new chunks arrive, then call `finishStreaming()` when the stream finishes.
+#### With animation
+
+```Swift
+StreamingMarkdownReader(markdownSource) { parseResult in
+ MarkdownView(parseResult)
+}
+.markdownStreamingAnimation(.blur)
+```
+
+`markdownStreamingAnimation` supports 3 defined animation: `.blur`, `.gradient` and `.opacity`. Also, you can define custom animations by `StreamingTextAnimation` protocol.
+
### Text selection
Use `MarkdownText` when text selection behavior is more important than full view-based layout.