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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 66 additions & 41 deletions Sources/Extension/UIKit/UILabel/UILabelExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#if os(iOS) || os(tvOS)

import UIKit
import CoreText

private var UIGestureRecognizerKey: Void?
private var UILabelTouchedKey: Void?
Expand Down Expand Up @@ -266,49 +267,73 @@ fileprivate extension UILabel {
}

func matching(_ point: CGPoint) -> (NSRange, Action)? {
let text = adaptation(scaledAttributedText ?? synthesizedAttributedText ?? attributedText, with: numberOfLines)
guard let attributedString = AttributedString(text) else { return nil }

// 构建同步Label的TextKit
let delegate = UILabelLayoutManagerDelegate(scaledMetrics, with: baselineAdjustment)
let textStorage = NSTextStorage()
let textContainer = NSTextContainer(size: bounds.size)
let layoutManager = NSLayoutManager()
layoutManager.delegate = delegate // 重新计算行高确保TextKit与UILabel显示同步
textContainer.lineBreakMode = lineBreakMode
textContainer.lineFragmentPadding = 0.0
textContainer.maximumNumberOfLines = numberOfLines
layoutManager.usesFontLeading = false // UILabel没有使用FontLeading排版
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
textStorage.setAttributedString(attributedString.value) // 放在最后添加富文本 TextKit的坑

// 确保布局
layoutManager.ensureLayout(for: textContainer)

// 获取文本所占高度
let height = layoutManager.usedRect(for: textContainer).height

// 获取点击坐标 并排除各种偏移
var point = point
point.y -= (bounds.height - height) / 2

// Debug
// Use the actual attributed string UILabel renders (prefer scaled version for adjustsFontSizeToFitWidth)
guard let attributedText = scaledAttributedText ?? synthesizedAttributedText ?? self.attributedText else { return nil }
guard attributedText.length > 0 else { return nil }

// Use UILabel's own text rect — this accounts for vertical centering, alignment, and numberOfLines
let textRect = self.textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines)
guard textRect.width > 0, textRect.height > 0 else { return nil }

// Build CoreText layout — CoreText is the engine UILabel uses internally,
// so its line height and glyph positioning match UILabel closely,
// avoiding the known discrepancies with TextKit's NSLayoutManager.
let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString)
let framePath = CGMutablePath()
framePath.addRect(CGRect(origin: .zero, size: textRect.size))
let ctFrame = CTFramesetterCreateFrame(framesetter, CFRange(location: 0, length: 0), framePath, nil)

guard let lines = CTFrameGetLines(ctFrame) as? [CTLine], !lines.isEmpty else { return nil }

// Limit visible lines (CoreText fills what fits; apply numberOfLines cap)
let visibleCount = numberOfLines > 0 ? min(numberOfLines, lines.count) : lines.count
var origins = [CGPoint](repeating: .zero, count: visibleCount)
CTFrameGetLineOrigins(ctFrame, CFRange(location: 0, length: visibleCount), &origins)

// Debug overlay — draw CoreText lines on top of UILabel for visual comparison
subviews.filter({ $0 is DebugView }).forEach({ $0.removeFromSuperview() })
let view = DebugView(frame: .init(x: 0, y: (bounds.height - height) / 2, width: bounds.width, height: height))
view.draw = { layoutManager.drawGlyphs(forGlyphRange: .init(location: 0, length: textStorage.length), at: .zero) }
addSubview(view)

