Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ enum SampleMarkdownTheme: String, CaseIterable, Identifiable {
enabled: true,
allowedImageTypes: [.remote(allowedDomains: ["markdownguide.org"]), .assetCatalog, .bundledResource]
))
.withParagraphAnimationStyle(value: .fadeAndRise)
}

private func resolvedTheme(for demonstration: Demonstration) -> SampleMarkdownTheme {
Expand Down
23 changes: 23 additions & 0 deletions Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ extension MarkdownRenderConfig {
public func withImageConfig(_ value: ImageConfig) -> MarkdownRenderConfig {
MarkdownRenderConfig(
shouldAnimateText: shouldAnimateText,
paragraphAnimationStyle: paragraphAnimationStyle,
blockQuoteStyle: blockQuoteStyle,
headingStyle: headingStyle,
orderedListStyle: orderedListStyle,
Expand All @@ -256,4 +257,26 @@ extension MarkdownRenderConfig {
imageConfig: value
)
}

/// Returns a copy with `paragraphAnimationStyle` replaced. Only takes effect
/// when `shouldAnimateText` is `true`.
public func withParagraphAnimationStyle(value: ParagraphAnimationStyle) -> MarkdownRenderConfig {
MarkdownRenderConfig(
shouldAnimateText: shouldAnimateText,
paragraphAnimationStyle: value,
blockQuoteStyle: blockQuoteStyle,
headingStyle: headingStyle,
orderedListStyle: orderedListStyle,
paragraphStyle: paragraphStyle,
tableStyle: tableStyle,
inlineStyle: inlineStyle,
textContextMenu: textContextMenu,
citationConfig: citationConfig,
codeBlockConfig: codeBlockConfig,
blockSpacing: blockSpacing,
textSelectionConfig: textSelectionConfig,
thematicBreakColor: thematicBreakColor,
imageConfig: imageConfig
)
}
}
5 changes: 5 additions & 0 deletions Sources/MarkdownText/Models/MarkdownRenderConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import SwiftUI
public struct MarkdownRenderConfig: Hashable, Sendable {
/// When `true`, newly appended text fades in instead of appearing instantly.
public let shouldAnimateText: Bool
/// How newly appended words animate in while streaming. Only applies when
/// `shouldAnimateText` is `true`. Defaults to `.fade`.
public let paragraphAnimationStyle: ParagraphAnimationStyle
/// Styling applied to block-quote content.
public let blockQuoteStyle: MarkdownTextStyle
/// Per-level heading styling.
Expand Down Expand Up @@ -254,6 +257,7 @@ public struct MarkdownRenderConfig: Hashable, Sendable {
/// override only the fields they care about.
public init(
shouldAnimateText: Bool = false,
paragraphAnimationStyle: ParagraphAnimationStyle = .fade,
blockQuoteStyle: MarkdownTextStyle = MarkdownRenderConfig.defaultBlockQuoteStyle,
headingStyle: MarkdownHeadingTextStyle = MarkdownRenderConfig.defaultHeadingStyle,
orderedListStyle: MarkdownTextStyle = MarkdownRenderConfig.defaultOrderedListStyle,
Expand All @@ -269,6 +273,7 @@ public struct MarkdownRenderConfig: Hashable, Sendable {
imageConfig: ImageConfig = .disabled
) {
self.shouldAnimateText = shouldAnimateText
self.paragraphAnimationStyle = paragraphAnimationStyle
self.blockQuoteStyle = blockQuoteStyle
self.headingStyle = headingStyle
self.orderedListStyle = orderedListStyle
Expand Down
68 changes: 48 additions & 20 deletions Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ class ParagraphNSView: NSTextView {

private(set) var paragraphContents: NSMutableAttributedString = NSMutableAttributedString()
private(set) var lineSpacing: CGFloat?
var animationStyle: ParagraphAnimationStyle = .fade
private var activeAnimations: [FadeAnimationData] = []
private var nextWaveStartTime: CFTimeInterval = 0
private var fadeAnimationDisplayLink: CADisplayLink?
private var cachedSize: CachedParagraphNSViewSize?

Expand Down Expand Up @@ -143,17 +145,36 @@ class ParagraphNSView: NSTextView {

if animatedByWord, newContentLength > 0 {
let newContentRange = NSRange(location: oldLength, length: newContentLength)
let wordRanges = finalString.splitIntoWords(withIn: newContentRange)
let wordCount = wordRanges.count
let delayBetweenWords: Double = ParagraphAnimationConstants.delayBetweenWordsRatio / Double(max(wordCount, 1))
let baseStartTime = CACurrentMediaTime()
for (index, wordRange) in wordRanges.enumerated() {
let animationData = FadeAnimationData(
startTime: baseStartTime + Double(index) * delayBetweenWords,
duration: Self.animationDuration,
range: wordRange
)
activeAnimations.append(animationData)

if animationStyle.rises {
// Rise: animate per character so each glyph ripples in, rather than
// whole words moving as rigid blocks. The stagger cursor is chained
// across streamed chunks (and capped by maxWaveLead) so the wave stays
// continuous instead of restarting per chunk.
let characterRanges = finalString.splitIntoCharacters(withIn: newContentRange)
var cursor = min(max(baseStartTime, nextWaveStartTime), baseStartTime + ParagraphAnimationConstants.maxWaveLead)
for range in characterRanges {
activeAnimations.append(FadeAnimationData(
startTime: cursor,
duration: Self.animationDuration,
range: range
))
cursor += ParagraphAnimationConstants.delayBetweenCharacters
}
nextWaveStartTime = cursor
} else {
// Fade: animate word by word (original behavior).
let wordRanges = finalString.splitIntoWords(withIn: newContentRange)
let wordCount = wordRanges.count
let delayBetweenWords: Double = ParagraphAnimationConstants.delayBetweenWordsRatio / Double(max(wordCount, 1))
for (index, wordRange) in wordRanges.enumerated() {
activeAnimations.append(FadeAnimationData(
startTime: baseStartTime + Double(index) * delayBetweenWords,
duration: Self.animationDuration,
range: wordRange
))
}
}

updateTextViewWithCurrentAnimations()
Expand All @@ -163,6 +184,7 @@ class ParagraphNSView: NSTextView {
}
} else {
activeAnimations.removeAll()
nextWaveStartTime = 0
}
}

Expand Down Expand Up @@ -273,20 +295,26 @@ class ParagraphNSView: NSTextView {
continue
}
let elapsed = currentTime - animation.startTime
let animatedAlpha: CGFloat

let progress: CGFloat
if elapsed < 0 {
animatedAlpha = 0.0
progress = 0.0
} else {
let progress = min(max(elapsed / animation.duration, 0.0), 1.0)
let easedProgress = paragraphEaseOut(progress)
animatedAlpha = easedProgress
progress = paragraphEaseOut(min(max(elapsed / animation.duration, 0.0), 1.0))
}

// Vertical offset: start below the baseline and settle up to 0. Negative
// baselineOffset lowers the glyphs; it eases back to 0 as progress → 1.
if animationStyle.rises {
let offset = -ParagraphAnimationConstants.riseDistance * (1 - progress)
textStorage.addAttribute(.baselineOffset, value: offset, range: animation.range)
}

let defaultColor = NSColor(Color.Theme.Foreground.Primary.Primary750)
textStorage.enumerateAttribute(.foregroundColor, in: animation.range, options: []) { value, range, _ in
let baseColor = (value as? NSColor) ?? defaultColor
textStorage.addAttribute(.foregroundColor, value: baseColor.withAlphaComponent(animatedAlpha), range: range)
if animationStyle.fades {
let defaultColor = NSColor(Color.Theme.Foreground.Primary.Primary750)
textStorage.enumerateAttribute(.foregroundColor, in: animation.range, options: []) { value, range, _ in
let baseColor = (value as? NSColor) ?? defaultColor
textStorage.addAttribute(.foregroundColor, value: baseColor.withAlphaComponent(progress), range: range)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct ParagraphView: NSViewRepresentable {
// paragraph gets its own view instead.
let view = ParagraphNSView()
view.onUrlTap = openUrlFunction
view.animationStyle = config.paragraphAnimationStyle
view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: false)
view.setTextContextMenu(config.resolvedTextContextMenu)
view.setMarkdownController(markdownController)
Expand All @@ -42,6 +43,7 @@ struct ParagraphView: NSViewRepresentable {
}

func updateNSView(_ view: ParagraphNSView, context: Context) {
view.animationStyle = config.paragraphAnimationStyle
if view.paragraphContents != contents || view.lineSpacing != lineSpacing {
let shouldAnimate = view.window != nil && config.shouldAnimateText
view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: shouldAnimate)
Expand Down
38 changes: 38 additions & 0 deletions Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,44 @@ import Foundation
enum ParagraphAnimationConstants {
static let fadeInDuration: CFTimeInterval = 0.5
static let delayBetweenWordsRatio: Double = 0.1
/// Vertical distance, in points, that a word travels while rising into place
/// for the `.rise` / `.fadeAndRise` styles. The word starts this far below
/// its final baseline and settles up to `0`.
static let riseDistance: CGFloat = 8
/// Fixed delay between consecutive characters in a rise wave. A small,
/// constant per-character delay (rather than a per-chunk-normalized delay)
/// makes characters ripple in continuously instead of moving as rigid blocks.
static let delayBetweenCharacters: CFTimeInterval = 0.02
/// Maximum time the rise wave is allowed to run ahead of real time. Caps the
/// backlog so a large streamed chunk doesn't leave trailing characters
/// lagging far behind; beyond this lead, characters start catching up.
static let maxWaveLead: CFTimeInterval = 0.3
}

/// How newly appended words animate in when `shouldAnimateText` is enabled.
public enum ParagraphAnimationStyle: Sendable, Hashable {
/// Words fade from transparent to opaque (the default, original behavior).
case fade
/// Words rise from slightly below their baseline into place.
case rise
/// Words simultaneously fade in and rise into place.
case fadeAndRise

/// Whether this style animates opacity.
var fades: Bool {
switch self {
case .fade, .fadeAndRise: return true
case .rise: return false
}
}

/// Whether this style animates vertical offset.
var rises: Bool {
switch self {
case .rise, .fadeAndRise: return true
case .fade: return false
}
}
}

struct FadeAnimationData {
Expand Down
71 changes: 49 additions & 22 deletions Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ class ParagraphUIView: UITextView {

private(set) var paragraphContents: NSMutableAttributedString = NSMutableAttributedString()
private(set) var lineSpacing: CGFloat?
var animationStyle: ParagraphAnimationStyle = .fade
private var activeAnimations: [FadeAnimationData] = []
private var nextWaveStartTime: CFTimeInterval = 0
private var fadeAnimationDisplayLink: CADisplayLink?
private var cachedSize: CachedParagraphUIViewSize?

Expand Down Expand Up @@ -135,19 +137,37 @@ class ParagraphUIView: UITextView {

if animatedByWord,
newContentLength > 0 {
// Animate word by word
let newContentRange = NSRange(location: oldAttributedString.length, length: newContentLength)
let wordRanges = attributedText.splitIntoWords(withIn: newContentRange)
let wordCount = wordRanges.count
let delayBetweenWords: Double = ParagraphAnimationConstants.delayBetweenWordsRatio / Double(wordCount)
let baseStartTime = CACurrentMediaTime()
for (index, wordRange) in wordRanges.enumerated() {
let animationData = FadeAnimationData(
startTime: baseStartTime + Double(index) * delayBetweenWords,
duration: Self.animationDuration,
range: wordRange
)
activeAnimations.append(animationData)

if animationStyle.rises {
// Rise: animate per character so each glyph ripples in, rather than
// whole words moving as rigid blocks. The stagger cursor is chained
// across streamed chunks (and capped by maxWaveLead) so the wave stays
// continuous instead of restarting per chunk.
let characterRanges = attributedText.splitIntoCharacters(withIn: newContentRange)
var cursor = min(max(baseStartTime, nextWaveStartTime), baseStartTime + ParagraphAnimationConstants.maxWaveLead)
for range in characterRanges {
activeAnimations.append(FadeAnimationData(
startTime: cursor,
duration: Self.animationDuration,
range: range
))
cursor += ParagraphAnimationConstants.delayBetweenCharacters
}
nextWaveStartTime = cursor
} else {
// Fade: animate word by word (original behavior).
let wordRanges = attributedText.splitIntoWords(withIn: newContentRange)
let wordCount = wordRanges.count
let delayBetweenWords: Double = ParagraphAnimationConstants.delayBetweenWordsRatio / Double(max(wordCount, 1))
for (index, wordRange) in wordRanges.enumerated() {
activeAnimations.append(FadeAnimationData(
startTime: baseStartTime + Double(index) * delayBetweenWords,
duration: Self.animationDuration,
range: wordRange
))
}
}

updateTextViewWithCurrentAnimations()
Expand All @@ -158,6 +178,7 @@ class ParagraphUIView: UITextView {
} else {
// If no animation needed anymore, clean up all existings animations if any.
activeAnimations.removeAll()
nextWaveStartTime = 0
}
}

Expand Down Expand Up @@ -294,23 +315,29 @@ class ParagraphUIView: UITextView {
continue
}
let elapsed = currentTime - animation.startTime
let animatedAlpha: CGFloat

let progress: CGFloat
if elapsed < 0 {
animatedAlpha = 0.0
progress = 0.0
} else {
let progress = min(max(elapsed / animation.duration, 0.0), 1.0)
let easedProgress = paragraphEaseOut(progress)
animatedAlpha = easedProgress
progress = paragraphEaseOut(min(max(elapsed / animation.duration, 0.0), 1.0))
}

// Vertical offset: start below the baseline and settle up to 0. Negative
// baselineOffset lowers the glyphs; it eases back to 0 as progress → 1.
if animationStyle.rises {
let offset = -ParagraphAnimationConstants.riseDistance * (1 - progress)
textStorage.addAttribute(.baselineOffset, value: offset, range: animation.range)
}

// Apply alpha to this animation's range, preserving each span's
// Opacity: apply alpha to this animation's range, preserving each span's
// existing foreground color. Spans with no foreground color get a
// sensible default so they still fade in instead of disappearing.
let defaultColor = UIColor(Color.Theme.Foreground.Primary.Primary750)
textStorage.enumerateAttribute(.foregroundColor, in: animation.range, options: []) { value, range, _ in
let baseColor = (value as? UIColor) ?? defaultColor
textStorage.addAttribute(.foregroundColor, value: baseColor.withAlphaComponent(animatedAlpha), range: range)
if animationStyle.fades {
let defaultColor = UIColor(Color.Theme.Foreground.Primary.Primary750)
textStorage.enumerateAttribute(.foregroundColor, in: animation.range, options: []) { value, range, _ in
let baseColor = (value as? UIColor) ?? defaultColor
textStorage.addAttribute(.foregroundColor, value: baseColor.withAlphaComponent(progress), range: range)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ struct ParagraphView: UIViewRepresentable {
let openUrlFunction = openURL.callAsFunction(_:)
let view = ParagraphViewCache.shared.createOrReuseView(contents: contents, lineSpacing: lineSpacing)
view.onUrlTap = openUrlFunction
view.animationStyle = config.paragraphAnimationStyle
view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: false)
view.setTextContextMenu(config.resolvedTextContextMenu)
view.setMarkdownController(markdownController)
Expand All @@ -37,6 +38,7 @@ struct ParagraphView: UIViewRepresentable {
}

func updateUIView(_ view: ParagraphUIView, context: Context) {
view.animationStyle = config.paragraphAnimationStyle
if view.paragraphContents != contents || view.lineSpacing != lineSpacing {
let shouldAnimate = view.window != nil && config.shouldAnimateText // only animate when visible
view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: shouldAnimate)
Expand Down
24 changes: 24 additions & 0 deletions Sources/MarkdownText/Utilities/NSAttributedString+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@ import AppKit
#endif

extension NSAttributedString {
/// Splits `range` into one `NSRange` per composed character sequence
/// (grapheme). Unlike `splitIntoWords`, the returned ranges are contiguous
/// and cover every character — including whitespace and attachments — which
/// makes it suitable for per-character animation waves.
func splitIntoCharacters(withIn range: NSRange) -> [NSRange] {
var characters: [NSRange] = []
let string = self.string as NSString

guard range.location != NSNotFound,
range.location >= 0,
NSMaxRange(range) <= string.length else {
return characters
}

string.enumerateSubstrings(
in: range,
options: [.byComposedCharacterSequences, .substringNotRequired]
) { (_, substringRange, _, _) in
characters.append(substringRange)
}

return characters
}

func splitIntoWords(withIn range: NSRange) -> [NSRange] {
var words: [NSRange] = []
let string = self.string as NSString
Expand Down
Loading