diff --git a/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift b/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift
index f7b5bb9..44032f4 100644
--- a/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift
+++ b/Example/STBaseProjectExampleTests/STMarkdownUIViewTests.swift
@@ -140,6 +140,30 @@ final class STMarkdownUIViewTests: XCTestCase {
XCTAssertLessThan(self.foregroundAlpha(in: visible, at: tailRange.location), 0.5)
}
+ func testStreamingLineFadeModeCreatesMaskAndReportsAnimatingState() {
+ let style = STMarkdownStyle(
+ font: .systemFont(ofSize: 16),
+ textColor: .label,
+ lineHeight: 22,
+ kern: 0,
+ streamFadeInEnabled: true,
+ streamLineFadeEnabled: true
+ )
+ let view = STMarkdownStreamingTextView(style: style, usesTextLayoutManager: false)
+ view.frame = CGRect(x: 0, y: 0, width: 240, height: 80)
+ view.layoutIfNeeded()
+ view.setMarkdown("Hello", animated: false)
+
+ var states: [Bool] = []
+ (view.contentTextView as? STShimmerTextView)?.onAnimationStateChange = { states.append($0) }
+
+ view.appendMarkdownFragment(" world", animated: true)
+
+ XCTAssertTrue(view.isStreamingAnimationIdle == false)
+ XCTAssertNotNil(view.contentTextView.layer.mask)
+ XCTAssertEqual(states.first, true)
+ }
+
func testSmartStreamingSecondCommittedFrameUsesIncrementalMergedRenderPath() {
let view = STMarkdownStreamingTextView()
view.tokenFadeDuration = 0
diff --git a/README.md b/README.md
index 9aacfa4..7147232 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ STBaseProject 是一个功能强大的 iOS 基础组件库,提供了丰富的
在 `Podfile` 中添加:
```ruby
-pod 'STBaseProject', '~> 1.3.0'
+pod 'STBaseProject', '~> 1.4.0'
```
然后执行:
@@ -57,7 +57,7 @@ pod install
```swift
dependencies: [
- .package(url: "https://github.com/i-stack/STBaseProject.git", from: "1.3.0")
+ .package(url: "https://github.com/i-stack/STBaseProject.git", from: "1.4.0")
]
```
@@ -85,7 +85,7 @@ dependencies: [
```swift
dependencies: [
- .package(url: "https://github.com/i-stack/STBaseProject.git", from: "1.3.0")
+ .package(url: "https://github.com/i-stack/STBaseProject.git", from: "1.4.0")
],
targets: [
.target(
@@ -105,16 +105,16 @@ targets: [
```ruby
# 默认:仅核心(等价于 default_subspecs)
-pod 'STBaseProject', '~> 1.3.0'
+pod 'STBaseProject', '~> 1.4.0'
# 核心 + 定位(按需把 STContacts、STMedia、STMarkdown 加入数组即可)
-pod 'STBaseProject', '~> 1.3.0', :subspecs => ['STBaseProject', 'STLocation']
+pod 'STBaseProject', '~> 1.4.0', :subspecs => ['STBaseProject', 'STLocation']
# 核心 + 多个扩展示例
-# pod 'STBaseProject', '~> 1.3.0', :subspecs => ['STBaseProject', 'STLocation', 'STContacts', 'STMedia']
+# pod 'STBaseProject', '~> 1.4.0', :subspecs => ['STBaseProject', 'STLocation', 'STContacts', 'STMedia']
# 仅安装某个扩展、不要核心(一般少见;扩展模块不依赖核心时可单独拉取)
-# pod 'STBaseProject/STLocation', '~> 1.3.0'
+# pod 'STBaseProject/STLocation', '~> 1.4.0'
```
@@ -275,13 +275,13 @@ README 仅保留能力概览与模块入口。
仓库内提供自动发布脚本:
```bash
-./scripts/release_pod.sh 1.1.6
+./scripts/release_pod.sh 1.4.0
```
推荐(自动创建并推送同名 tag):
```bash
-./scripts/release_pod.sh 1.1.6 --tag --push-tag
+./scripts/release_pod.sh 1.4.0 --tag --push-tag
```
脚本会按顺序执行:
diff --git a/STBaseProject.podspec b/STBaseProject.podspec
index 67340aa..a3da2de 100644
--- a/STBaseProject.podspec
+++ b/STBaseProject.podspec
@@ -8,7 +8,7 @@
Pod::Spec.new do |s|
s.name = 'STBaseProject'
- s.version = '1.3.0'
+ s.version = '1.4.0'
s.summary = 'Modular iOS foundation: MVVM bases, networking, security, UIKit, Markdown, localization (SPM & CocoaPods).'
s.description = <<-DESC
STBaseProject is an iOS 16+ modular foundation toolkit distributed via CocoaPods subspecs and Swift Package Manager.
diff --git a/Sources/STMarkdown/Core/STMarkdownRegexPatterns.swift b/Sources/STMarkdown/Core/STMarkdownRegexPatterns.swift
index 6be82f6..5203660 100644
--- a/Sources/STMarkdown/Core/STMarkdownRegexPatterns.swift
+++ b/Sources/STMarkdown/Core/STMarkdownRegexPatterns.swift
@@ -190,6 +190,14 @@ public enum STMarkdownCitationRegex {
pattern: #"\[[^\]]+\]\([^)]+\)\s*(\[(?i:citation)\s*:?\s*\d+\])"#,
owner: "STMarkdownCitationRegex.linkCitationDeduplicate"
)
+
+ /// 将 1–20 的正整数转为对应的 Unicode 带圆圈数字字符(① ② … ⑳)。
+ /// 超出范围(或非正整数)返回 nil。
+ public static func circledNumberText(for number: String) -> String? {
+ guard let value = Int(number), value > 0, value <= 20 else { return nil }
+ guard let scalar = UnicodeScalar(0x2460 + value - 1) else { return nil }
+ return String(Character(scalar))
+ }
}
public enum STMarkdownStreamingRegex {
diff --git a/Sources/STMarkdown/Core/STMarkdownTypography.swift b/Sources/STMarkdown/Core/STMarkdownTypography.swift
index 37d74cf..4768033 100644
--- a/Sources/STMarkdown/Core/STMarkdownTypography.swift
+++ b/Sources/STMarkdown/Core/STMarkdownTypography.swift
@@ -34,7 +34,7 @@ public enum STMarkdownTypography {
}
public static func headingInsets(for level: Int) -> UIEdgeInsets {
- return UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0)
+ return UIEdgeInsets(top: 18, left: 0, bottom: 18, right: 0)
}
public static func headingParagraphStyle(level: Int, font: UIFont, style: STMarkdownStyle) -> NSMutableParagraphStyle {
diff --git a/Sources/STMarkdown/Parsing/STMarkdownMathNormalizer.swift b/Sources/STMarkdown/Parsing/STMarkdownMathNormalizer.swift
index 1e8c72a..0462f35 100644
--- a/Sources/STMarkdown/Parsing/STMarkdownMathNormalizer.swift
+++ b/Sources/STMarkdown/Parsing/STMarkdownMathNormalizer.swift
@@ -49,28 +49,31 @@ public enum STMarkdownMathNormalizer {
}
if trimmed.hasPrefix("$$") {
+ let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" }))
let result = consumeDollarMathBlock(from: lines, start: index)
let currentIndex = mathMap.count
mathMap[currentIndex] = result.content.trimmingCharacters(in: .whitespacesAndNewlines)
output.append("")
- output.append("{{ST_MATH_BLOCK:\(currentIndex)}}")
+ output.append(indent + "{{ST_MATH_BLOCK:\(currentIndex)}}")
output.append("")
index = result.nextIndex
continue
}
if trimmed.hasPrefix(#"\["#) {
+ let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" }))
let result = consumeBracketMathBlock(from: lines, start: index)
let currentIndex = mathMap.count
mathMap[currentIndex] = result.content.trimmingCharacters(in: .whitespacesAndNewlines)
output.append("")
- output.append("{{ST_MATH_BLOCK:\(currentIndex)}}")
+ output.append(indent + "{{ST_MATH_BLOCK:\(currentIndex)}}")
output.append("")
index = result.nextIndex
continue
}
if let environment = environmentName(from: trimmed) {
+ let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" }))
let result = consumeEnvironmentMathBlock(
from: lines,
start: index,
@@ -79,7 +82,7 @@ public enum STMarkdownMathNormalizer {
let currentIndex = mathMap.count
mathMap[currentIndex] = result.content.trimmingCharacters(in: .whitespacesAndNewlines)
output.append("")
- output.append("{{ST_MATH_BLOCK:\(currentIndex)}}")
+ output.append(indent + "{{ST_MATH_BLOCK:\(currentIndex)}}")
output.append("")
index = result.nextIndex
continue
@@ -110,6 +113,10 @@ public enum STMarkdownMathNormalizer {
result = result.replacingOccurrences(of: "⦅ST_LATEX_PAREN_CLOSE⦆", with: #"\)"#)
result = result.replacingOccurrences(of: "⦅ST_LATEX_BRACKET_OPEN⦆", with: #"\["#)
result = result.replacingOccurrences(of: "⦅ST_LATEX_BRACKET_CLOSE⦆", with: #"\]"#)
+ result = result.replacingOccurrences(of: "⦅ST_MATH_LBRACKET⦆", with: "[")
+ result = result.replacingOccurrences(of: "⦅ST_MATH_RBRACKET⦆", with: "]")
+ result = result.replacingOccurrences(of: "⦅ST_MATH_ASTERISK⦆", with: "*")
+ result = result.replacingOccurrences(of: "⦅ST_MATH_BACKTICK⦆", with: "`")
return result
}
@@ -119,6 +126,71 @@ public enum STMarkdownMathNormalizer {
result = result.replacingOccurrences(of: #"\)"#, with: "⦅ST_LATEX_PAREN_CLOSE⦆")
result = result.replacingOccurrences(of: #"\["#, with: "⦅ST_LATEX_BRACKET_OPEN⦆")
result = result.replacingOccurrences(of: #"\]"#, with: "⦅ST_LATEX_BRACKET_CLOSE⦆")
+ result = protectMarkdownCharsInsideMath(in: result)
+ return result
+ }
+
+ /// 在 `applyInlineSentinels` 已经把 `\(...\)` / `\[...\]` 定界符替换成 sentinel 之后,
+ /// 把 sentinel 包裹的数学区段内部仍为字面量的 `[`、`]`、`*`、`` ` `` 也换成 sentinel。
+ ///
+ /// 不处理这一步时,例如 `\(\sum_{k=1}^n k^3 = \left[\frac{n(n+1)}{2}\right]^2\)`,
+ /// 数学内部的 `[\frac{n(n+1)}{2}\right]` 会被 swift-markdown 识别为
+ /// shortcut reference link,从而拆掉 Text 节点 + 吃掉中括号,后续
+ /// `splitInlineMath` 拿不到完整的 `\(...\)` 配对,整段公式退化为纯文本。
+ ///
+ /// `_` 不需要保护:LaTeX 中 `_` 多为 intraword (`k_n`) 或紧跟标点 (`_{...`),
+ /// 都不满足 CommonMark left-flanking 条件,无法打开 emphasis。
+ private static func protectMarkdownCharsInsideMath(in text: String) -> String {
+ let openParen = "⦅ST_LATEX_PAREN_OPEN⦆"
+ let closeParen = "⦅ST_LATEX_PAREN_CLOSE⦆"
+ let openBracket = "⦅ST_LATEX_BRACKET_OPEN⦆"
+ let closeBracket = "⦅ST_LATEX_BRACKET_CLOSE⦆"
+
+ var result = ""
+ var cursor = text.startIndex
+
+ while cursor < text.endIndex {
+ let tail = text[cursor...]
+ let parenOpen = tail.range(of: openParen)
+ let bracketOpen = tail.range(of: openBracket)
+
+ let nextOpen: (Range, String)?
+ switch (parenOpen, bracketOpen) {
+ case let (p?, b?):
+ nextOpen = p.lowerBound < b.lowerBound ? (p, closeParen) : (b, closeBracket)
+ case let (p?, nil):
+ nextOpen = (p, closeParen)
+ case let (nil, b?):
+ nextOpen = (b, closeBracket)
+ case (nil, nil):
+ nextOpen = nil
+ }
+
+ guard let (openRange, closingSentinel) = nextOpen else {
+ result += String(tail)
+ break
+ }
+
+ result += String(text[cursor.. String {
+ var result = ""
+ var i = text.startIndex
+ while i < text.endIndex {
+ guard text[i] == "\\" else {
+ result.append(text[i])
+ i = text.index(after: i)
+ continue
+ }
+ let next = text.index(after: i)
+ guard next < text.endIndex else {
+ result.append(text[i])
+ i = next
+ continue
+ }
+ let nextChar = text[next]
+ guard nextChar == "(" || nextChar == "[" else {
+ result.append(text[i])
+ i = text.index(after: i)
+ continue
+ }
+ let closeChar: Character = nextChar == "(" ? ")" : "]"
+ let afterDelim = text.index(after: next)
+ var j = afterDelim
+ var found = false
+ while j < text.endIndex {
+ if text[j] == "\\" {
+ let k = text.index(after: j)
+ if k < text.endIndex && text[k] == closeChar {
+ let contentLen = text.distance(from: afterDelim, to: j)
+ result.append("\\")
+ result.append(nextChar)
+ result += String(repeating: " ", count: contentLen)
+ result.append("\\")
+ result.append(closeChar)
+ i = text.index(after: k)
+ found = true
+ break
+ }
+ }
+ j = text.index(after: j)
+ }
+ if !found {
+ result.append(text[i])
+ i = text.index(after: i)
+ }
+ }
+ return result
+ }
+
private static func trimUnpairedTrailingMarker(in line: String, marker: String, markerLen: Int) -> String {
var positions: [String.Index] = []
if markerLen == 1 {
@@ -761,15 +823,21 @@ public enum STMarkdownStreamingTransforms {
private static func countUnescapedOccurrences(of token: String, in text: String) -> Int {
guard !token.isEmpty else { return 0 }
+ let scalars = Array(text.unicodeScalars)
+ let tokenScalars = Array(token.unicodeScalars)
+ guard tokenScalars.count <= scalars.count else { return 0 }
+
var count = 0
- var searchStart = text.startIndex
- while let range = text.range(of: token, range: searchStart.. text.startIndex
- && text[text.index(before: range.lowerBound)] == "\\"
- if !escaped {
+ var index = 0
+ while index <= scalars.count - tokenScalars.count {
+ let escaped = index > 0 && scalars[index - 1] == "\\"
+ let matches = !escaped && zip(scalars[index..<(index + tokenScalars.count)], tokenScalars).allSatisfy(==)
+ if matches {
count += 1
+ index += tokenScalars.count
+ } else {
+ index += 1
}
- searchStart = range.upperBound
}
return count
}
@@ -907,6 +975,46 @@ public enum STMarkdownStreamingTransforms {
return output.joined(separator: "\n")
}
+ /// Reverse of ``flattenStreamingListSyntax``: converts Unicode bullet symbols and N) markers
+ /// back to standard Markdown list syntax (`-` and `N.`) so that the STMarkdown parser
+ /// correctly identifies nested list items rather than treating them as plain-text continuations.
+ ///
+ /// Only lines with 0–3 leading spaces are processed, matching the same constraint used during
+ /// flattening. Code fences are passed through unchanged.
+ public static func unflattenStreamingListSyntax(in text: String) -> String {
+ guard !text.isEmpty else { return text }
+ var inFencedCodeBlock = false
+ var fenceToken: String?
+ var output: [String] = []
+ let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ output.reserveCapacity(lines.count)
+ for line in lines {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") {
+ let token = String(trimmed.prefix(3))
+ if !inFencedCodeBlock { inFencedCodeBlock = true; fenceToken = token }
+ else if fenceToken == token { inFencedCodeBlock = false; fenceToken = nil }
+ output.append(line); continue
+ }
+ if inFencedCodeBlock { output.append(line); continue }
+ let range = NSRange(location: 0, length: (line as NSString).length)
+ if Self.flattenedUnorderedListLineRegex.firstMatch(in: line, options: [], range: range) != nil {
+ output.append(Self.flattenedUnorderedListLineRegex.stringByReplacingMatches(
+ in: line, options: [], range: range, withTemplate: "$1- $2"
+ ))
+ continue
+ }
+ if Self.flattenedOrderedListLineRegex.firstMatch(in: line, options: [], range: range) != nil {
+ output.append(Self.flattenedOrderedListLineRegex.stringByReplacingMatches(
+ in: line, options: [], range: range, withTemplate: "$1$2. $3"
+ ))
+ continue
+ }
+ output.append(line)
+ }
+ return output.joined(separator: "\n")
+ }
+
public static func flattenStreamingBlockSyntax(in text: String) -> String {
guard !text.isEmpty else { return text }
var inFencedCodeBlock = false
diff --git a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAdvancedRenderers.swift b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAdvancedRenderers.swift
index 6617482..02f0cbe 100644
--- a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAdvancedRenderers.swift
+++ b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAdvancedRenderers.swift
@@ -100,23 +100,36 @@ public struct STMarkdownHighFidelityMathRenderer: STMarkdownInlineMathRendering,
}
public func renderBlockMath(formula: String, style: STMarkdownStyle) -> NSAttributedString? {
+ let availableWidth: CGFloat
+ if style.renderWidth > 0 {
+ availableWidth = style.renderWidth
+ } else {
+ availableWidth = Thread.isMainThread ? UIScreen.main.bounds.width - 32 : 343
+ }
guard let image = self.renderImage(
formula: formula,
fontSize: max(style.font.pointSize + 2, 18),
textColor: style.textColor,
displayMode: true,
- maximumWidth: 280
+ maximumWidth: availableWidth
) else {
return self.fallbackRenderer.renderBlockMath(formula: formula, style: style)
}
let attachment = NSTextAttachment()
attachment.image = image
- attachment.bounds = CGRect(origin: .zero, size: image.size)
+ // Scale down to fit when SwiftMath returns an image wider than the available container
+ // width (e.g. aligned rows with long \text{} content). Scaling only the attachment
+ // bounds works because UIKit draws the image to fit those bounds.
+ let scale: CGFloat = image.size.width > availableWidth && availableWidth > 0
+ ? availableWidth / image.size.width
+ : 1.0
+ let displaySize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
+ attachment.bounds = CGRect(origin: .zero, size: displaySize)
let paragraphStyle = NSMutableParagraphStyle()
- paragraphStyle.minimumLineHeight = max(style.lineHeight, image.size.height)
- paragraphStyle.maximumLineHeight = max(style.lineHeight, image.size.height)
+ paragraphStyle.minimumLineHeight = max(style.lineHeight, displaySize.height)
+ paragraphStyle.maximumLineHeight = max(style.lineHeight, displaySize.height)
paragraphStyle.paragraphSpacing = style.paragraphSpacing
paragraphStyle.paragraphSpacingBefore = style.lineHeight / 2
paragraphStyle.alignment = .center
@@ -138,49 +151,24 @@ public struct STMarkdownHighFidelityMathRenderer: STMarkdownInlineMathRendering,
private extension STMarkdownHighFidelityMathRenderer {
func renderImage(formula: String, fontSize: CGFloat, textColor: UIColor, displayMode: Bool, maximumWidth: CGFloat) -> UIImage? {
- // MTMathUILabel 是 UIView,layout / layer.render(in:) 都需要在主线程访问。
- // 上层 `STMarkdownAttributedStringRenderer.render(document:)` 没有 actor 注解,
- // 后台调度器组装 NSAttributedString 时不能触碰 SwiftMath 的 UIView 渲染路径;
- // 返回 nil 让调用方走纯 NSAttributedString fallback,而不是在库底层触发 crash。
- guard Thread.isMainThread else {
- return nil
- }
let normalized = self.normalizedFormula(formula)
- let label = MTMathUILabel()
- label.latex = normalized
- label.fontSize = fontSize
- label.textColor = textColor
- label.backgroundColor = .clear
- label.labelMode = displayMode ? .display : .text
- label.textAlignment = displayMode ? .center : .left
- label.contentInsets = displayMode
+ let mathImage = MTMathImage(
+ latex: normalized,
+ fontSize: max(fontSize, 10),
+ textColor: textColor,
+ labelMode: displayMode ? .display : .text,
+ textAlignment: displayMode ? .center : .left
+ )
+ mathImage.contentInsets = displayMode
? UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
: UIEdgeInsets(top: 2, left: 0, bottom: 2, right: 0)
- label.displayErrorInline = true
- let fittingSize = label.sizeThatFits(CGSize(width: maximumWidth, height: .greatestFiniteMagnitude))
- guard fittingSize.width > 0, fittingSize.height > 0 else {
- return nil
- }
- label.frame = CGRect(origin: .zero, size: CGSize(width: ceil(fittingSize.width), height: ceil(fittingSize.height)))
- let format = UIGraphicsImageRendererFormat.default()
- let renderer = UIGraphicsImageRenderer(size: label.bounds.size, format: format)
- let image = renderer.image { context in
- let cgContext = context.cgContext
- // MTMathUILabel 内部使用 Core Graphics 坐标系(Y 轴向上)绘制公式。
- // UIGraphicsImageRenderer 提供的是 UIKit 坐标系(Y 轴向下)。
- // layer.render(in:) 不会自动处理 isGeometryFlipped=true 导致的坐标翻转,
- // 因此需要手动翻转 Y 轴,与 SwiftMath 官方 MathImage.asImage() 的做法一致。
- cgContext.translateBy(x: 0, y: label.bounds.size.height)
- cgContext.scaleBy(x: 1, y: -1)
- label.layer.render(in: cgContext)
- }
- return image.size.width > 0 && image.size.height > 0 ? image : nil
+ let (_, image) = mathImage.asImage()
+ guard let image, image.size.width > 0, image.size.height > 0 else { return nil }
+ return image
}
func normalizedFormula(_ formula: String) -> String {
- // Raw-string 字面 `#"\("#` 里的反斜杠是**一个**字面字符。
- // 早期写成 `#"\\("#` 会去匹配两个反斜杠+括号,对真实 LaTeX 输入永远不会命中。
- formula
+ var result = formula
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: #"\("#, with: "")
.replacingOccurrences(of: #"\)"#, with: "")
@@ -188,5 +176,57 @@ private extension STMarkdownHighFidelityMathRenderer {
.replacingOccurrences(of: #"\]"#, with: "")
.replacingOccurrences(of: #"\'"#, with: "'")
.replacingOccurrences(of: #"\|"#, with: "|")
+ // SwiftMath does not support align/align* — map to its equivalent `aligned`
+ .replacingOccurrences(of: #"\begin{align*}"#, with: #"\begin{aligned}"#)
+ .replacingOccurrences(of: #"\end{align*}"#, with: #"\end{aligned}"#)
+ .replacingOccurrences(of: #"\begin{align}"#, with: #"\begin{aligned}"#)
+ .replacingOccurrences(of: #"\end{align}"#, with: #"\end{aligned}"#)
+ // SwiftMath uses Latin Modern math fonts which have no CJK glyphs. When CJK characters
+ // appear inside \text{...}, SwiftMath computes zero/incorrect advance widths for those
+ // glyphs, causing the bitmap to be allocated too narrow and clipping any content that
+ // follows (e.g. "(x-1)" appears as "(x-"). Strip CJK scalars from \text{} content so
+ // SwiftMath gets a clean layout; the surrounding math renders correctly.
+ result = Self.stripCJKFromTextCommands(in: result)
+ return result
+ }
+
+ // MARK: - CJK sanitisation
+
+ private static let textCommandRegex: NSRegularExpression = {
+ // Matches \text{ ... } with non-greedy content, stopping at the first unmatched }
+ // Simple one-level: \text{[^}]*}
+ (try? NSRegularExpression(pattern: #"\\text\{([^}]*)\}"#)) ?? NSRegularExpression()
+ }()
+
+ private static func stripCJKFromTextCommands(in formula: String) -> String {
+ guard formula.unicodeScalars.contains(where: { isCJKScalar($0) }) else { return formula }
+ let ns = formula as NSString
+ let matches = textCommandRegex.matches(in: formula, range: NSRange(location: 0, length: ns.length))
+ guard !matches.isEmpty else { return formula }
+ var result = formula
+ for match in matches.reversed() {
+ guard match.numberOfRanges >= 2 else { continue }
+ let contentRange = match.range(at: 1)
+ let content = ns.substring(with: contentRange)
+ let stripped = String(content.unicodeScalars.filter { !isCJKScalar($0) })
+ guard stripped != content else { continue }
+ // If what remains after stripping is only whitespace or empty, wipe the
+ // whole \text{...} command to avoid leaving a meaningless residual token.
+ let residual = stripped.trimmingCharacters(in: .whitespaces)
+ let replacement = residual.isEmpty ? "" : stripped
+ result = (result as NSString).replacingCharacters(in: match.range, with: "\\text{\(replacement)}")
+ }
+ return result
+ }
+
+ private static func isCJKScalar(_ scalar: Unicode.Scalar) -> Bool {
+ let v = scalar.value
+ return (0x4E00...0x9FFF).contains(v) // CJK Unified Ideographs
+ || (0x3400...0x4DBF).contains(v) // CJK Extension A
+ || (0x20000...0x2A6DF).contains(v) // CJK Extension B
+ || (0x3000...0x303F).contains(v) // CJK Symbols and Punctuation
+ || (0xFF00...0xFFEF).contains(v) // Halfwidth/Fullwidth Forms
+ || (0x3040...0x309F).contains(v) // Hiragana
+ || (0x30A0...0x30FF).contains(v) // Katakana
}
}
diff --git a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift
index 3200165..5483c6c 100644
--- a/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift
+++ b/Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift
@@ -789,6 +789,9 @@ private extension STMarkdownAttributedStringRenderer {
after previousBlock: STMarkdownRenderBlock,
before nextBlock: STMarkdownRenderBlock
) -> CGFloat {
+ if STMarkdownBlockLayoutCalculator.isTableAdjacent(previousBlock: previousBlock, nextBlock: nextBlock) {
+ return 18
+ }
let trailingSpacing = self.trailingBlockSpacing(for: previousBlock)
let leadingSpacing = self.leadingBlockSpacing(for: nextBlock)
return max(max(trailingSpacing, leadingSpacing), 1)
diff --git a/Sources/STMarkdown/Rendering/Default/STMarkdownDefaultMathRenderer.swift b/Sources/STMarkdown/Rendering/Default/STMarkdownDefaultMathRenderer.swift
index e053826..4d820b9 100644
--- a/Sources/STMarkdown/Rendering/Default/STMarkdownDefaultMathRenderer.swift
+++ b/Sources/STMarkdown/Rendering/Default/STMarkdownDefaultMathRenderer.swift
@@ -23,40 +23,40 @@ public struct STMarkdownDefaultMathRenderer: STMarkdownInlineMathRendering, STMa
private extension STMarkdownDefaultMathRenderer {
static let commandMap: [String: String] = [
- #"\\alpha"#: "α",
- #"\\beta"#: "β",
- #"\\gamma"#: "γ",
- #"\\delta"#: "δ",
- #"\\theta"#: "θ",
- #"\\lambda"#: "λ",
- #"\\mu"#: "μ",
- #"\\pi"#: "π",
- #"\\sigma"#: "σ",
- #"\\phi"#: "φ",
- #"\\omega"#: "ω",
- #"\\Delta"#: "Δ",
- #"\\Gamma"#: "Γ",
- #"\\Pi"#: "Π",
- #"\\Sigma"#: "Σ",
- #"\\Phi"#: "Φ",
- #"\\Omega"#: "Ω",
- #"\\cdot"#: "·",
- #"\\times"#: "×",
- #"\\pm"#: "±",
- #"\\neq"#: "≠",
- #"\\le"#: "≤",
- #"\\ge"#: "≥",
- #"\\approx"#: "≈",
- #"\\infty"#: "∞",
- #"\\to"#: "→",
- #"\\leftarrow"#: "←",
- #"\\Rightarrow"#: "⇒",
- #"\\sum"#: "∑",
- #"\\prod"#: "∏",
- #"\\int"#: "∫",
- #"\\partial"#: "∂",
- #"\\nabla"#: "∇",
- #"\\sqrt"#: "√",
+ "\\alpha": "α",
+ "\\beta": "β",
+ "\\gamma": "γ",
+ "\\delta": "δ",
+ "\\theta": "θ",
+ "\\lambda": "λ",
+ "\\mu": "μ",
+ "\\pi": "π",
+ "\\sigma": "σ",
+ "\\phi": "φ",
+ "\\omega": "ω",
+ "\\Delta": "Δ",
+ "\\Gamma": "Γ",
+ "\\Pi": "Π",
+ "\\Sigma": "Σ",
+ "\\Phi": "Φ",
+ "\\Omega": "Ω",
+ "\\cdot": "·",
+ "\\times": "×",
+ "\\pm": "±",
+ "\\neq": "≠",
+ "\\le": "≤",
+ "\\ge": "≥",
+ "\\approx": "≈",
+ "\\infty": "∞",
+ "\\to": "→",
+ "\\leftarrow": "←",
+ "\\Rightarrow": "⇒",
+ "\\sum": "∑",
+ "\\prod": "∏",
+ "\\int": "∫",
+ "\\partial": "∂",
+ "\\nabla": "∇",
+ "\\sqrt": "√",
]
func renderMath(formula: String, style: STMarkdownStyle, baseFont: UIFont, textColor: UIColor, displayMode: Bool) -> NSAttributedString {
diff --git a/Sources/STMarkdown/Rendering/STMarkdownBlockLayoutCalculator.swift b/Sources/STMarkdown/Rendering/STMarkdownBlockLayoutCalculator.swift
index 736e52e..0e8bccc 100644
--- a/Sources/STMarkdown/Rendering/STMarkdownBlockLayoutCalculator.swift
+++ b/Sources/STMarkdown/Rendering/STMarkdownBlockLayoutCalculator.swift
@@ -15,6 +15,9 @@ public enum STMarkdownBlockLayoutCalculator {
before nextBlock: STMarkdownRenderBlock,
style: STMarkdownStyle
) -> CGFloat {
+ if isTableAdjacent(previousBlock: previousBlock, nextBlock: nextBlock) {
+ return style.blockSpacing
+ }
if case .heading = nextBlock {
// Heading 的 paragraphSpacingBefore/paragraphSpacing 已编码了上下留白,
// 分隔符 "\n" 只是终止上一段,minimumLineHeight 对此无视觉效果,取最小值 1pt。
@@ -67,6 +70,18 @@ public enum STMarkdownBlockLayoutCalculator {
}
}
+ public static func isTableAdjacent(
+ previousBlock: STMarkdownRenderBlock,
+ nextBlock: STMarkdownRenderBlock
+ ) -> Bool {
+ switch (previousBlock, nextBlock) {
+ case (.table, _), (_, .table):
+ return true
+ default:
+ return false
+ }
+ }
+
// MARK: - Separator AttributedString
/// 生成两个相邻块之间的间距 `NSAttributedString`(单个 `"\n"`,行高 = 计算所得间距)。
diff --git a/Sources/STMarkdown/Rendering/STMarkdownFullHeightCodeBlockSupport.swift b/Sources/STMarkdown/Rendering/STMarkdownFullHeightCodeBlockSupport.swift
new file mode 100644
index 0000000..993281b
--- /dev/null
+++ b/Sources/STMarkdown/Rendering/STMarkdownFullHeightCodeBlockSupport.swift
@@ -0,0 +1,192 @@
+//
+// STMarkdownFullHeightCodeBlockAttachment.swift
+// STBaseProject
+//
+// Created by 寒江孤影 on 2026/05/26.
+//
+
+import UIKit
+
+/// 全高(不折叠)代码块 attachment。
+/// 与 STMarkdownCodeBlockAttachment(折叠 + 渲染缓存)的核心差异:
+/// - 总是展示完整代码高度,不裁剪
+/// - 不维护折叠状态(isCollapsed 始终为 false)
+/// - 轻量缓存:同 key 命中时直接复用图片,避免流式阶段重复渲染
+public final class STMarkdownFullHeightCodeBlockAttachment: NSTextAttachment {
+ public let language: String?
+ public let code: String
+ public let style: STMarkdownStyle
+ public let headerHeight: CGFloat
+ public let contentInsets: UIEdgeInsets
+
+ private static let renderCache: NSCache = {
+ let cache = NSCache()
+ cache.countLimit = 32
+ return cache
+ }()
+
+ public static func clearRenderCache() {
+ Self.renderCache.removeAllObjects()
+ }
+
+ public init(language: String?, code: String, style: STMarkdownStyle) {
+ self.language = language?.trimmingCharacters(in: .whitespacesAndNewlines)
+ self.code = code
+ self.style = style
+ self.contentInsets = style.codeBlockContentInsets
+ let autoHeight = max(
+ ceil(UIFont.st_monospacedSystemFont(
+ ofSize: max(style.font.pointSize - 2, 12),
+ weight: .semibold
+ ).lineHeight),
+ 18
+ )
+ self.headerHeight = style.codeBlockHeaderHeight > 0 ? style.codeBlockHeaderHeight : autoHeight
+ super.init(data: nil, ofType: nil)
+
+ let cacheKey = Self.cacheKey(language: self.language, code: code, style: style)
+ if let cached = Self.renderCache.object(forKey: cacheKey as NSString) {
+ self.image = cached
+ self.bounds = CGRect(origin: .zero, size: cached.size)
+ return
+ }
+ let image = STMarkdownDynamicHeightCodeBlockRenderer.renderAttachmentImage(
+ language: self.language,
+ code: code,
+ style: style,
+ headerHeight: self.headerHeight,
+ contentInsets: self.contentInsets
+ )
+ Self.renderCache.setObject(image, forKey: cacheKey as NSString)
+ self.image = image
+ self.bounds = CGRect(origin: .zero, size: image.size)
+ }
+
+ public required init?(coder: NSCoder) { nil }
+
+ private static func cacheKey(language: String?, code: String, style: STMarkdownStyle) -> String {
+ let count = code.count
+ let utf8Count = code.utf8.count
+ let prefix = String(code.prefix(32))
+ let suffix = String(code.suffix(32))
+ let fingerprint = "c\(count)_b\(utf8Count)_h\(code.hashValue)_p\(prefix)_s\(suffix)"
+ return [
+ language ?? "",
+ fingerprint,
+ String(format: "%.2f", style.renderWidth),
+ String(format: "%.2f", style.font.pointSize),
+ String(format: "%.2f", style.bodyLineSpacing),
+ ].joined(separator: "|")
+ }
+}
+
+/// 全高代码块渲染器,实现 STMarkdownCodeBlockRendering 协议,可直接注入 STMarkdownAdvancedRenderers.codeBlockRenderer。
+public struct STMarkdownDynamicHeightCodeBlockRenderer: STMarkdownCodeBlockRendering {
+ public init() {}
+
+ public func renderCodeBlock(language: String?, code: String, style: STMarkdownStyle) -> NSAttributedString? {
+ NSAttributedString(attachment: STMarkdownFullHeightCodeBlockAttachment(language: language, code: code, style: style))
+ }
+
+ public static func renderAttachmentImage(
+ language: String?,
+ code: String,
+ style: STMarkdownStyle,
+ headerHeight: CGFloat,
+ contentInsets: UIEdgeInsets
+ ) -> UIImage {
+ let blockWidth = max(
+ style.renderWidth > 0
+ ? style.renderWidth
+ : (280 + style.codeBlockContentInsets.left + style.codeBlockContentInsets.right),
+ 1
+ )
+ let contentWidth = max(blockWidth - contentInsets.left - contentInsets.right, 1)
+ let backgroundColor = style.codeBlockBackgroundColor ?? UIColor.secondarySystemBackground
+ let borderColor = style.codeBlockBorderColor ?? UIColor.separator
+ let headerColor = style.codeBlockHeaderTextColor ?? style.textColor.withAlphaComponent(0.72)
+ let headerFont = UIFont.st_monospacedSystemFont(
+ ofSize: max(style.font.pointSize - 2, 12),
+ weight: .semibold
+ )
+ let codeFont = UIFont.st_monospacedSystemFont(
+ ofSize: max(style.font.pointSize - 1, 12),
+ weight: .regular
+ )
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.lineBreakMode = .byCharWrapping
+ paragraphStyle.lineSpacing = max(style.bodyLineSpacing, 2)
+ let highlightedBody = STMarkdownCodeSyntaxHighlighter.highlightedBody(
+ language: language,
+ code: code,
+ font: codeFont,
+ textColor: style.codeBlockTextColor ?? style.textColor,
+ paragraphStyle: paragraphStyle
+ )
+ let bodyHeight = max(
+ ceil(highlightedBody.boundingRect(
+ with: CGSize(width: contentWidth, height: .greatestFiniteMagnitude),
+ options: [.usesLineFragmentOrigin, .usesFontLeading],
+ context: nil
+ ).height),
+ ceil(codeFont.lineHeight)
+ )
+ let separatorSpacing = style.codeBlockSeparatorSpacing
+ let buttonRowReservedWidth = style.codeBlockButtonRowReservedWidth
+ let blockHeight = contentInsets.top + headerHeight + separatorSpacing + bodyHeight + contentInsets.bottom
+ let format = UIGraphicsImageRendererFormat.default()
+ format.scale = style.resolvedDisplayScale
+ return UIGraphicsImageRenderer(
+ size: CGSize(width: blockWidth, height: blockHeight),
+ format: format
+ ).image { context in
+ let cgContext = context.cgContext
+ let rect = CGRect(x: 0, y: 0, width: blockWidth, height: blockHeight)
+ let path = UIBezierPath(roundedRect: rect, cornerRadius: style.codeBlockCornerRadius)
+ backgroundColor.setFill()
+ path.fill()
+ if style.codeBlockBorderWidth > 0 {
+ borderColor.setStroke()
+ path.lineWidth = style.codeBlockBorderWidth
+ path.stroke()
+ }
+ let headerText = (language?.isEmpty == false ? language?.uppercased() : "CODE") ?? "CODE"
+ let headerTextSize = (headerText as NSString).size(withAttributes: [.font: headerFont])
+ let headerTextY = contentInsets.top + max((headerHeight - headerTextSize.height) / 2, 0)
+ let headerRect = CGRect(
+ x: contentInsets.left,
+ y: headerTextY,
+ width: max(contentWidth - buttonRowReservedWidth, 1),
+ height: headerTextSize.height
+ )
+ (headerText as NSString).draw(
+ in: headerRect,
+ withAttributes: [.font: headerFont, .foregroundColor: headerColor]
+ )
+ let separatorRect = CGRect(
+ x: contentInsets.left,
+ y: contentInsets.top + headerHeight + separatorSpacing / 2,
+ width: contentWidth,
+ height: 1
+ )
+ cgContext.setFillColor(
+ (style.horizontalRuleColor ?? UIColor.separator).withAlphaComponent(0.35).cgColor
+ )
+ cgContext.fill(separatorRect)
+ let codeRect = CGRect(
+ x: contentInsets.left,
+ y: contentInsets.top + headerHeight + separatorSpacing,
+ width: contentWidth,
+ height: bodyHeight
+ )
+ cgContext.saveGState()
+ cgContext.clip(to: codeRect)
+ highlightedBody.draw(
+ with: codeRect,
+ options: [.usesLineFragmentOrigin, .usesFontLeading],
+ context: nil
+ )
+ cgContext.restoreGState()
+ }
+ }
+}
diff --git a/Sources/STMarkdown/Rendering/STMarkdownHTMLPreviewDocumentBuilder.swift b/Sources/STMarkdown/Rendering/STMarkdownHTMLPreviewDocumentBuilder.swift
new file mode 100644
index 0000000..53ae3fc
--- /dev/null
+++ b/Sources/STMarkdown/Rendering/STMarkdownHTMLPreviewDocumentBuilder.swift
@@ -0,0 +1,79 @@
+//
+// STMarkdownHTMLPreviewDocumentBuilder.swift
+// STBaseProject
+//
+// Created by 寒江孤影 on 2026/05/26.
+//
+
+import UIKit
+
+/// HTML 片段预览文档包装器。负责将裸 HTML fragment 包裹成完整可渲染的 HTML 文档,
+/// 注入背景色、文字色、monospace 样式与内容宽度约束。
+/// 不含任何宿主业务逻辑(分享、导航、主题系统)。
+public enum STMarkdownHTMLPreviewDocumentBuilder {
+ /// 将 HTML fragment 包裹为完整 HTML 文档。
+ /// - Parameters:
+ /// - fragment: 待包裹的原始 HTML 片段。
+ /// - contentWidth: 文档 body 最大宽度(px),由宿主容器尺寸决定。
+ /// - style: 提供背景色、文字色等样式参数;为 nil 时使用系统默认色。
+ /// - backgroundColorFallback: style.codeBlockBackgroundColor 为 nil 时使用的兜底背景色,默认为系统次背景色。
+ public static func wrappedHTMLDocument(
+ fragment: String,
+ contentWidth: CGFloat,
+ style: STMarkdownStyle?,
+ backgroundColorFallback: UIColor = .secondarySystemBackground
+ ) -> String {
+ let bg = rgbString(from: style?.codeBlockBackgroundColor ?? backgroundColorFallback)
+ let fg = rgbString(from: style?.codeBlockTextColor ?? style?.textColor ?? UIColor.label)
+ let muted = rgbString(
+ from: style?.codeBlockHeaderTextColor
+ ?? (style?.textColor ?? UIColor.label).withAlphaComponent(0.72)
+ )
+ return """
+
+
+
+
+
+
+
+
+ \(fragment)
+
+
+ """
+ }
+
+ private static func rgbString(from color: UIColor) -> String {
+ let c = color.cgColor
+ guard let components = c.components, components.count >= 3 else {
+ return "rgb(128,128,128)"
+ }
+ let r = Int((components[0] * 255).rounded())
+ let g = Int((components[1] * 255).rounded())
+ let b = Int((components[2] * 255).rounded())
+ return "rgb(\(r),\(g),\(b))"
+ }
+}
diff --git a/Sources/STMarkdown/Rendering/STMarkdownPlainTextRenderer.swift b/Sources/STMarkdown/Rendering/STMarkdownPlainTextRenderer.swift
new file mode 100644
index 0000000..eb323a6
--- /dev/null
+++ b/Sources/STMarkdown/Rendering/STMarkdownPlainTextRenderer.swift
@@ -0,0 +1,161 @@
+import Foundation
+
+public enum STMarkdownPlainTextRenderer {
+
+ private static let plainTextParser = STMarkdownStructureParser()
+
+ public static func makeDeferredPlainTextActivePresentation(
+ from text: String,
+ kind: STMarkdownStreamingBlockKind
+ ) -> String {
+ guard !text.isEmpty else { return "" }
+ let prepared = Self.prepareActivePlainTextMarkdown(text, kind: kind)
+ guard !prepared.isEmpty else { return "" }
+ let document = Self.plainTextParser.parse(prepared)
+ let rendered = Self.renderPlainText(document.blocks)
+ guard !rendered.isEmpty else {
+ return Self.fallbackPlainText(from: prepared, kind: kind)
+ }
+ return rendered
+ }
+
+ private static func prepareActivePlainTextMarkdown(
+ _ text: String,
+ kind: STMarkdownStreamingBlockKind
+ ) -> String {
+ guard !text.isEmpty else { return "" }
+ var candidate = STMarkdownStreamingTransforms.trimTrailingIncompleteCitationTags(in: text)
+ candidate = STMarkdownStreamingTransforms.trimIncompleteTrailingMarkdownSyntax(in: candidate)
+ candidate = STMarkdownStreamingTransforms.trimTrailingIncompleteHtmlTag(in: candidate)
+ candidate = STMarkdownStreamingTransforms.sanitizeDanglingInlineMarkdownFragments(in: candidate)
+ candidate = STMarkdownStreamingTransforms.trimIncompleteTrailingEmphasis(in: candidate)
+ candidate = STMarkdownStreamingTransforms.autoCloseTrailingInlineCode(in: candidate)
+ if kind == .list {
+ candidate = STMarkdownStreamingTransforms.softenTrailingListLeadingDanglingEmphasis(in: candidate)
+ }
+ return candidate.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private static func fallbackPlainText(
+ from text: String,
+ kind: STMarkdownStreamingBlockKind
+ ) -> String {
+ var flattened = STMarkdownStreamingTransforms.flattenStreamingBlockSyntax(in: text)
+ if kind == .list {
+ flattened = STMarkdownStreamingTransforms.flattenStreamingListSyntax(in: flattened)
+ }
+ flattened = flattened.replacingOccurrences(of: "**", with: "")
+ flattened = flattened.replacingOccurrences(of: "__", with: "")
+ flattened = flattened.replacingOccurrences(of: "~~", with: "")
+ flattened = flattened.replacingOccurrences(of: "`", with: "")
+ flattened = flattened.replacingOccurrences(of: "*", with: "")
+ flattened = flattened.replacingOccurrences(of: "_", with: "")
+ return STMarkdownStreamingTransforms.sanitizeDanglingInlineMarkdownFragments(in: flattened)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private static func renderPlainText(_ blocks: [STMarkdownBlockNode], quoteDepth: Int = 0) -> String {
+ guard !blocks.isEmpty else { return "" }
+ var parts: [String] = []
+ for block in blocks {
+ let rendered = Self.renderPlainText(block, quoteDepth: quoteDepth)
+ if !rendered.isEmpty {
+ parts.append(rendered)
+ }
+ }
+ return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private static func renderPlainText(_ block: STMarkdownBlockNode, quoteDepth: Int) -> String {
+ switch block {
+ case .paragraph(let inlines):
+ return Self.quotePrefix(depth: quoteDepth) + Self.renderPlainText(inlines)
+ case .heading(_, let content):
+ return Self.quotePrefix(depth: quoteDepth) + Self.renderPlainText(content)
+ case .quote(let blocks):
+ return Self.renderPlainText(blocks, quoteDepth: quoteDepth + 1)
+ case .list(let kind, let items):
+ return Self.renderPlainTextList(kind: kind, items: items, quoteDepth: quoteDepth)
+ case .codeBlock(_, let code):
+ let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return "" }
+ return Self.quotePrefix(depth: quoteDepth) + trimmed
+ case .thematicBreak:
+ return ""
+ case .details(let summary, let body):
+ let summaryText = Self.renderPlainText(summary)
+ let bodyText = Self.renderPlainText(body, quoteDepth: quoteDepth)
+ return [summaryText, bodyText]
+ .filter { !$0.isEmpty }
+ .joined(separator: "\n")
+ case .rawHTML(let html):
+ let trimmed = html.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return "" }
+ return Self.quotePrefix(depth: quoteDepth) + trimmed
+ case .image(_, let altText, _):
+ return Self.quotePrefix(depth: quoteDepth) + altText
+ case .table, .mathBlock:
+ return ""
+ }
+ }
+
+ private static func renderPlainTextList(
+ kind: STMarkdownListKind,
+ items: [STMarkdownListItemNode],
+ quoteDepth: Int
+ ) -> String {
+ guard !items.isEmpty else { return "" }
+ var lines: [String] = []
+ for (index, item) in items.enumerated() {
+ let marker: String
+ switch kind {
+ case .unordered:
+ marker = quoteDepth > 0 ? "◦" : "•"
+ case .ordered(let startIndex):
+ marker = "\(startIndex + index))"
+ }
+ let itemBody = Self.renderPlainText(item.blocks, quoteDepth: quoteDepth)
+ let itemLines = itemBody.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ guard let firstLine = itemLines.first, !firstLine.isEmpty else { continue }
+ lines.append("\(marker) \(firstLine)")
+ for continuation in itemLines.dropFirst() where !continuation.isEmpty {
+ lines.append("\(Self.quotePrefix(depth: quoteDepth))\(continuation)")
+ }
+ }
+ return lines.joined(separator: "\n")
+ }
+
+ private static func renderPlainText(_ inlines: [STMarkdownInlineNode]) -> String {
+ var output = ""
+ for inline in inlines {
+ switch inline {
+ case .text(let text):
+ output += text
+ case .inlineMath(let text, _):
+ output += text
+ case .emphasis(let children),
+ .strong(let children),
+ .strikethrough(let children):
+ output += Self.renderPlainText(children)
+ case .code(let code):
+ output += code
+ case .link(_, let children):
+ output += Self.renderPlainText(children)
+ case .image(_, let alt, _):
+ output += alt
+ case .softBreak:
+ output += "\n"
+ case .footnoteReference(let label):
+ output += "[\(label)]"
+ case .inlineRawHTML(let html):
+ output += html
+ }
+ }
+ return output.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private static func quotePrefix(depth: Int) -> String {
+ guard depth > 0 else { return "" }
+ return String(repeating: "│ ", count: depth)
+ }
+}
diff --git a/Sources/STMarkdown/Rendering/STMarkdownStreamingPresentationHelpers.swift b/Sources/STMarkdown/Rendering/STMarkdownStreamingPresentationHelpers.swift
new file mode 100644
index 0000000..1e55fc3
--- /dev/null
+++ b/Sources/STMarkdown/Rendering/STMarkdownStreamingPresentationHelpers.swift
@@ -0,0 +1,375 @@
+import Foundation
+
+/// Generic stateless text-stabilization helpers for streaming markdown rendering.
+/// Contains pure text-transformation utilities that have no app-specific state or
+/// business logic. Depends only on other STMarkdown types.
+public enum STMarkdownStreamingTextStabilizer {
+
+ // MARK: - List structure detection
+
+ public static func isListLine(_ trimmedLine: String) -> Bool {
+ if trimmedLine.hasPrefix("- ")
+ || trimmedLine.hasPrefix("+ ")
+ || trimmedLine.hasPrefix("* ")
+ || trimmedLine.hasPrefix("• ")
+ || trimmedLine.hasPrefix("◦ ")
+ || trimmedLine.hasPrefix("▪ ") {
+ return true
+ }
+ return trimmedLine.range(of: #"^\d+(?:\.|[))])\s+"#, options: .regularExpression) != nil
+ }
+
+ public static func endsWithOrderedListLine(_ text: String) -> Bool {
+ let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ guard let lastNonEmpty = lines.last(where: {
+ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }) else {
+ return false
+ }
+ let trimmed = lastNonEmpty.trimmingCharacters(in: .whitespaces)
+ // Check both "1. " (standard) and "1) " (flattened-streaming form from flattenStreamingListSyntax)
+ for separator: Character in [".", ")"] {
+ guard let sepIndex = trimmed.firstIndex(of: separator),
+ sepIndex > trimmed.startIndex else {
+ continue
+ }
+ let digits = trimmed[.. String {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty, Self.isListLine(trimmed) else { return line }
+ let markers = ["~~", "**", "__"]
+ var updated = line
+ for marker in markers {
+ if Self.countUnescapedOccurrences(of: marker, in: updated) % 2 != 0 {
+ if updated.hasSuffix(marker) {
+ updated = String(updated.dropLast(marker.count))
+ } else if marker.count > 1 {
+ let unit = String(marker.prefix(1))
+ if updated.hasSuffix(unit) {
+ updated = String(updated.dropLast(unit.count))
+ }
+ }
+ }
+ }
+ return updated
+ }
+
+ // MARK: - Stable preview helpers
+
+ public static func committedLinePrefix(in text: String) -> String {
+ guard let lastNewline = text.lastIndex(of: "\n") else { return "" }
+ return String(text[.. String.Index? {
+ let closingCharacters = CharacterSet(charactersIn: "\"\u{2018}\u{2019}\u{201C}\u{201D}\u{FF09})]】」』 ")
+ var index = text.endIndex
+ while index > text.startIndex {
+ let previous = text.index(before: index)
+ let character = text[previous]
+ if "。!?!?;;::\n".contains(character) {
+ var boundary = text.index(after: previous)
+ while boundary < text.endIndex,
+ let scalar = text[boundary].unicodeScalars.first,
+ closingCharacters.contains(scalar) {
+ boundary = text.index(after: boundary)
+ }
+ return boundary
+ }
+ index = previous
+ }
+ return nil
+ }
+
+ public static func containsPotentiallyUnstableMarkdownSyntax(_ text: String) -> Bool {
+ guard !text.isEmpty else { return false }
+ if text.contains("[") || text.contains("|") || text.contains("`") {
+ return true
+ }
+ if text.contains("**") || text.contains("__") || text.contains("~~") {
+ return true
+ }
+ if text.hasPrefix("#") || text.hasPrefix(">") {
+ return true
+ }
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ if ["*", "-", "+"].contains(trimmed) {
+ return true
+ }
+ let orderedRange = NSRange(location: 0, length: trimmed.utf16.count)
+ if STMarkdownStreamingRegex.streamingPartialOrderedListMarker.firstMatch(
+ in: trimmed, options: [], range: orderedRange
+ ) != nil {
+ return true
+ }
+ return false
+ }
+
+ public static func makeStableParagraphPreview(from text: String) -> String {
+ guard !text.isEmpty else { return "" }
+ let stabilized = STMarkdownStreamingTransforms.stabilizeStreamingPresentationTail(in: text)
+ if Self.containsPotentiallyUnstableMarkdownSyntax(stabilized) {
+ if let boundary = Self.lastSentenceBoundary(in: stabilized) {
+ return String(stabilized[.. String {
+ guard !text.isEmpty else { return "" }
+ let stabilized = STMarkdownStreamingTransforms.stabilizeStreamingPresentationTail(in: text)
+ if !Self.containsPotentiallyUnstableMarkdownSyntax(stabilized) {
+ return stabilized
+ }
+ return Self.committedLinePrefix(in: stabilized)
+ }
+
+ public static func makeStableQuotedPreview(from text: String) -> String {
+ guard !text.isEmpty else { return "" }
+ let stabilized = STMarkdownStreamingTransforms.stabilizeStreamingPresentationTail(in: text)
+ if !Self.containsPotentiallyUnstableMarkdownSyntax(stabilized) {
+ return stabilized
+ }
+ return Self.committedLinePrefix(in: stabilized)
+ }
+
+ public static func makeStableSingleLineBlockPreview(from text: String) -> String {
+ guard !text.isEmpty else { return "" }
+ let stabilized = STMarkdownStreamingTransforms.stabilizeStreamingPresentationTail(in: text)
+ if stabilized.hasSuffix("\n") {
+ return stabilized.trimmingCharacters(in: .newlines)
+ }
+ return Self.containsPotentiallyUnstableMarkdownSyntax(stabilized) ? "" : stabilized
+ }
+
+ // MARK: - Table helpers
+
+ public static func isLikelyStreamingTableHeaderCandidate(_ line: String) -> Bool {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else { return false }
+ let pipeCount = trimmed.filter { $0 == "|" }.count
+ guard pipeCount >= 2 else { return false }
+ if trimmed.hasPrefix("|") || trimmed.hasSuffix("|") {
+ return true
+ }
+ let cells = trimmed
+ .split(separator: "|", omittingEmptySubsequences: false)
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .filter { !$0.isEmpty }
+ guard cells.count >= 2 else { return false }
+ let hasSentencePunctuation = cells.contains { cell in
+ cell.contains("。") || cell.contains(",") || cell.contains(";") || cell.contains(":")
+ }
+ let maxCellLength = cells.map(\.count).max() ?? 0
+ return !hasSentencePunctuation && maxCellLength <= 24
+ }
+
+ public static func containsLikelyTableSyntax(in lines: [String]) -> Bool {
+ for line in lines {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.isEmpty { continue }
+ let pipeCount = trimmed.filter { $0 == "|" }.count
+ if pipeCount >= 2 || trimmed.hasPrefix("|") {
+ return true
+ }
+ }
+ return false
+ }
+
+ public static func makeStreamingTablePresentation(from text: String) -> String {
+ guard !text.isEmpty else { return "" }
+ let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ guard !lines.isEmpty else { return "" }
+ guard lines.count >= 2 else { return "" }
+ let separator = lines[1].trimmingCharacters(in: .whitespaces)
+ let nonSepChars = separator.filter { $0 != "|" && $0 != "-" && $0 != ":" && $0 != " " && $0 != "\t" }
+ guard nonSepChars.isEmpty, separator.contains("--") else { return "" }
+
+ var validLines: [String] = []
+ validLines.append(lines[0])
+ validLines.append(lines[1])
+ for row in lines.dropFirst(2) {
+ let trimmed = row.trimmingCharacters(in: .whitespaces)
+ let pipeCount = trimmed.filter { $0 == "|" }.count
+ if pipeCount >= 2 {
+ validLines.append(row)
+ }
+ }
+ guard validLines.count >= 2 else { return "" }
+
+ if validLines.count == 2 {
+ let colCount = max(lines[0].filter({ $0 == "|" }).count - 1, 1)
+ let emptyCells = Array(repeating: " ", count: colCount)
+ validLines.append("| " + emptyCells.joined(separator: " | ") + " |")
+ }
+
+ let lastIdx = validLines.count - 1
+ if lastIdx >= 2 {
+ validLines[lastIdx] = Self.autoCloseEmphasisInTableRow(validLines[lastIdx])
+ }
+ return validLines.joined(separator: "\n")
+ }
+
+ public static func autoCloseEmphasisInTableRow(_ row: String) -> String {
+ let parts = row.split(separator: "|", omittingEmptySubsequences: false).map(String.init)
+ guard parts.count >= 3 else { return row }
+ var result: [String] = []
+ for (index, part) in parts.enumerated() {
+ if index == 0 || index == parts.count - 1 {
+ result.append(part)
+ continue
+ }
+ result.append(Self.autoCloseEmphasisInCellContent(part))
+ }
+ return result.joined(separator: "|")
+ }
+
+ public static func autoCloseEmphasisInCellContent(_ cell: String) -> String {
+ var text = cell
+ let markers: [(String, Int)] = [("~~", 2), ("**", 2), ("__", 2), ("*", 1), ("_", 1)]
+ for (marker, _) in markers {
+ var count = 0
+ var searchRange = text.startIndex.. String {
+ guard !text.isEmpty else { return text }
+ var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ guard let lastNonEmptyIndex = lines.lastIndex(where: {
+ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }) else {
+ return text
+ }
+ let line = lines[lastNonEmptyIndex]
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else { return text }
+ guard !trimmed.contains("|") else { return text }
+ let nsRange = NSRange(location: 0, length: trimmed.utf16.count)
+ let isStandaloneStrongLine = trimmed.hasPrefix("**")
+ let isHeadingStrongLine = Self.headingStrongLineRegex.firstMatch(
+ in: trimmed, options: [], range: nsRange
+ ) != nil
+ guard isStandaloneStrongLine || isHeadingStrongLine else { return text }
+ guard !trimmed.hasSuffix("**") else { return text }
+ guard Self.countUnescapedOccurrences(of: "**", in: trimmed) == 1 else { return text }
+ let closingSuffix = line.hasSuffix("*") ? "*" : "**"
+ lines[lastNonEmptyIndex] = line + closingSuffix
+ return lines.joined(separator: "\n")
+ }
+
+ public static func countUnescapedOccurrences(of token: String, in text: String) -> Int {
+ guard !token.isEmpty else { return 0 }
+ var count = 0
+ var searchStart = text.startIndex
+ while let range = text.range(of: token, range: searchStart.. text.startIndex
+ && text[text.index(before: range.lowerBound)] == "\\"
+ if !escaped { count += 1 }
+ searchStart = range.upperBound
+ }
+ return count
+ }
+
+ /// 在单行中检测最后一个未配对的 marker 并截断。
+ public static func trimUnpairedTrailingMarker(in line: String, marker: String, markerLen: Int) -> String {
+ var positions: [String.Index] = []
+
+ if markerLen == 1 {
+ let markerChar = marker.first!
+ var i = line.startIndex
+ while i < line.endIndex {
+ if line[i] == markerChar {
+ let runStart = i
+ var runLength = 0
+ var j = i
+ while j < line.endIndex, line[j] == markerChar {
+ runLength += 1
+ j = line.index(after: j)
+ }
+ if runLength % 2 == 1 {
+ var isListBullet = false
+ if runLength == 1, marker == "*" || marker == "-" || marker == "+" {
+ let lineHead: String.Index
+ if let prevNewline = line[line.startIndex..[ \t]?(.*)$"#,
+ options: []
+ )
+}
diff --git a/Sources/STMarkdown/Table/STMarkdownTableView.swift b/Sources/STMarkdown/Table/STMarkdownTableView.swift
index e21ad54..355224b 100644
--- a/Sources/STMarkdown/Table/STMarkdownTableView.swift
+++ b/Sources/STMarkdown/Table/STMarkdownTableView.swift
@@ -27,11 +27,7 @@ public final class STMarkdownTableView: UIView {
private let gridLayout: STMarkdownTableGridLayout
private let collectionView: UICollectionView
- private let leftGradientLayer = CAGradientLayer()
- private let rightGradientLayer = CAGradientLayer()
private let cellInsets = UIEdgeInsets(top: 8, left: 10, bottom: 8, right: 10)
- private let gradientOverlayWidth: CGFloat = 24
- private let gradientVisibilityThreshold: CGFloat = 1
public init(style: STMarkdownStyle) {
self.style = style
@@ -39,7 +35,6 @@ public final class STMarkdownTableView: UIView {
self.collectionView = UICollectionView(frame: .zero, collectionViewLayout: self.gridLayout)
super.init(frame: .zero)
self.setupCollectionView()
- self.setupGradientLayers()
self.applyStyle()
}
@@ -69,30 +64,12 @@ public final class STMarkdownTableView: UIView {
}
}
- private func setupGradientLayers() {
- self.leftGradientLayer.name = "STMarkdownTableLeftGradient"
- self.leftGradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
- self.leftGradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
- self.leftGradientLayer.opacity = 0
-
- self.rightGradientLayer.name = "STMarkdownTableRightGradient"
- self.rightGradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
- self.rightGradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
- self.rightGradientLayer.opacity = 0
-
- self.layer.addSublayer(self.leftGradientLayer)
- self.layer.addSublayer(self.rightGradientLayer)
- }
-
private func applyStyle() {
let borderColor = self.style.tableBorderColor ?? UIColor.separator
self.collectionView.backgroundColor = borderColor
self.backgroundColor = borderColor
self.gridLayout.interItemSpacing = 0.5
self.gridLayout.lineSpacing = 0.5
- let overlayColor = (self.style.tableBackgroundColor ?? UIColor.secondarySystemBackground).cgColor
- self.leftGradientLayer.colors = [overlayColor, UIColor.clear.cgColor]
- self.rightGradientLayer.colors = [UIColor.clear.cgColor, overlayColor]
}
public override func layoutSubviews() {
@@ -100,8 +77,6 @@ public final class STMarkdownTableView: UIView {
if self.collectionView.frame != self.bounds {
self.collectionView.frame = self.bounds
}
- self.layoutGradientLayers()
- self.updateHorizontalScrollHints()
}
public override func sizeThatFits(_ size: CGSize) -> CGSize {
@@ -163,39 +138,6 @@ public final class STMarkdownTableView: UIView {
)
}
- private func layoutGradientLayers() {
- guard self.bounds.width > 0, self.bounds.height > 0 else { return }
- self.leftGradientLayer.frame = CGRect(
- x: 0,
- y: 0,
- width: self.gradientOverlayWidth,
- height: self.bounds.height
- )
- self.rightGradientLayer.frame = CGRect(
- x: self.bounds.width - self.gradientOverlayWidth,
- y: 0,
- width: self.gradientOverlayWidth,
- height: self.bounds.height
- )
- }
-
- private func updateHorizontalScrollHints() {
- self.collectionView.layoutIfNeeded()
- let visibleWidth = self.collectionView.bounds.width
- let scrollableWidth = self.collectionView.contentSize.width
- let maxOffsetX = max(0, scrollableWidth - visibleWidth)
-
- guard visibleWidth > 0, maxOffsetX > self.gradientVisibilityThreshold else {
- self.leftGradientLayer.opacity = 0
- self.rightGradientLayer.opacity = 0
- return
- }
-
- let offsetX = min(max(self.collectionView.contentOffset.x, 0), maxOffsetX)
- self.leftGradientLayer.opacity = offsetX > self.gradientVisibilityThreshold ? 1 : 0
- self.rightGradientLayer.opacity = offsetX < (maxOffsetX - self.gradientVisibilityThreshold) ? 1 : 0
- }
-
@objc private func handleExpandGesture(_ gestureRecognizer: UILongPressGestureRecognizer) {
guard gestureRecognizer.state == .began else { return }
self.expandTableIfPossible()
@@ -208,10 +150,6 @@ public final class STMarkdownTableView: UIView {
}
extension STMarkdownTableView: UICollectionViewDelegate {
- public func scrollViewDidScroll(_ scrollView: UIScrollView) {
- self.updateHorizontalScrollHints()
- }
-
public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: false)
guard let tableData,
diff --git a/Sources/STMarkdown/UI/STMarkdownLineNumberDrawView.swift b/Sources/STMarkdown/UI/STMarkdownLineNumberDrawView.swift
new file mode 100644
index 0000000..5c34b20
--- /dev/null
+++ b/Sources/STMarkdown/UI/STMarkdownLineNumberDrawView.swift
@@ -0,0 +1,96 @@
+//
+// STMarkdownLineNumberDrawView.swift
+// STBaseProject
+//
+// Created by 寒江孤影 on 2026/05/26.
+//
+
+import UIKit
+
+public struct STMarkdownLineNumberEntry {
+ public let text: String
+ public let y: CGFloat
+ public init(text: String, y: CGFloat) {
+ self.text = text
+ self.y = y
+ }
+}
+
+/// 行号绘制视图,通过 `update(entries:font:color:rightInset:)` 驱动,无主题系统依赖。
+public final class STMarkdownLineNumberDrawView: UIView {
+ private var entries: [STMarkdownLineNumberEntry] = []
+ private var drawFont: UIFont = UIFont.st_monospacedSystemFont(ofSize: 12, weight: .regular)
+ private var drawColor: UIColor = .systemGray
+ private var rightInset: CGFloat = 6
+ private var lineHeight: CGFloat = UIFont.st_monospacedSystemFont(ofSize: 12, weight: .regular).lineHeight
+
+ public override init(frame: CGRect) {
+ super.init(frame: frame)
+ self.isOpaque = false
+ self.contentMode = .redraw
+ }
+
+ public required init?(coder: NSCoder) { nil }
+
+ public func update(
+ entries: [STMarkdownLineNumberEntry],
+ font: UIFont,
+ color: UIColor,
+ rightInset: CGFloat
+ ) {
+ self.entries = entries
+ self.drawFont = font
+ self.drawColor = color
+ self.rightInset = rightInset
+ self.lineHeight = max(font.lineHeight, 1)
+ self.setNeedsDisplay()
+ }
+
+ public override func draw(_ rect: CGRect) {
+ self.drawEntries(in: rect)
+ }
+
+ public func drawEntries(in rect: CGRect) {
+ guard !self.entries.isEmpty else { return }
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.alignment = .right
+ paragraphStyle.lineBreakMode = .byClipping
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: self.drawFont,
+ .foregroundColor: self.drawColor,
+ .paragraphStyle: paragraphStyle,
+ ]
+ for entry in self.entries {
+ let alignedY = self.alignToPixel(entry.y)
+ let lineRect = CGRect(
+ x: 0,
+ y: alignedY,
+ width: self.alignToPixel(max(self.bounds.width - self.rightInset, 1)),
+ height: self.alignToPixel(self.lineHeight)
+ )
+ if lineRect.maxY < rect.minY || lineRect.minY > rect.maxY { continue }
+ (entry.text as NSString).draw(in: lineRect, withAttributes: attributes)
+ }
+ }
+
+ public func render(in context: CGContext, bounds: CGRect) {
+ context.saveGState()
+ context.translateBy(
+ x: self.alignToPixel(bounds.minX),
+ y: self.alignToPixel(bounds.minY)
+ )
+ self.drawEntries(in: CGRect(
+ origin: .zero,
+ size: CGSize(
+ width: self.alignToPixel(bounds.size.width),
+ height: self.alignToPixel(bounds.size.height)
+ )
+ ))
+ context.restoreGState()
+ }
+
+ private func alignToPixel(_ value: CGFloat) -> CGFloat {
+ let scale = max(UIScreen.main.scale, 1)
+ return (value * scale).rounded(.toNearestOrAwayFromZero) / scale
+ }
+}
diff --git a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift
index 5121e43..5019657 100644
--- a/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift
+++ b/Sources/STMarkdown/UI/STMarkdownStreamingTextView.swift
@@ -1005,7 +1005,7 @@ public final class STMarkdownStreamingTextView: STMarkdownBaseTextView {
)
private static let unorderedListMarkerRegex = try! NSRegularExpression(
- pattern: #"(?m)(?:^|\n)\t*[●▪]\t"#,
+ pattern: #"(?m)(?:^|\n)\t*[●○▪]\t"#,
options: []
)
diff --git a/Sources/STUIKit/STButton/STBtn.swift b/Sources/STUIKit/STButton/STBtn.swift
index 9ec342f..40d1c35 100644
--- a/Sources/STUIKit/STButton/STBtn.swift
+++ b/Sources/STUIKit/STButton/STBtn.swift
@@ -358,6 +358,12 @@ open class STBtn: UIButton {
/// 子类(如 `STIconBtn`)覆写此方法以写入图文布局 / `contentInsets` 等 Configuration 字段。
/// 调用时机:每次 `configurationUpdateHandler` 触发,在字体/状态 transformer 之后、`onConfigurationUpdate` 之前。
open func refineButtonConfiguration(_ button: UIButton, configuration config: inout UIButton.Configuration) {
+ // iOS 26 changed UIButton.Configuration.plain() defaults: non-nil symbol config
+ // and non-zero content insets. Both are cleared here so custom PNG images render
+ // at their natural size. Callers can restore via onConfigurationUpdate.
+ config.preferredSymbolConfigurationForImage = nil
+ config.contentInsets = .zero
+ config.imagePadding = 0
config.background.cornerRadius = self.layer.cornerRadius
let resolvedBackgroundColor = self.resolvedStateBackgroundColor(for: button.state)
if self.suppressesSystemStateEffects {
diff --git a/Sources/STUIKit/STButton/STIconBtn.swift b/Sources/STUIKit/STButton/STIconBtn.swift
index 5de890f..e471bbf 100644
--- a/Sources/STUIKit/STButton/STIconBtn.swift
+++ b/Sources/STUIKit/STButton/STIconBtn.swift
@@ -173,6 +173,8 @@ open class STIconBtn: STBtn {
}
open override func refineButtonConfiguration(_ button: UIButton, configuration config: inout UIButton.Configuration) {
+ super.refineButtonConfiguration(button, configuration: &config)
+
let icon = self.iconContentInsets
// `iconContentInsets` 以绝对值语义写入 `config.contentInsets`。
// 如需在此之上额外叠加自定义 padding,请通过 `onConfigurationUpdate` 修改传入的 `config.contentInsets`;
@@ -197,7 +199,5 @@ open class STIconBtn: STBtn {
config.imagePlacement = .bottom
}
config.imagePadding = (hasImage && hasTitle) ? self.spacing : 0
-
- super.refineButtonConfiguration(button, configuration: &config)
}
}
diff --git a/Sources/STUIKit/STTabBar/STTabBarConfig.swift b/Sources/STUIKit/STTabBar/STTabBarConfig.swift
index 90f4abe..4fe56d4 100644
--- a/Sources/STUIKit/STTabBar/STTabBarConfig.swift
+++ b/Sources/STUIKit/STTabBar/STTabBarConfig.swift
@@ -39,6 +39,10 @@ public struct STTabBarConfig {
public var selectedScale: CGFloat
/// 未选中项透明度
public var unselectedAlpha: CGFloat
+ /// 图文布局区域顶部偏移(从 TabBar 顶部算起,通常等于背景图视觉内容区上沿距顶距离;0 = 全高居中)
+ public var itemLayoutAreaTopInset: CGFloat
+ /// 图文布局区域底部边距(从 TabBar 底部算起,通常等于背景图视觉内容区下沿距底距离;0 = 全高居中)
+ public var itemLayoutAreaBottomInset: CGFloat
public init(
backgroundColor: UIColor = .systemBackground,
@@ -55,7 +59,9 @@ public struct STTabBarConfig {
enableAnimation: Bool = true,
animationDuration: TimeInterval = 0.3,
selectedScale: CGFloat = 1.1,
- unselectedAlpha: CGFloat = 0.7
+ unselectedAlpha: CGFloat = 0.7,
+ itemLayoutAreaTopInset: CGFloat = 0,
+ itemLayoutAreaBottomInset: CGFloat = 0
) {
self.backgroundColor = backgroundColor
self.backgroundImage = backgroundImage
@@ -72,5 +78,7 @@ public struct STTabBarConfig {
self.animationDuration = animationDuration
self.selectedScale = selectedScale
self.unselectedAlpha = unselectedAlpha
+ self.itemLayoutAreaTopInset = itemLayoutAreaTopInset
+ self.itemLayoutAreaBottomInset = itemLayoutAreaBottomInset
}
}
diff --git a/Sources/STUIKit/STTabBar/STTabBarItemView.swift b/Sources/STUIKit/STTabBar/STTabBarItemView.swift
index a031675..fc0b8b6 100644
--- a/Sources/STUIKit/STTabBar/STTabBarItemView.swift
+++ b/Sources/STUIKit/STTabBar/STTabBarItemView.swift
@@ -76,8 +76,8 @@ public class STTabBarItemView: UIView {
self.titleLabel.topAnchor.constraint(equalTo: self.iconImageView.bottomAnchor, constant: 2),
self.titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4),
self.titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4),
- self.titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -6),
-
+ self.titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: 0),
+
// badgeLabel 约束
self.badgeLabel.topAnchor.constraint(equalTo: self.iconImageView.topAnchor, constant: -4),
self.badgeLabel.trailingAnchor.constraint(equalTo: self.iconImageView.trailingAnchor, constant: 4),
@@ -170,7 +170,7 @@ public class STTabBarItemView: UIView {
private enum ImageAndTextMetrics {
static let titleGap: CGFloat = 2
- static let bottomPadding: CGFloat = 6
+ static let bottomPadding: CGFloat = 0
}
/// 单行标题占用高度(用于在固定 TabBar 高度内分配图标与「距顶」)
@@ -194,14 +194,15 @@ public class STTabBarItemView: UIView {
let baseW = model.layout.imageSize?.width ?? 24
let baseH = model.layout.imageSize?.height ?? 24
let titleH = self.titleLineHeight(for: model)
- let fixedTail = ImageAndTextMetrics.titleGap + titleH + ImageAndTextMetrics.bottomPadding
- let maxTop = barH - fixedTail - baseH
- let top = min(model.layout.imageTopInset, max(0, maxTop))
+ let contentH = baseH + ImageAndTextMetrics.titleGap + titleH
+ let areaTop = self.config?.itemLayoutAreaTopInset ?? 0
+ let areaBottom = barH - (self.config?.itemLayoutAreaBottomInset ?? 0)
+ let usableH = max(contentH, areaBottom - areaTop)
+ let top = areaTop + max(0, floor((usableH - contentH) / 2))
var iconW = baseW
var iconH = baseH
- let total = top + iconH + ImageAndTextMetrics.titleGap + titleH + ImageAndTextMetrics.bottomPadding
- if total > barH {
- let iconBudget = max(1, barH - top - fixedTail)
+ if top + iconH + ImageAndTextMetrics.titleGap + titleH > barH {
+ let iconBudget = max(1, barH - top - ImageAndTextMetrics.titleGap - titleH)
let scale = min(1, iconBudget / baseH)
iconH = baseH * scale
iconW = baseW * scale
@@ -276,11 +277,11 @@ public class STTabBarItemView: UIView {
self.titleLabel.topAnchor.constraint(equalTo: self.iconImageView.bottomAnchor, constant: 2),
self.titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 4),
self.titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -4),
- self.titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -6)
+ self.titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: 0)
]
NSLayoutConstraint.activate(self.titleLabelConstraints)
}
-
+
private func setupCustomMode(_ model: STTabBarItemModel) {
// 自定义视图模式
self.iconImageView.isHidden = true
diff --git a/Sources/STUIKit/STTextView/STShimmerTextView.swift b/Sources/STUIKit/STTextView/STShimmerTextView.swift
index 518f895..c7aecfa 100644
--- a/Sources/STUIKit/STTextView/STShimmerTextView.swift
+++ b/Sources/STUIKit/STTextView/STShimmerTextView.swift
@@ -9,13 +9,9 @@ import UIKit
open class STShimmerTextView: UITextView {
- /// 在 NSAttributedString 中标记此 key 的 range 不参与 fade-in 动画,
- /// 直接以最终颜色渲染。用于 list marker、block separator、heading 等结构元素。
public static let skipFadeInAttributeKey = NSAttributedString.Key("STShimmerTextView.skipFadeIn")
- // MARK: - LineFadeLayer(行级 CAGradientLayer 遮罩扫入动画)
private final class LineFadeLayer: CAGradientLayer {
- /// 动画已完成(待下次 applyLineFadeAnimation 时清理折入基底层)。
var isFadeComplete: Bool = false
}
@@ -35,8 +31,6 @@ open class STShimmerTextView: UITextView {
private struct AnimatingToken {
let range: NSRange
let startTime: CFTimeInterval
- /// 逐字 stagger 间隔:colorRuns 中第 i 个字符的实际 startTime = startTime + i * staggerInterval。
- /// 为 0 时所有字符同时 fade-in(原始行为)。
let staggerInterval: TimeInterval
let colorRuns: [AnimatingColorRun]
}
@@ -54,6 +48,8 @@ open class STShimmerTextView: UITextView {
public var lineFadeMode: Bool = false
/// 行级扫入动画时长(秒),默认 0.15 s,与 FluidMarkdown 对齐。
public var lineFadeDuration: TimeInterval = 0.15
+ /// 当前逐字输出行尾部的柔和渐隐宽度。
+ public var lineFadeTrailingWidth: CGFloat = 18
public var suppressSystemTextMenu: Bool = false
public var onAnimationStateChange: ((Bool) -> Void)?
private var displayLink: CADisplayLink?
@@ -65,6 +61,7 @@ open class STShimmerTextView: UITextView {
/// 最终目标态的 attributed text(全不透明),不含任何动画中间状态的 alpha 值。
/// 供外部做 "已渲染前缀" 比较时使用,避免因动画过渡期 alpha < 1 导致前缀比较误判。
private var _baseAttributedText: NSMutableAttributedString = NSMutableAttributedString()
+ private var _isLineFadeAnimating: Bool = false
open var defaultTextAttributes: [NSAttributedString.Key: Any] {
return [
@@ -78,7 +75,7 @@ open class STShimmerTextView: UITextView {
}
public var isAnimatingTextReveal: Bool {
- self.displayLink != nil && !self.animatingTokens.isEmpty
+ (self.displayLink != nil && !self.animatingTokens.isEmpty) || self._isLineFadeAnimating
}
public override init(frame: CGRect, textContainer: NSTextContainer?) {
@@ -94,9 +91,6 @@ open class STShimmerTextView: UITextView {
/// - Parameter usingTextLayoutManager: `true` 时使用 TextKit 2 栈(iOS 16+);低版本系统始终为 TextKit 1。
public convenience init(usingTextLayoutManager: Bool) {
if #available(iOS 16.0, *) {
- // UITextView(frame:textContainer:nil) 在 iOS 16+ 默认启用 TextKit 2,
- // 导致 textLayoutManager != nil;行级遮罩动画依赖 NSLayoutManager,
- // 必须通过 UITextView(usingTextLayoutManager:) 显式指定版本。
let shell = UITextView(usingTextLayoutManager: usingTextLayoutManager)
self.init(frame: .zero, textContainer: shell.textContainer)
} else {
@@ -114,8 +108,6 @@ open class STShimmerTextView: UITextView {
self.textContainer.lineFragmentPadding = 0
self.font = .st_systemFont(ofSize: 16)
self.textColor = .label
- // iOS 16+ 若已启用 TextKit 2(`textLayoutManager != nil`),访问 `layoutManager` 会强制降级到
- // TK1 兼容栈并在控制台产生 `_UITextViewEnablingCompatibilityMode` 告警;仅在经典 TK1 路径下设置。
if #available(iOS 16.0, *) {
if self.textLayoutManager == nil {
self.layoutManager.allowsNonContiguousLayout = false
@@ -125,15 +117,24 @@ open class STShimmerTextView: UITextView {
}
}
+ open override func layoutSubviews() {
+ super.layoutSubviews()
+ guard let mask = _lineFadeMaskLayer else { return }
+ CATransaction.begin()
+ CATransaction.setDisableActions(true)
+ mask.frame = self.bounds
+ mask.sublayerTransform = CATransform3DMakeTranslation(contentOffset.x, -contentOffset.y, 0)
+ _lineFadeBaseLayer?.frame.size.width = self.bounds.width
+ CATransaction.commit()
+ }
+
public func append(_ text: String) {
guard !text.isEmpty else { return }
let startLocation = self.textStorage.length
let baseColor = self.baseForegroundColor(from: self.defaultTextAttributes)
- // 在追加前,立即完成上一行(最后一个 \n 之前)的所有动画
if self.tokenFadeDuration > 0, !self.animateAcrossNewlines {
self.finishAnimationsBeforeLastNewline()
}
- // _baseAttributedText 记录全不透明最终态
let baseAttr = NSAttributedString(
string: text,
attributes: [.font: self.font ?? UIFont.st_systemFont(ofSize: 16), .foregroundColor: baseColor]
@@ -167,10 +168,7 @@ open class STShimmerTextView: UITextView {
let appended = NSMutableAttributedString(attributedString: attributedText)
let defaultColor = self.baseForegroundColor(from: self.defaultTextAttributes)
self.ensureForegroundColor(in: appended, defaultColor: defaultColor)
- // 保存 contentOffset:UITextView 在 textStorage 修改后可能意外偏移。
let savedOffset = self.contentOffset
-
- // 行级 CAGradientLayer 扫入模式:文本保持全不透明,由遮罩层控制可见性。
if animated && self.lineFadeMode {
_baseAttributedText.append(appended)
self.textStorage.beginEditing()
@@ -182,13 +180,9 @@ open class STShimmerTextView: UITextView {
)
return
}
-
- // 在追加前,立即完成上一行(最后一个 \n 之前)的所有动画
if animated, self.tokenFadeDuration > 0, !self.animateAcrossNewlines {
self.finishAnimationsBeforeLastNewline()
}
- // _baseAttributedText 记录全不透明最终态,必须在 applyTransparentForegroundColors
- // 之前追加,保留原始 alpha=1 颜色。
_baseAttributedText.append(appended)
let colorRuns = self.animatingColorRuns(in: appended, offset: startLocation)
if animated {
@@ -201,27 +195,20 @@ open class STShimmerTextView: UITextView {
if self.contentOffset != savedOffset { self.contentOffset = savedOffset }
return
}
-
- // 默认策略:当 delta 内含换行符时,最后一个 \n 之前的内容立即显示,只对最后一行做淡入。
- // 聊天流式场景要求严格逐字输出时,会开启 animateAcrossNewlines,整个 delta 都走字符级渐显。
let deltaString = appended.string as NSString
let lastNLInDelta = deltaString.range(of: "\n", options: .backwards)
if !self.animateAcrossNewlines, lastNLInDelta.location != NSNotFound {
let splitPos = lastNLInDelta.location + lastNLInDelta.length // local offset in delta
- // 立即完成 splitPos 之前的 colorRuns
self.textStorage.beginEditing()
var trailingRuns: [AnimatingColorRun] = []
for run in colorRuns {
let runLocalStart = run.range.location - startLocation
let runLocalEnd = runLocalStart + run.range.length
if runLocalEnd <= splitPos {
- // run 完全在 \n 之前 → 立即显示
self.textStorage.addAttribute(.foregroundColor, value: run.targetColor, range: run.range)
} else if runLocalStart >= splitPos {
- // run 完全在 \n 之后 → 保留动画
trailingRuns.append(run)
} else {
- // run 横跨 \n → 拆分
let beforeLength = splitPos - runLocalStart
let beforeRange = NSRange(location: run.range.location, length: beforeLength)
self.textStorage.addAttribute(.foregroundColor, value: run.targetColor, range: beforeRange)
@@ -232,7 +219,6 @@ open class STShimmerTextView: UITextView {
}
self.textStorage.endEditing()
if self.contentOffset != savedOffset { self.contentOffset = savedOffset }
- // 只对尾部片段(最后一个 \n 之后的内容)创建动画 token
if !trailingRuns.isEmpty {
self.appendStaggeredTokens(for: trailingRuns)
self.startDisplayLinkIfNeeded()
@@ -254,33 +240,23 @@ open class STShimmerTextView: UITextView {
self.textStorage.endEditing()
}
- public func replaceTrailingAttributedText(
- from location: Int,
- with attributedText: NSAttributedString,
- animateNewPortion: Bool = true
- ) {
+ public func replaceTrailingAttributedText(from location: Int, with attributedText: NSAttributedString, animateNewPortion: Bool = true) {
let clampedLocation = max(0, min(location, self.textStorage.length))
let savedOffset = self.contentOffset
-
- // 1. 立即完成前缀区域内仍在动画的 token,丢弃与尾部重叠的 token
if !self.animatingTokens.isEmpty {
self.textStorage.beginEditing()
for token in self.animatingTokens {
let tokenEnd = token.range.location + token.range.length
if tokenEnd <= clampedLocation {
- // token 在前缀区域内 → 立即完成动画
for run in token.colorRuns {
self.textStorage.addAttribute(.foregroundColor, value: run.targetColor, range: run.range)
}
}
- // token 与尾部重叠 → 丢弃(即将被替换)
}
self.textStorage.endEditing()
}
self.animatingTokens.removeAll()
if self.lineFadeMode { self.removeLineFadeMask() }
-
- // 计算旧尾部字符串,用于后续判断哪些是"真正新增"的字符
let oldTrailingLength = self.textStorage.length - clampedLocation
let oldTrailingString: String
if oldTrailingLength > 0 {
@@ -289,8 +265,6 @@ open class STShimmerTextView: UITextView {
} else {
oldTrailingString = ""
}
-
- // 2. 同步更新 _baseAttributedText:保留 [0, clampedLocation) 前缀 + 新尾部
let clampedBaseLocation = max(0, min(location, _baseAttributedText.length))
let newBase = NSMutableAttributedString(
attributedString: _baseAttributedText.attributedSubstring(
@@ -299,19 +273,11 @@ open class STShimmerTextView: UITextView {
)
newBase.append(attributedText)
_baseAttributedText = newBase
-
- // 3. 替换 textStorage 中的尾部内容。
- // 对已有文本部分(旧尾部与新尾部的公共前缀)直接以最终颜色渲染(不做 fade-in),
- // 对真正新增的字符执行逐字 stagger fade-in 动画。
let newTrailingString = attributedText.string
let commonPrefixCount = oldTrailingString.commonPrefix(with: newTrailingString).utf16.count
-
- // 准备新尾部的 appended 副本用于提取 colorRuns
let appended = NSMutableAttributedString(attributedString: attributedText)
let defaultColor = self.baseForegroundColor(from: self.defaultTextAttributes)
self.ensureForegroundColor(in: appended, defaultColor: defaultColor)
-
- // 对真正新增的部分(公共前缀之后)提取 colorRuns 并设置透明
let newCharCount = attributedText.length - commonPrefixCount
var newColorRuns: [AnimatingColorRun] = []
if animateNewPortion,
@@ -322,7 +288,6 @@ open class STShimmerTextView: UITextView {
let newPortion = appended.attributedSubstring(from: newRange)
let newPortionMut = NSMutableAttributedString(attributedString: newPortion)
let newPortionOffset = clampedLocation + commonPrefixCount
- // 提取 colorRuns(从新增部分)
let fullRange = NSRange(location: 0, length: newPortionMut.length)
newPortionMut.enumerateAttribute(.foregroundColor, in: fullRange, options: []) { value, subrange, _ in
guard let color = value as? UIColor else { return }
@@ -336,7 +301,6 @@ open class STShimmerTextView: UITextView {
targetColor: color
))
}
- // 将新增部分在 appended 中设为透明
if !newColorRuns.isEmpty {
newPortionMut.enumerateAttribute(.foregroundColor, in: fullRange, options: []) { value, subrange, _ in
if newPortionMut.attribute(.attachment, at: subrange.location, effectiveRange: nil) != nil { return }
@@ -358,8 +322,6 @@ open class STShimmerTextView: UITextView {
)
self.textStorage.endEditing()
if self.contentOffset != savedOffset { self.contentOffset = savedOffset }
-
- // 对新增字符启动逐字 stagger 动画
if !newColorRuns.isEmpty {
self.appendStaggeredTokens(for: newColorRuns)
self.startDisplayLinkIfNeeded()
@@ -411,7 +373,6 @@ open class STShimmerTextView: UITextView {
let start = colorRuns.map(\.range.location).min()!
let end = colorRuns.map { $0.range.location + $0.range.length }.max()!
let totalLength = colorRuns.reduce(0) { $0 + $1.range.length }
- // 字符数 ≤ 2 或 stagger 为 0 时不使用 stagger
let stagger = (self.characterStaggerInterval > 0 && totalLength > 2)
? self.characterStaggerInterval : 0
let token = AnimatingToken(
@@ -451,7 +412,6 @@ open class STShimmerTextView: UITextView {
self.textStorage.beginEditing()
for token in self.animatingTokens {
if token.staggerInterval <= 0 {
- // 无 stagger:所有 colorRuns 共享同一进度
let elapsed = now - token.startTime
let progress = min(1.0, elapsed / fadeDuration)
let easedProgress = 1.0 - pow(1.0 - progress, 3.0)
@@ -460,7 +420,6 @@ open class STShimmerTextView: UITextView {
self.textStorage.addAttribute(.foregroundColor, value: color, range: run.range)
}
} else {
- // 有 stagger:逐字符计算进度,每个字符独立的 startTime
var charIndex = 0
for run in token.colorRuns {
for offset in 0.. 0 else { return }
attributedText.enumerateAttribute(.foregroundColor, in: range, options: []) { value, subrange, _ in
- // 跳过含 NSTextAttachment 的字符(如 citation 圆圈),
- // 它们的视觉由 attachment image 决定,不应被 alpha 动画影响。
if attributedText.attribute(.attachment, at: subrange.location, effectiveRange: nil) != nil {
return
}
- // 跳过标记了 skipFadeIn 的 range(list marker、block separator 等结构元素),
- // 它们需要直接以最终颜色渲染,不做 alpha 渐变。
if attributedText.attribute(Self.skipFadeInAttributeKey, at: subrange.location, effectiveRange: nil) != nil {
return
}
let color = (value as? UIColor) ?? defaultColor
- // 跳过已经透明的颜色(如 blockSeparator 的 UIColor.clear),
- // 避免 withAlphaComponent(0) 将 (0,0,0,0) 变为 (0,0,0,0) 后在动画中渐变为 (0,0,0,progress)。
var alpha: CGFloat = 0
color.getWhite(nil, alpha: &alpha)
if alpha < 0.01 { return }
@@ -535,15 +487,12 @@ open class STShimmerTextView: UITextView {
var runs: [AnimatingColorRun] = []
attributedText.enumerateAttribute(.foregroundColor, in: range, options: []) { value, subrange, _ in
guard let color = value as? UIColor else { return }
- // 跳过 NSTextAttachment 字符,不参与 fade-in 动画
if attributedText.attribute(.attachment, at: subrange.location, effectiveRange: nil) != nil {
return
}
- // 跳过标记了 skipFadeIn 的 range
if attributedText.attribute(Self.skipFadeInAttributeKey, at: subrange.location, effectiveRange: nil) != nil {
return
}
- // 跳过已透明的颜色(blockSeparator 等),它们不需要 fade-in
var alpha: CGFloat = 0
color.getWhite(nil, alpha: &alpha)
if alpha < 0.01 { return }
@@ -582,37 +531,29 @@ open class STShimmerTextView: UITextView {
let str = _baseAttributedText.string as NSString
let len = str.length
guard len > 0 else { return }
- // 在 [0, len) 范围内倒序查找最后一个换行符
let lastNLRange = str.range(of: "\n", options: .backwards, range: NSRange(location: 0, length: len))
guard lastNLRange.location != NSNotFound else { return }
- // boundary:最后一个 \n 之后的第一个字符位置;此位置之前的 token 全部立即完成
let boundary = lastNLRange.location + lastNLRange.length
-
var completedIndices: [Int] = []
var splitReplacements: [(index: Int, newToken: AnimatingToken)] = []
self.textStorage.beginEditing()
for (idx, token) in self.animatingTokens.enumerated() {
let tokenEnd = token.range.location + token.range.length
if tokenEnd <= boundary {
- // token 完全在 boundary 之前 → 立即完成全部动画
for run in token.colorRuns {
self.textStorage.addAttribute(.foregroundColor, value: run.targetColor, range: run.range)
}
completedIndices.append(idx)
} else if token.range.location < boundary {
- // token 横跨 boundary → 拆分:boundary 之前的部分立即完成,之后的部分保留动画
var beforeRuns: [AnimatingColorRun] = []
var afterRuns: [AnimatingColorRun] = []
for run in token.colorRuns {
let runEnd = run.range.location + run.range.length
if runEnd <= boundary {
- // run 完全在 boundary 之前
beforeRuns.append(run)
} else if run.range.location >= boundary {
- // run 完全在 boundary 之后
afterRuns.append(run)
} else {
- // run 横跨 boundary → 拆成两段
let beforeLength = boundary - run.range.location
let beforeRange = NSRange(location: run.range.location, length: beforeLength)
beforeRuns.append(AnimatingColorRun(range: beforeRange, targetColor: run.targetColor))
@@ -621,14 +562,12 @@ open class STShimmerTextView: UITextView {
afterRuns.append(AnimatingColorRun(range: afterRange, targetColor: run.targetColor))
}
}
- // 立即完成 boundary 之前的部分
for run in beforeRuns {
self.textStorage.addAttribute(.foregroundColor, value: run.targetColor, range: run.range)
}
if afterRuns.isEmpty {
completedIndices.append(idx)
} else {
- // 用剩余的 afterRuns 替换原 token,保持原始 startTime
let afterStart = afterRuns.map(\.range.location).min() ?? boundary
let afterEnd = afterRuns.map { $0.range.location + $0.range.length }.max() ?? boundary
let newToken = AnimatingToken(
@@ -640,10 +579,8 @@ open class STShimmerTextView: UITextView {
splitReplacements.append((index: idx, newToken: newToken))
}
}
- // token 完全在 boundary 之后 → 不处理,继续动画
}
self.textStorage.endEditing()
- // 应用拆分替换
for replacement in splitReplacements {
self.animatingTokens[replacement.index] = replacement.newToken
}
@@ -656,13 +593,12 @@ open class STShimmerTextView: UITextView {
}
}
- // MARK: - Line Fade Mask
-
private func removeLineFadeMask() {
guard _lineFadeMaskLayer != nil else { return }
self.layer.mask = nil
_lineFadeMaskLayer = nil
_lineFadeBaseLayer = nil
+ self.setLineFadeAnimating(false)
}
/// 对 `changedRange` 所在的最末行应用 CAGradientLayer 水平扫入遮罩动画(FluidMarkdown 风格)。
@@ -670,7 +606,6 @@ open class STShimmerTextView: UITextView {
/// TK2(iOS 16+)优先;TK2 不可用时回退到 TK1 layoutManager。
private func applyLineFadeAnimation(changedRange: NSRange) {
guard changedRange.length > 0, self.bounds.width > 1 else { return }
-
if _lineFadeMaskLayer == nil {
let null = NSNull()
let mask = CALayer()
@@ -688,9 +623,7 @@ open class STShimmerTextView: UITextView {
}
guard let mask = _lineFadeMaskLayer, let base = _lineFadeBaseLayer else { return }
mask.frame = self.bounds
- // FluidMarkdown 对齐:补偿滚动偏移(isScrollEnabled=false 时为 identity,仍保留以确保正确性)
mask.sublayerTransform = CATransform3DMakeTranslation(contentOffset.x, -contentOffset.y, 0)
-
if #available(iOS 16.0, *), let tlm = self.textLayoutManager {
applyLineFadeAnimation_tk2(changedRange: changedRange, tlm: tlm, mask: mask, base: base)
} else {
@@ -713,8 +646,6 @@ open class STShimmerTextView: UITextView {
let textRange = NSTextRange(location: rs, end: re)
else { return }
tlm.ensureLayout(for: textRange)
-
- // 按 minY 分组得到最末行的 union 矩形
var prevMinY: CGFloat = .nan
var curLineRect: CGRect = .null
var lastLineRect: CGRect = .null
@@ -749,64 +680,75 @@ open class STShimmerTextView: UITextView {
/// - lineRect: 行片段矩形(用于确定 y 位置和行高)。
/// - rightEdge: 行内已用文字的右边界(TK1 用 usedRect.maxX;TK2 用 segment union 的 maxX)。
private func installLineFadeLayer(lineRect rect: CGRect, rightEdge: CGFloat, mask: CALayer, base: CALayer) {
- // 基础层覆盖当前行以上的所有内容
base.frame = CGRect(x: 0, y: 0, width: mask.bounds.width, height: rect.minY)
-
- // lineDetectRect:稍扩展以容纳浮点误差(与 FluidMarkdown 相同)
+ let tailWidth = max(8, self.lineFadeTrailingWidth)
let lineDetectRect = CGRect(
x: floor(rect.minX), y: floor(rect.minY),
- width: ceil(rect.width), height: ceil(rect.height + 1)
+ width: ceil(max(rect.width, rightEdge - rect.minX + tailWidth)),
+ height: ceil(rect.height + 1)
)
- var latestX: CGFloat = rect.minX
+ var previousRightEdge: CGFloat?
for sub in mask.sublayers ?? [] {
guard let fl = sub as? LineFadeLayer else { continue }
if lineDetectRect.contains(fl.frame) {
- if fl.isFadeComplete {
- // 已完成的层:折入基础层并移除
- fl.removeFromSuperlayer()
- base.frame = CGRect(x: 0, y: 0, width: mask.bounds.width, height: rect.maxY)
- } else {
- latestX = max(latestX, fl.frame.maxX)
- }
- } else {
- // 其他行的旧层:基础层已覆盖,直接移除
- fl.removeFromSuperlayer()
+ previousRightEdge = max(previousRightEdge ?? rect.minX, fl.frame.maxX - tailWidth)
}
- }
-
- let newFrame = CGRect(x: latestX, y: rect.minY, width: rightEdge - latestX, height: rect.height)
+ fl.removeFromSuperlayer()
+ }
+ let lineWidth = max(0, rightEdge - rect.minX)
+ let newFrame = CGRect(
+ x: rect.minX,
+ y: rect.minY,
+ width: lineWidth + tailWidth,
+ height: rect.height
+ )
guard newFrame.width > 0.5, newFrame.height > 0.5 else { return }
- // 去重:整数像素级别比较(FluidMarkdown 用 CGRectIntegral)
- let isDuplicate = (mask.sublayers ?? []).compactMap { $0 as? LineFadeLayer }.contains {
- CGRectEqualToRect(CGRectIntegral($0.frame), CGRectIntegral(newFrame))
- }
- guard !isDuplicate else { return }
-
let fl = LineFadeLayer()
fl.startPoint = CGPoint(x: 0, y: 0.5)
fl.endPoint = CGPoint(x: 1, y: 0.5)
fl.frame = newFrame
- // 模型值 = 最终状态(动画移除后 layer 回退到此值,无视觉跳变)
- fl.colors = [UIColor.black.cgColor, UIColor.black.cgColor]
-
- let anim = CAKeyframeAnimation(keyPath: "colors")
- anim.values = [
- [UIColor.clear.cgColor, UIColor.clear.cgColor],
- [UIColor.black.cgColor, UIColor.clear.cgColor],
- [UIColor.black.cgColor, UIColor.black.cgColor],
+ fl.colors = [UIColor.black.cgColor, UIColor.black.cgColor, UIColor.clear.cgColor]
+ let fadeStart = min(0.98, max(0, lineWidth / max(newFrame.width, 1)))
+ fl.locations = [NSNumber(value: 0), NSNumber(value: Double(fadeStart)), NSNumber(value: 1)]
+ let fromRightEdge = min(rightEdge, max(previousRightEdge ?? rect.minX, rect.minX))
+ let fromFadeStart = min(0.98, max(0, (fromRightEdge - rect.minX) / max(newFrame.width, 1)))
+ let anim = CABasicAnimation(keyPath: "locations")
+ anim.fromValue = [
+ NSNumber(value: 0),
+ NSNumber(value: Double(fromFadeStart)),
+ NSNumber(value: 1),
]
- anim.calculationMode = .linear
- anim.fillMode = .both // FluidMarkdown: kCAFillModeBoth
- anim.isRemovedOnCompletion = true // FluidMarkdown: removedOnCompletion = YES
+ anim.toValue = [
+ NSNumber(value: 0),
+ NSNumber(value: Double(fadeStart)),
+ NSNumber(value: 1),
+ ]
+ anim.fillMode = .both
+ anim.isRemovedOnCompletion = true
anim.duration = lineFadeDuration
- anim.delegate = LineFadeAnimationDelegate { [weak fl] in
- fl?.isFadeComplete = true // 供下次 applyLineFadeAnimation 做懒清理
+ self.setLineFadeAnimating(true)
+ anim.delegate = LineFadeAnimationDelegate { [weak self, weak fl] in
+ fl?.isFadeComplete = true
+ guard let self else { return }
+ let stillAnimating = (mask.sublayers ?? []).contains {
+ guard let layer = $0 as? LineFadeLayer else { return false }
+ return layer.animation(forKey: "fadeIn") != nil
+ }
+ if !stillAnimating {
+ self.setLineFadeAnimating(false)
+ }
}
mask.addSublayer(fl)
fl.add(anim, forKey: "fadeIn")
}
+ private func setLineFadeAnimating(_ isAnimating: Bool) {
+ guard self._isLineFadeAnimating != isAnimating else { return }
+ self._isLineFadeAnimating = isAnimating
+ self.onAnimationStateChange?(isAnimating)
+ }
+
/// 子类可重写:禁止系统长按复制/粘贴菜单,仅使用自定义 popupMenuItems(如 Bajoseek 回复区)
open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if self.suppressSystemTextMenu {