UILabel 很方便,但有幾件事它不告訴你:這段文字實際跑了幾行?每行的內容是什麼?每行佔的大小是多少?
這些需求在做客製化 UI 的時候常常會遇到,例如「超過三行就顯示漸層遮罩」、「第一行要特別標色」、「根據每行高度計算捲動位置」。UILabel 的公開 API 沒有提供,只好自己往下挖。
為什麼 UILabel 沒有這些 API
UILabel 的設計目標是顯示文字,不是提供排版資訊。實際的排版工作由底層的 CoreText 或 TextKit(NSLayoutManager) 完成,UILabel 把這些細節都封裝起來了。
要拿到行數和行內容,就必須繞過 UILabel,自己用同樣的文字和寬度重新跑一遍排版計算。
三個計算屬性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
| extension UILabel {
// MARK: - 行數
var lineCount: Int {
guard let text = attributedText else { return .zero }
let framesetter = CTFramesetterCreateWithAttributedString(text)
let fittingRect = CGRect(x: 0, y: 0, width: frame.width, height: .greatestFiniteMagnitude)
let path = UIBezierPath(rect: fittingRect)
let ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path.cgPath, nil)
let lines = CTFrameGetLines(ctFrame) as Array
return lines.count
}
// MARK: - 每行字串
var lineStrings: [String]? {
guard let text = text, let font = font else { return nil }
let attStr = NSMutableAttributedString(string: text)
attStr.addAttribute(.font, value: font, range: NSRange(location: 0, length: attStr.length))
let framesetter = CTFramesetterCreateWithAttributedString(attStr)
let fittingRect = CGRect(x: 0, y: 0, width: frame.width, height: .greatestFiniteMagnitude)
let path = UIBezierPath(rect: fittingRect)
let ctFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attStr.length), path.cgPath, nil)
guard let lines = CTFrameGetLines(ctFrame) as? [CTLine] else { return nil }
return lines.map { line in
let range = CTLineGetStringRange(line)
return (text as NSString).substring(with: NSRange(location: range.location, length: range.length))
}
}
// MARK: - 每行 Frame
var lineFrames: [CGRect] {
guard let text = attributedText else { return [] }
let layoutManager = NSLayoutManager()
let textStorage = NSTextStorage(attributedString: text)
textStorage.addLayoutManager(layoutManager)
let textContainer = NSTextContainer(size: CGSize(width: frame.width, height: .greatestFiniteMagnitude))
textContainer.lineFragmentPadding = 0
textContainer.lineBreakMode = .byWordWrapping
textContainer.maximumNumberOfLines = 0
layoutManager.addTextContainer(textContainer)
var result: [CGRect] = []
var index = 0
var lineRange = NSRange(location: NSNotFound, length: 0)
while index < layoutManager.numberOfGlyphs {
let rect = layoutManager.lineFragmentUsedRect(forGlyphAt: index, effectiveRange: &lineRange)
result.append(rect)
index = NSMaxRange(lineRange)
}
return result
}
}
|
為什麼 lineCount 和 lineStrings 用 CoreText,lineFrames 用 TextKit
CoreText 和 TextKit 是兩套不同層級的文字排版系統:
- CoreText:底層 C API,效能高,適合拿排版結果(行數、字串範圍)
- TextKit(NSLayoutManager):較高層的 Objective-C/Swift API,提供 glyph 層級的 frame 資訊
lineFragmentUsedRect 是 TextKit 的 API,CoreText 沒有對等的直接方法可以拿到每行實際佔用的 frame,所以 lineFrames 改用 TextKit。
注意事項
這三個屬性都依賴 frame.width,所以必須在 layout 完成之後 才能呼叫,否則 frame.width 會是 0,計算結果會錯。
1
2
3
4
5
6
| override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
print(label.lineCount) // ✅ layout 完成後才準確
print(label.lineStrings)
print(label.lineFrames)
}
|
本文使用 Claude 共同完成