// 获取字形下标
var fraction: CGFloat = 0
let glyphIndex = layoutManager.glyphIndex(for: point, in: textContainer, fractionOfDistanceThroughGlyph: &fraction)
// 获取字符下标
let index = layoutManager.characterIndexForGlyph(at: glyphIndex)
// 通过字形距离判断是否在字形范围内
guard fraction > 0, fraction < 1 else {
return nil
let debugView = DebugView(frame: textRect)
debugView.draw = { [lines = Array(lines.prefix(visibleCount)), origins, height = textRect.height] in
guard let ctx = UIGraphicsGetCurrentContext() else { return }
ctx.saveGState()
ctx.translateBy(x: 0, y: height)
ctx.scaleBy(x: 1, y: -1)
for i in 0..<lines.count {
ctx.textPosition = origins[i]
CTLineDraw(lines[i], ctx)
}
ctx.restoreGState()
}
addSubview(debugView)

// Convert tap point from UIKit coordinates (top-left origin) to CoreText coordinates (bottom-left origin)
let ctX = point.x - textRect.origin.x
let ctY = textRect.height - (point.y - textRect.origin.y)

// Find the tapped line using vertical midpoints to split inter-line space evenly between neighbors
var tappedLineIndex: Int?
for i in 0..<visibleCount {
let topBound: CGFloat = (i == 0) ? textRect.height : (origins[i - 1].y + origins[i].y) / 2
let bottomBound: CGFloat = (i == visibleCount - 1) ? 0 : (origins[i].y + origins[i + 1].y) / 2
if ctY <= topBound && ctY >= bottomBound {
tappedLineIndex = i
break
}
}
// 获取点击的字符串范围和回调事件
guard let lineIndex = tappedLineIndex else { return nil }
let line = lines[lineIndex]

// Check horizontal bounds — account for text alignment offset
var ascent: CGFloat = 0, descent: CGFloat = 0, leading: CGFloat = 0
let lineWidth = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading))
let lineOriginX = origins[lineIndex].x
guard ctX >= lineOriginX, ctX <= lineOriginX + lineWidth else { return nil }

// Get string index at tap position (x relative to line origin)
let index = CTLineGetStringIndexForPosition(line, CGPoint(x: ctX - lineOriginX, y: 0))
guard index >= 0, index < attributedText.length else { return nil }

// Find matching action range
guard
let range = actions.keys.first(where: { $0.contains(index) }),
let action = actions[range] else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ class UILabelLayoutManagerDelegate: NSObject, NSLayoutManagerDelegate {
used.size.height = scaledMetrics.scaledSize.height

case .alignCenters:
print(scaledMetrics)
// 居中的基线偏移 使用Scaled的尺寸高度
var baseline = baselineOffset.pointee
// 整行的占用高度 - 缩放的行高 = 上下边距; 上边距 = 上下边距 * 0.5; 居中的基线偏移 = 上边距 + 缩放的基线偏移
Expand Down
130 changes: 130 additions & 0 deletions Tests/AttributedString_iOS_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
#if os(iOS)

import XCTest
import UIKit
import CoreText

class AttributedString_iOS_Tests: XCTestCase {

Expand All @@ -32,6 +34,134 @@ class AttributedString_iOS_Tests: XCTestCase {
}
}

// MARK: - CoreText / UILabel Sync Tests

/// Verify that CoreText line height matches UILabel's textRect for mixed Chinese/English text.
/// This is the core issue: TextKit line heights diverge from UILabel, but CoreText should match.
func testCoreTextHeightMatchesUILabelForMixedText() throws {
let label = UILabel()
label.numberOfLines = 0
label.frame = CGRect(x: 0, y: 0, width: 300, height: 500)

let text = NSMutableAttributedString(string: "我的名字叫李响Hello World mixed text测试")
text.addAttribute(.font, value: UIFont.systemFont(ofSize: 17), range: NSRange(location: 0, length: text.length))
label.attributedText = text
label.layoutIfNeeded()

let textRect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 0)

// CoreText layout with same text and width
let framesetter = CTFramesetterCreateWithAttributedString(text as CFAttributedString)
let suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(
framesetter,
CFRange(location: 0, length: 0),
nil,
CGSize(width: textRect.width, height: .greatestFiniteMagnitude),
nil
)

// CoreText suggested height should be very close to UILabel's text rect height
// Allow 2pt tolerance for rounding
XCTAssertEqual(suggestedSize.height, textRect.height, accuracy: 2.0,
"CoreText height (\(suggestedSize.height)) should match UILabel textRect height (\(textRect.height)) for mixed Chinese/English text")
}

/// Verify CoreText layout produces correct line count matching UILabel with numberOfLines truncation
func testCoreTextLineCountMatchesUILabelWithNumberOfLines() throws {
let label = UILabel()
label.numberOfLines = 2
label.frame = CGRect(x: 0, y: 0, width: 200, height: 200)

// Long text that wraps to more than 2 lines
let text = NSMutableAttributedString(string: "这是一段很长的中文和English混合的文本,用于测试numberOfLines截断是否正确工作。This text should wrap to many lines.")
text.addAttribute(.font, value: UIFont.systemFont(ofSize: 17), range: NSRange(location: 0, length: text.length))
label.attributedText = text
label.layoutIfNeeded()

let textRect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 2)

