Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion Examples/Examples/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ struct ContentView: View {
@State private var streamingTask: Task<Void, Error>?
@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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
25 changes: 25 additions & 0 deletions Examples/Examples/Support/ExampleMarkdown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,31 @@ enum ExampleMarkdown {

Inline HTML also works in prose, such as <mark>highlighted text</mark>.

---
## GitHub Flavored Markdown Alerts

> [!Warning]
>
> This is a warning.
>
> Maybe you should solve it.

> [!NOTE]
>
> Note

> [!TIP]
>
> Tip

> [!IMPORTANT]
>
> Important

> [!CAUTION]
>
> Caution

---

## Custom Renderers
Expand Down
2 changes: 2 additions & 0 deletions Examples/Examples/Views/MarkdownPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SwiftUI
struct MarkdownPreview: View {
var source: StreamingMarkdownSource
var rendererKind: MarkdownRendererKind
var animation: any StreamingTextAnimation

var body: some View {
ScrollView {
Expand Down Expand Up @@ -40,6 +41,7 @@ struct MarkdownPreview: View {
MarkdownView(parseResult)
}
}
.markdownStreamingAnimation(animation)
}

private static let showcaseBaseURL = URL(string: "https://developer.apple.com")!
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions Sources/MarkdownView/Modifiers/StreamingAnimationModifier.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(" ")
Expand Down Expand Up @@ -353,7 +394,7 @@ fileprivate extension MarkdownViewRenderer {
elementRenderers: elementRenderers
)
.makeBody(for: cell)

return MarkdownTableStyleConfiguration.Table.Cell(
horizontalAlignment: cell.horizontalAlignment,
textAlignment: cell.textAlignment,
Expand All @@ -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) }
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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) }
}
Loading