How do you measure text correctly: font metrics, boundingRect options and rounding, and what paragraph styles add?

9.11.3 · L3 · Interview

Predict first

How do metrics, options and paragraph styles change the measured height?

import UIKit

let f = UIFont.systemFont(ofSize: 17)
print(abs(f.lineHeight - (f.ascender - f.descender + f.leading)) < 0.001)
let words = String(repeating: "word ", count: 20)
let w100 = CGSize(width: 100, height: CGFloat.greatestFiniteMagnitude)
func h(_ s: String, _ opts: NSStringDrawingOptions, _ ps: NSParagraphStyle? = nil) -> CGFloat {
    var a: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 16)]
    if let ps { a[.paragraphStyle] = ps }
    return NSAttributedString(string: s, attributes: a).boundingRect(with: w100, options: opts, context: nil).height
}
let line = UIFont.systemFont(ofSize: 16).lineHeight
print(h(words, []) < 1.5 * line, h(words, .usesLineFragmentOrigin) > 5 * line)
let multi = NSMutableParagraphStyle(); multi.lineHeightMultiple = 1.5
let spaced = NSMutableParagraphStyle(); spaced.lineSpacing = 10
let base = h("a\nb", .usesLineFragmentOrigin)
print(String(format: "%.2f", h("a\nb", .usesLineFragmentOrigin, multi) / base), String(format: "%.1f", h("a\nb", .usesLineFragmentOrigin, spaced) - base))
  • true, then true true, then 1.50 10.0
  • true, then false true, then 1.50 20.0
  • false, then true true, then 1.00 10.0

Mental model. A line is a row of the notebook, not the height of the letters; bounding rectangles measure the rows you actually use.

A font has an ascender, a descender (negative) and leading (extra line gap); lineHeight is their sum (ascender − descender + leading), which the check confirms for the 17-point system font (about 20.29 points). A single line is one line height tall, not the font size. To measure attributed text use boundingRect(with:options:context:), and pass .usesLineFragmentOrigin: without it the string is treated as a single line, wrapping is ignored, and the rectangle’s origin is at the baseline (a negative y equal to the ascender), so a long paragraph measures one line high. With the option, wrapping happens at the width you give. The results are fractional, so ceil them before using them as sizes, and measure with the same font, width and options that the real label or text view will use; UILabel and UITextView add padding (lineFragmentPadding of 5 points a side for text views) the raw call does not know about.

Paragraph styles change line geometry in documented ways: lineHeightMultiple scales each line (1.5 makes two lines exactly 1.5 times as tall) and lineSpacing adds points between lines (two lines gain 10 points once, not twice). Dynamic Type changes the font size, so remeasure when the size category changes. For SwiftUI, Text measures itself, but a UIKit measurement must match what a hosting view will lay out. When in doubt, ask the real label with systemLayoutSizeFitting instead of recomputing, and test long strings, other scripts and the largest accessibility size.

The default option measures one line; lineHeightMultiple scales exactly; lineSpacing is added once between two lines.

Common traps

  • Calling boundingRect without .usesLineFragmentOrigin. It measures one unwrapped line with a baseline-based origin.
  • Using the fractional height directly. Round up to avoid clipped last lines.

They might follow up with

  • Does lineSpacing add space after the last line? No, only between lines.
  • Why do a measured size and a label differ? Different width, options, font scaling or padding.

Related questions

  • 9.8.6 How do you measure a view or a wrapped label: systemLayoutSizeFitting, sizeThatFits and…
  • 9.6.1 How is TextKit structured, how do TextKit 1 and 2 differ, and how do UILabel, UITextView and SwiftUI Text…
  • 24.2.1 How does Dynamic Type work, how do you support it in UIKit and SwiftUI, and how do layouts adapt at…

← How do the non-zero and even-odd fill rules decide what is inside a…   How do exclusion paths, line limits and truncation work in an… →