diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift index 72c022d..b277bc9 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift @@ -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 { diff --git a/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift b/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift index 019cc3f..640d304 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift @@ -241,6 +241,7 @@ extension MarkdownRenderConfig { public func withImageConfig(_ value: ImageConfig) -> MarkdownRenderConfig { MarkdownRenderConfig( shouldAnimateText: shouldAnimateText, + paragraphAnimationStyle: paragraphAnimationStyle, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -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 + ) + } } diff --git a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift index 7344464..ee429f5 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift @@ -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. @@ -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, @@ -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 diff --git a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift index c4327bd..3d1c4f8 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift @@ -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? @@ -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() @@ -163,6 +184,7 @@ class ParagraphNSView: NSTextView { } } else { activeAnimations.removeAll() + nextWaveStartTime = 0 } } @@ -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) + } } } } diff --git a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift index befa77f..42ee3e7 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift @@ -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) @@ -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) diff --git a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift index 525d2d3..11c1225 100644 --- a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift +++ b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift @@ -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 { diff --git a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift index 56f3f10..df58d33 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift @@ -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? @@ -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() @@ -158,6 +178,7 @@ class ParagraphUIView: UITextView { } else { // If no animation needed anymore, clean up all existings animations if any. activeAnimations.removeAll() + nextWaveStartTime = 0 } } @@ -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) + } } } } diff --git a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift index c1fdd34..bda23ac 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift @@ -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) @@ -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) diff --git a/Sources/MarkdownText/Utilities/NSAttributedString+.swift b/Sources/MarkdownText/Utilities/NSAttributedString+.swift index d3af12e..273aa16 100644 --- a/Sources/MarkdownText/Utilities/NSAttributedString+.swift +++ b/Sources/MarkdownText/Utilities/NSAttributedString+.swift @@ -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