From 6d626dcaa73c9c4a1fbb96e5fff00dc38b3fb43d Mon Sep 17 00:00:00 2001 From: pannous Date: Tue, 17 Mar 2026 15:53:24 +0100 Subject: [PATCH 1/3] fix: replace TextKit with CoreText for UILabel hit testing TextKit's NSLayoutManager calculates line heights differently from UILabel, causing misalignment with mixed Chinese/English fonts, numberOfLines truncation, and byCharWrapping mode. Since UILabel internally uses CoreText for text layout, switching to CoreText (CTFramesetter/CTFrame/CTLine) directly for hit testing and debug overlay eliminates these discrepancies. Key changes: - Use CTFramesetter/CTFrame for text layout instead of NSLayoutManager - Use UILabel.textRect(forBounds:limitedToNumberOfLines:) for accurate positioning - Handle line truncation with CTLineCreateTruncatedLine for visual matching - Remove lineBreakMode adaptation hack (CoreText handles all modes natively) - Debug overlay now uses CTLineDraw for closer visual match with UILabel Fixes #12 --- .../UIKit/UILabel/UILabelExtension.swift | 129 +++++++++++------- .../UILabelLayoutManagerDelegate.swift | 1 - 2 files changed, 83 insertions(+), 47 deletions(-) diff --git a/Sources/Extension/UIKit/UILabel/UILabelExtension.swift b/Sources/Extension/UIKit/UILabel/UILabelExtension.swift index 391cdcf..def1386 100644 --- a/Sources/Extension/UIKit/UILabel/UILabelExtension.swift +++ b/Sources/Extension/UIKit/UILabel/UILabelExtension.swift @@ -14,6 +14,7 @@ #if os(iOS) || os(tvOS) import UIKit +import CoreText private var UIGestureRecognizerKey: Void? private var UILabelTouchedKey: Void? @@ -266,55 +267,91 @@ 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) + + // Build visible lines array, with truncation on last line if needed + var visibleLines = Array(lines.prefix(visibleCount)) + if numberOfLines > 0 && lines.count > numberOfLines { + // Create truncated last line with ellipsis to match UILabel's rendering + let lastLine = visibleLines[visibleCount - 1] + let lastCharRange = CTLineGetStringRange(lastLine) + var truncAttrs: [NSAttributedString.Key: Any] = [:] + if lastCharRange.length > 0 { + truncAttrs = attributedText.attributes(at: lastCharRange.location, effectiveRange: nil) + } + let token = NSAttributedString(string: "\u{2026}", attributes: truncAttrs) + let tokenLine = CTLineCreateWithAttributedString(token as CFAttributedString) + if let truncated = CTLineCreateTruncatedLine(lastLine, Double(textRect.width), .end, tokenLine) { + visibleLines[visibleCount - 1] = truncated + } + } + + // 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 = { [visibleLines, origins, height = textRect.height] in + guard let ctx = UIGraphicsGetCurrentContext() else { return } + ctx.saveGState() + // Flip to CoreText coordinate system (origin at bottom-left) + ctx.translateBy(x: 0, y: height) + ctx.scaleBy(x: 1, y: -1) + for i in 0..= origins[i].y - descent, ctY <= origins[i].y + ascent else { continue } + // Check horizontal bounds + guard ctX >= 0, ctX <= lineWidth else { continue } + + // Get string index at tap position + let index = CTLineGetStringIndexForPosition(lines[i], CGPoint(x: ctX, y: 0)) + guard index >= 0, index < attributedText.length else { continue } + + // Find matching action range + guard + let range = actions.keys.first(where: { $0.contains(index) }), + let action = actions[range] else { + return nil + } + return (range, action) } - return (range, action) + + return nil } } diff --git a/Sources/Extension/UIKit/UILabel/UILabelLayoutManagerDelegate.swift b/Sources/Extension/UIKit/UILabel/UILabelLayoutManagerDelegate.swift index 72ae4df..9b72448 100644 --- a/Sources/Extension/UIKit/UILabel/UILabelLayoutManagerDelegate.swift +++ b/Sources/Extension/UIKit/UILabel/UILabelLayoutManagerDelegate.swift @@ -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; 居中的基线偏移 = 上边距 + 缩放的基线偏移 From 2a139146ad241705912dbfcda69cf05119b91aa2 Mon Sep 17 00:00:00 2001 From: pannous Date: Tue, 17 Mar 2026 15:54:37 +0100 Subject: [PATCH 2/3] fix: improve hit testing with midpoint line detection Use vertical midpoints between adjacent lines for tap detection instead of strict ascent/descent bounds. This ensures taps in inter-line spacing are correctly assigned to the nearest line. --- .../UIKit/UILabel/UILabelExtension.swift | 70 ++++++++----------- 1 file changed, 29 insertions(+), 41 deletions(-) diff --git a/Sources/Extension/UIKit/UILabel/UILabelExtension.swift b/Sources/Extension/UIKit/UILabel/UILabelExtension.swift index def1386..e74a24c 100644 --- a/Sources/Extension/UIKit/UILabel/UILabelExtension.swift +++ b/Sources/Extension/UIKit/UILabel/UILabelExtension.swift @@ -290,68 +290,56 @@ fileprivate extension UILabel { var origins = [CGPoint](repeating: .zero, count: visibleCount) CTFrameGetLineOrigins(ctFrame, CFRange(location: 0, length: visibleCount), &origins) - // Build visible lines array, with truncation on last line if needed - var visibleLines = Array(lines.prefix(visibleCount)) - if numberOfLines > 0 && lines.count > numberOfLines { - // Create truncated last line with ellipsis to match UILabel's rendering - let lastLine = visibleLines[visibleCount - 1] - let lastCharRange = CTLineGetStringRange(lastLine) - var truncAttrs: [NSAttributedString.Key: Any] = [:] - if lastCharRange.length > 0 { - truncAttrs = attributedText.attributes(at: lastCharRange.location, effectiveRange: nil) - } - let token = NSAttributedString(string: "\u{2026}", attributes: truncAttrs) - let tokenLine = CTLineCreateWithAttributedString(token as CFAttributedString) - if let truncated = CTLineCreateTruncatedLine(lastLine, Double(textRect.width), .end, tokenLine) { - visibleLines[visibleCount - 1] = truncated - } - } - // Debug overlay — draw CoreText lines on top of UILabel for visual comparison subviews.filter({ $0 is DebugView }).forEach({ $0.removeFromSuperview() }) let debugView = DebugView(frame: textRect) - debugView.draw = { [visibleLines, origins, height = textRect.height] in + debugView.draw = { [lines = Array(lines.prefix(visibleCount)), origins, height = textRect.height] in guard let ctx = UIGraphicsGetCurrentContext() else { return } ctx.saveGState() - // Flip to CoreText coordinate system (origin at bottom-left) ctx.translateBy(x: 0, y: height) ctx.scaleBy(x: 1, y: -1) - for i in 0..= bottomBound { + tappedLineIndex = i + break + } + } + guard let lineIndex = tappedLineIndex else { return nil } + let line = lines[lineIndex] - // Check vertical bounds (line occupies: origin.y - descent .. origin.y + ascent) - guard ctY >= origins[i].y - descent, ctY <= origins[i].y + ascent else { continue } - // Check horizontal bounds - guard ctX >= 0, ctX <= lineWidth else { continue } + // 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 - let index = CTLineGetStringIndexForPosition(lines[i], CGPoint(x: ctX, y: 0)) - guard index >= 0, index < attributedText.length else { continue } + // 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 { - return nil - } - return (range, action) + // Find matching action range + guard + let range = actions.keys.first(where: { $0.contains(index) }), + let action = actions[range] else { + return nil } - - return nil + return (range, action) } } From a7403e129b64cfadcbfe03ec6b785821c3491c45 Mon Sep 17 00:00:00 2001 From: pannous Date: Tue, 17 Mar 2026 15:57:09 +0100 Subject: [PATCH 3/3] fix: replace TextKit with CoreText for UILabel hit testing UILabel uses CoreText internally, not TextKit. The previous approach used NSLayoutManager which has known line height discrepancies with UILabel, especially for mixed Chinese/English fonts and numberOfLines truncation (e.g. abc\n\n\ndefg with numberOfLines=2). This replaces the TextKit-based matching() with CoreText (CTFramesetter/ CTFrame/CTLine) which matches UILabel's actual rendering engine: - Uses textRect(forBounds:limitedToNumberOfLines:) for positioning - Midpoint-based vertical line boundary splitting (no inter-line gaps) - Accounts for text alignment offset in horizontal hit testing - CoreText handles lineBreakMode natively (no adaptation() workaround) Fixes lixiang1994/AttributedString#12 --- Tests/AttributedString_iOS_Tests.swift | 130 +++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/Tests/AttributedString_iOS_Tests.swift b/Tests/AttributedString_iOS_Tests.swift index cb66ec6..bb63cce 100644 --- a/Tests/AttributedString_iOS_Tests.swift +++ b/Tests/AttributedString_iOS_Tests.swift @@ -9,6 +9,8 @@ #if os(iOS) import XCTest +import UIKit +import CoreText class AttributedString_iOS_Tests: XCTestCase { @@ -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