Joe's Blog

iOS 開發筆記

UILabel 計算行數、每行字串、每行大小

發佈於 2020-07-01
2026-06-13 更新

UILabel 很方便,但有幾件事它不告訴你:這段文字實際跑了幾行?每行的內容是什麼?每行佔的大小是多少?

這些需求在做客製化 UI 的時候常常會遇到,例如「超過三行就顯示漸層遮罩」、「第一行要特別標色」、「根據每行高度計算捲動位置」。UILabel 的公開 API 沒有提供,只好自己往下挖。


為什麼 UILabel 沒有這些 API

UILabel 的設計目標是顯示文字,不是提供排版資訊。實際的排版工作由底層的 CoreTextTextKit(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 是兩套不同層級的文字排版系統:

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 共同完成