How do exclusion paths, line limits and truncation work in an NSTextContainer, and what do UILabel’s own limits do?
Predict first
What do an exclusion path and a line limit do, and what does a label report for a three-line limit?
import UIKit
func lineCount(_ lm: NSLayoutManager) -> Int {
var n = 0
lm.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: lm.numberOfGlyphs)) { _, _, _, _, _ in n += 1 }
return n
}
let text = String(repeating: "alpha beta gamma delta ", count: 12)
let font = UIFont.systemFont(ofSize: 16)
let store = NSTextStorage(string: text, attributes: [.font: font])
let lm = NSLayoutManager(); store.addLayoutManager(lm)
let tc = NSTextContainer(size: CGSize(width: 200, height: CGFloat.greatestFiniteMagnitude)); lm.addTextContainer(tc)
let base = lineCount(lm)
tc.exclusionPaths = [UIBezierPath(rect: CGRect(x: 100, y: 0, width: 100, height: 60))]
print(lineCount(lm) > base, tc.lineBreakMode.rawValue)
tc.exclusionPaths = []; tc.maximumNumberOfLines = 3
print(lm.glyphRange(for: tc).length < lm.numberOfGlyphs)
let l = UILabel(); l.font = font; l.text = text
let box = CGRect(x: 0, y: 0, width: 200, height: 1000)
print(Int((l.textRect(forBounds: box, limitedToNumberOfLines: 3).height / font.lineHeight).rounded()), l.lineBreakMode.rawValue, l.numberOfLines)
- true 0, then true, then 3 4 1
- false 0, then false, then 12 0 0
- true 4, then true, then 3 0 1
Mental model. The container is a pond with islands: the text water flows around the exclusion rocks and stops at the line-limit shore.
In TextKit the container decides where lines may go. An NSTextContainer has a size (the wrapping width), exclusionPaths (regions the text must flow around, in the container’s coordinates), maximumNumberOfLines (0 means unlimited) and a lineBreakMode (word wrapping by default; set truncation to mark where text was cut). The check lays out a paragraph at width 200: adding a 100×60 exclusion path on the right makes lines narrower and increases the line count (12 to 15 for that text) and the used height; setting maximumNumberOfLines stops layout early, so the laid-out glyph range is shorter than the string; and with a truncating mode the layout manager reports a truncatedGlyphRange on the last line. Everything is in terms of NSLayoutManager here (TextKit 1), the same engine UITextView uses once you touch its layoutManager; TextKit 2 offers the same ideas through layout fragments.
UILabel is the same thing with fewer knobs: numberOfLines, lineBreakMode (default truncating the tail), adjustsFontSizeToFitWidth with a minimumScaleFactor (single-line shrink-to-fit), and textRect(forBounds:limitedToNumberOfLines:) to ask for the rectangle a limited number of lines would occupy. In the check a three-line limit asks for exactly three line heights, against about twelve lines without a limit. Use exclusion paths for wrapping around images, a line limit plus a “more” button for expandable text, and always give truncated text an accessible full version.
Exclusion adds lines, the limit shortens the laid-out range, and a label’s default is truncating the tail (raw 4) on one line.
Common traps
- Setting exclusion paths in the wrong coordinate space. They are in the container’s space, which is offset by the text view’s text container inset.
- Truncating without offering the full text. Provide a way to expand it, and an accessibility value.
They might follow up with
- What gives a single line shrink-to-fit?
adjustsFontSizeToFitWidthwith a minimum scale factor. - How do you ask where text breaks? Enumerate line fragments on the layout manager.
Related questions
- 9.6.1 How is TextKit structured, how do TextKit 1 and 2 differ, and how do UILabel, UITextView and SwiftUI Text…
- 9.11.3 How do you measure text correctly: font metrics, boundingRect options and rounding, and what paragraph styles…
- 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 you measure text correctly: font metrics, boundingRect options… What does an image cost in pixels, what does opaque change in a… →