diff --git a/Sources/Extension/UIKit/UILabel/UILabelExtension.swift b/Sources/Extension/UIKit/UILabel/UILabelExtension.swift index 391cdcf..e74a24c 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,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..= 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 { 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; 居中的基线偏移 = 上边距 + 缩放的基线偏移 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