let framesetter = CTFramesetterCreateWithAttributedString(text as CFAttributedString)
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: textRect.size))
let frame = CTFramesetterCreateFrame(framesetter, CFRange(location: 0, length: 0), path, nil)

guard let lines = CTFrameGetLines(frame) as? [CTLine] else {
XCTFail("CoreText should produce lines")
return
}

// CoreText should produce at least 2 lines that fit in the textRect
XCTAssertGreaterThanOrEqual(lines.count, 2,
"CoreText should produce at least 2 lines in the available space")
}

/// Verify CoreText handles multiple newlines correctly with numberOfLines (the known TextKit bug)
func testCoreTextHandlesNewlinesWithNumberOfLines() throws {
let label = UILabel()
label.numberOfLines = 2
label.frame = CGRect(x: 0, y: 0, width: 300, height: 200)

// The exact case from the issue: abc\n\n\ndefg with numberOfLines=2
// TextKit incorrectly shows "defg" on line 2, but UILabel/CoreText should show an empty line 2
let text = NSMutableAttributedString(string: "abc\n\n\ndefg")
text.addAttribute(.font, value: UIFont.systemFont(ofSize: 17), range: NSRange(location: 0, length: text.length))
label.attributedText = text
label.layoutIfNeeded()

let textRect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 2)

let framesetter = CTFramesetterCreateWithAttributedString(text as CFAttributedString)
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: textRect.size))
let frame = CTFramesetterCreateFrame(framesetter, CFRange(location: 0, length: 0), path, nil)

guard let lines = CTFrameGetLines(frame) as? [CTLine] else {
XCTFail("CoreText should produce lines")
return
}

// With "abc\n\n\ndefg", CoreText should produce at least 3 lines (abc, empty, empty, defg)
// When limited to 2, the first 2 lines are "abc" and empty — not "defg"
XCTAssertGreaterThanOrEqual(lines.count, 2, "CoreText should handle newlines correctly")

// The second line's string range should NOT start at "defg" (index 6)
let line2Range = CTLineGetStringRange(lines[1])
XCTAssertNotEqual(line2Range.location, 6,
"Second line should not jump to 'defg' — it should be the first empty newline")
XCTAssertEqual(line2Range.location, 4,
"Second line should start at index 4 (after 'abc\\n')")
}

/// Verify CoreText hit testing produces valid character indices for mixed font text
func testCoreTextHitTestingMixedFonts() throws {
let text = NSMutableAttributedString(string: "Hello你好World世界")
// Different fonts for Chinese and English
text.addAttribute(.font, value: UIFont.systemFont(ofSize: 17), range: NSRange(location: 0, length: 5))
text.addAttribute(.font, value: UIFont(name: "PingFangSC-Regular", size: 17) ?? UIFont.systemFont(ofSize: 17), range: NSRange(location: 5, length: 2))
text.addAttribute(.font, value: UIFont.systemFont(ofSize: 17), range: NSRange(location: 7, length: 5))
text.addAttribute(.font, value: UIFont(name: "PingFangSC-Regular", size: 17) ?? UIFont.systemFont(ofSize: 17), range: NSRange(location: 12, length: 2))

let framesetter = CTFramesetterCreateWithAttributedString(text as CFAttributedString)
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: CGSize(width: 300, height: 100)))
let frame = CTFramesetterCreateFrame(framesetter, CFRange(location: 0, length: 0), path, nil)

guard let lines = CTFrameGetLines(frame) as? [CTLine], !lines.isEmpty else {
XCTFail("CoreText should produce at least one line")
return
}

// Test that tapping at x=0 gives the first character
let firstCharIndex = CTLineGetStringIndexForPosition(lines[0], CGPoint(x: 1, y: 0))
XCTAssertEqual(firstCharIndex, 0, "Tapping at start should return index 0")

// Test that tapping past the end gives the last index
var ascent: CGFloat = 0, descent: CGFloat = 0, leading: CGFloat = 0
let lineWidth = CTLineGetTypographicBounds(lines[0], &ascent, &descent, &leading)
let lastCharIndex = CTLineGetStringIndexForPosition(lines[0], CGPoint(x: lineWidth - 1, y: 0))
XCTAssertGreaterThan(lastCharIndex, 0, "Tapping near end should return a valid index")
}

}

#endif