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.