Skip to content

Viewport Layout, Line Fragments, Fonts & Rendering

Use when the user needs to understand how Apple text actually renders on screen: viewport layout, line-fragment geometry, rendering attributes, font substitution, fixAttributes, scroll-driven layout, or TextKit versus Core Text drawing differences. Reach for this when the issue is rendering mechanics, not generic layout invalidation.

Reference Skills

Use when the user needs to understand how Apple text actually renders on screen: viewport layout, line-fragment geometry, rendering attributes, font substitution, fixAttributes, scroll-driven layout, or TextKit versus Core Text drawing differences. Reach for this when the issue is rendering mechanics, not generic layout invalidation.

Family: TextKit Runtime And Layout

Use this skill when the main question is how TextKit 2 viewport layout, fragments, and rendering behavior actually work.

  • You need fragment, line-fragment, or viewport-layout details.
  • You are debugging custom rendering or visual overlays.
  • You need to know why visible and off-screen layout behave differently.
  • Need full TextKit 2 object reference -> /skill apple-text-textkit2-ref
  • Need rendering and viewport behavior -> stay here
  • Need invalidation semantics rather than rendering pipeline details -> /skill apple-text-layout-invalidation

Keep this file for viewport behavior, fragment geometry, and the high-level rendering mental model. For font fallback timing, rendering-attribute APIs, custom drawing hooks, Core Text underpinnings, and emoji notes, use rendering-pipeline.md.

┌─────────────────────────────────────┐
│ Estimated Layout │ ← Heights estimated, not exact
│ (not computed) │
├─────────────────────────────────────┤
│ Overscroll Buffer (above) │ ← Computed, ready for scroll
├─────────────────────────────────────┤
│ ███ VIEWPORT (visible) ███ │ ← Fully laid out, rendered
├─────────────────────────────────────┤
│ Overscroll Buffer (below) │ ← Computed, ready for scroll
├─────────────────────────────────────┤
│ Estimated Layout │
│ (not computed) │
└─────────────────────────────────────┘

NSTextViewportLayoutController orchestrates this:

// Delegate callbacks during viewport layout:
// 1. Before layout begins
func textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) {
// Remove old fragment views
}
// 2. For EACH visible layout fragment
func textViewportLayoutController(_ controller: NSTextViewportLayoutController,
configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) {
// Position and configure the fragment's view/layer
let frame = textLayoutFragment.layoutFragmentFrame
fragmentView.frame = frame
}
// 3. After layout completes
func textViewportLayoutControllerDidLayout(_ controller: NSTextViewportLayoutController) {
// Update scroll view content size
let contentHeight = textLayoutManager.usageBoundsForTextContainer.height
scrollView.contentSize = CGSize(width: bounds.width, height: contentHeight)
}

Estimated heights are unstable:

  • usageBoundsForTextContainer changes frequently during scrolling
  • Usually overestimates initially, then settles as layout proceeds
  • Causes scroll bar to “jiggle” — knob size and position shift as estimates refine

Scroll bar accuracy:

  • Scroll bar position/size are inaccurate until full document is laid out
  • Users see the scroll bar “stop mid-scroll as if at document end” until layout catches up
  • Even Apple’s TextEdit exhibits this behavior

Jump-to-position:

  • Fragment positions are dynamic before full layout
  • Positions shift as surrounding content gets laid out
  • Precise jumping requires ensureLayout for the target range first

Without allowsNonContiguousLayout (contiguous):

  • Lays out ALL text from beginning to display point
  • Scrolling to mid-document requires laying out everything before it
  • O(document_size) for first display
  • Exact document height guaranteed

With allowsNonContiguousLayout = true:

  • Can skip layout for non-visible portions
  • UITextView enables this by default
  • Reliability issues: boundingRect and lineFragmentRect can return slightly wrong coordinates for long text (several thousand characters)
  • Less controllable than TextKit 2’s viewport model

UITextView.isScrollEnabled = false (Inside Another Scroll View)

Section titled “UITextView.isScrollEnabled = false (Inside Another Scroll View)”

When scrolling is disabled:

  • UITextView expands to fit its full content
  • The “viewport” is effectively the entire content
  • TextKit 2’s viewport optimization is neutralized — all content gets laid out
  • This is intentional — the view needs full layout for Auto Layout intrinsic size
  • scrollRangeToVisible() doesn’t work with scrolling disabled

TextKit 1: Line Fragment Rect vs Used Rect

Section titled “TextKit 1: Line Fragment Rect vs Used Rect”
Line Fragment Rect (full allocation):
┌─────────────────────────────────────────────┐
│ padding │ Hello World ░░░░░░░░ │ padding │ ← lineFragmentRect
│ └────────────────────┘ │
│ │← lineFragmentUsedRect →│ │
│ (includes leading, glyph bounds) │
│ │
│ ↑ paragraph spacing before │
│ ↓ paragraph spacing after (rect only) │
└─────────────────────────────────────────────┘
RectIncludesExcludes
lineFragmentRectPadding, text, leading, paragraph spacingNothing — full allocation
lineFragmentUsedRectPadding, text, leadingParagraph spacing, trailing whitespace

Why two rects? The used rect tells you where content actually is (for hit testing, cursor positioning). The full rect tells you the total space allocated (for stacking lines, backgrounds).

let lineFragment: NSTextLineFragment
lineFragment.typographicBounds // Rect: dimensions for geometry queries
lineFragment.glyphOrigin // Point: where glyphs start drawing
lineFragment.characterRange // Range: in the PARENT element's string (NOT document!)
lineFragment.attributedString // The line's OWN attributed string (separate copy)

Critical coordinate conversion:

Document coordinates
→ Layout fragment frame (layoutFragmentFrame)
→ Line fragment typographic bounds (relative to fragment)
→ Glyph origin (within line)

To get a point in document coordinates from a line fragment:

let docPoint = CGPoint(
x: layoutFragment.layoutFragmentFrame.origin.x + lineFragment.typographicBounds.origin.x + localPoint.x,
y: layoutFragment.layoutFragmentFrame.origin.y + lineFragment.typographicBounds.origin.y + localPoint.y
)
  • TextKit 1: Layout manager manages line fragments directly. No explicit paragraph grouping.
  • TextKit 2: NSTextLayoutFragment ≈ paragraph. Contains 1+ NSTextLineFragment for each visual line the paragraph wraps into.

When text ends with \n (or document is empty), an extra empty line fragment is generated for the cursor position:

  • TextKit 1: extraLineFragmentRect, extraLineFragmentUsedRect on NSLayoutManager
  • TextKit 2: Requires .ensuresExtraLineFragment option in enumeration. Known bug (FB15131180) where the frame may be incorrect.

When NSTextContainer.exclusionPaths contains paths, a single visual line can split into multiple line fragments:

┌──────────────────────────────────────┐
│ Text flows ┌──────┐ around the │
│ naturally │ IMAGE │ exclusion │
│ around the └──────┘ path here │
└──────────────────────────────────────┘

The text container’s lineFragmentRect(forProposedRect:at:writingDirection:remainingRect:) returns:

  1. The largest available rectangle not intersecting exclusion paths
  2. A remainder rectangle for content on the other side
textContainer.lineFragmentPadding = 5.0 // Default: 5.0 points
  • Insets text within the line fragment on each end
  • Purely visual — the fragment rect itself is not reduced
  • NOT for document margins — use textContainerInset on the text view
  • NOT for paragraph indentation — use NSParagraphStyle.headIndent
  1. renderingSurfaceBounds not expanded for custom fragments — Text clipped at diacritics, descenders, or custom backgrounds. Always expand if drawing outside the default bounds.
  2. NSTextLineFragment.characterRange is local — Relative to the line’s attributed string, NOT the document. Must convert through parent element.
  3. Assuming viewport layout means all text is laid out — Only visible + buffer is laid out. Off-screen metrics are estimates.
  4. Font changes in didProcessEditing — Bypass fixAttributes font substitution. Characters with missing glyphs may not render.
  5. Confusing line fragment padding with margins — Padding is small (5pt default) and internal to the fragment. Use textContainerInset for margins.
  6. Querying full document height in TextKit 2usageBoundsForTextContainer.height is an estimate. It changes as you scroll. If exact height is required, use TextKit 1.

This page documents the apple-text-viewport-rendering reference skill. Use it when the subsystem is already known and you need mechanics, behavior, or API detail.

  • apple-text-textkit2-ref: Use when the user is already on TextKit 2 and needs exact NSTextLayoutManager, NSTextContentManager, NSTextContentStorage, viewport layout, fragment, rendering-attribute, or migration details. Reach for this when the stack choice is already made and the task is reference-level TextKit 2 mechanics, not stack selection or generic text-system debugging.
  • apple-text-layout-invalidation: Use when text layout stays stale, metrics do not refresh, or the user needs the exact invalidation model in TextKit 1 or TextKit 2. Reach for this when the problem is layout recalculation and ensureLayout-style mechanics, not broader rendering or storage architecture.
  • apple-text-attachments-ref: Use when embedding inline non-text content such as images, custom views, Genmoji, or attachment-backed runs inside Apple text systems. Reach for this when the problem is attachment APIs, layout, bounds, baseline alignment, or lifecycle, not broader rich-text architecture.
  • skills/apple-text-viewport-rendering/rendering-pipeline.md
Full SKILL.md source
SKILL.md
---
name: apple-text-viewport-rendering
description: "Use when the user needs to understand how Apple text actually renders on screen: viewport layout, line-fragment geometry, rendering attributes, font substitution, fixAttributes, scroll-driven layout, or TextKit versus Core Text drawing differences. Reach for this when the issue is rendering mechanics, not generic layout invalidation."
license: MIT
---
# Viewport Layout, Line Fragments, Fonts & Rendering
Use this skill when the main question is how TextKit 2 viewport layout, fragments, and rendering behavior actually work.
## When to Use
- You need fragment, line-fragment, or viewport-layout details.
- You are debugging custom rendering or visual overlays.
- You need to know why visible and off-screen layout behave differently.
## Quick Decision
- Need full TextKit 2 object reference -> `/skill apple-text-textkit2-ref`
- Need rendering and viewport behavior -> stay here
- Need invalidation semantics rather than rendering pipeline details -> `/skill apple-text-layout-invalidation`
## Core Guidance
Keep this file for viewport behavior, fragment geometry, and the high-level rendering mental model. For font fallback timing, rendering-attribute APIs, custom drawing hooks, Core Text underpinnings, and emoji notes, use [rendering-pipeline.md](rendering-pipeline.md).
## Viewport Effects on Layout
### TextKit 2: Viewport-Based (Always)
```
┌─────────────────────────────────────┐
│ Estimated Layout │ ← Heights estimated, not exact
│ (not computed) │
├─────────────────────────────────────┤
│ Overscroll Buffer (above) │ ← Computed, ready for scroll
├─────────────────────────────────────┤
│ ███ VIEWPORT (visible) ███ │ ← Fully laid out, rendered
├─────────────────────────────────────┤
│ Overscroll Buffer (below) │ ← Computed, ready for scroll
├─────────────────────────────────────┤
│ Estimated Layout │
│ (not computed) │
└─────────────────────────────────────┘
```
**NSTextViewportLayoutController** orchestrates this:
```swift
// Delegate callbacks during viewport layout:
// 1. Before layout begins
func textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) {
// Remove old fragment views
}
// 2. For EACH visible layout fragment
func textViewportLayoutController(_ controller: NSTextViewportLayoutController,
configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) {
// Position and configure the fragment's view/layer
let frame = textLayoutFragment.layoutFragmentFrame
fragmentView.frame = frame
}
// 3. After layout completes
func textViewportLayoutControllerDidLayout(_ controller: NSTextViewportLayoutController) {
// Update scroll view content size
let contentHeight = textLayoutManager.usageBoundsForTextContainer.height
scrollView.contentSize = CGSize(width: bounds.width, height: contentHeight)
}
```
### TextKit 2 Viewport Gotchas
**Estimated heights are unstable:**
- `usageBoundsForTextContainer` changes frequently during scrolling
- Usually overestimates initially, then settles as layout proceeds
- Causes scroll bar to "jiggle" — knob size and position shift as estimates refine
**Scroll bar accuracy:**
- Scroll bar position/size are inaccurate until full document is laid out
- Users see the scroll bar "stop mid-scroll as if at document end" until layout catches up
- Even Apple's TextEdit exhibits this behavior
**Jump-to-position:**
- Fragment positions are dynamic before full layout
- Positions shift as surrounding content gets laid out
- Precise jumping requires `ensureLayout` for the target range first
### TextKit 1: Contiguous vs Non-Contiguous
**Without `allowsNonContiguousLayout` (contiguous):**
- Lays out ALL text from beginning to display point
- Scrolling to mid-document requires laying out everything before it
- O(document_size) for first display
- Exact document height guaranteed
**With `allowsNonContiguousLayout = true`:**
- Can skip layout for non-visible portions
- UITextView enables this by default
- **Reliability issues:** `boundingRect` and `lineFragmentRect` can return slightly wrong coordinates for long text (several thousand characters)
- Less controllable than TextKit 2's viewport model
### UITextView.isScrollEnabled = false (Inside Another Scroll View)
When scrolling is disabled:
- UITextView expands to fit its full content
- The "viewport" is effectively the entire content
- TextKit 2's viewport optimization is **neutralized** — all content gets laid out
- This is intentional — the view needs full layout for Auto Layout intrinsic size
- `scrollRangeToVisible()` doesn't work with scrolling disabled
## Line Fragments Deep Dive
### TextKit 1: Line Fragment Rect vs Used Rect
```
Line Fragment Rect (full allocation):
┌─────────────────────────────────────────────┐
│ padding │ Hello World ░░░░░░░░ │ padding │ ← lineFragmentRect
│ └────────────────────┘ │
│ │← lineFragmentUsedRect →│ │
│ (includes leading, glyph bounds) │
│ │
│ ↑ paragraph spacing before │
│ ↓ paragraph spacing after (rect only) │
└─────────────────────────────────────────────┘
```
| Rect | Includes | Excludes |
|------|----------|----------|
| **lineFragmentRect** | Padding, text, leading, paragraph spacing | Nothing — full allocation |
| **lineFragmentUsedRect** | Padding, text, leading | Paragraph spacing, trailing whitespace |
**Why two rects?** The used rect tells you where content actually is (for hit testing, cursor positioning). The full rect tells you the total space allocated (for stacking lines, backgrounds).
### TextKit 2: NSTextLineFragment
```swift
let lineFragment: NSTextLineFragment
lineFragment.typographicBounds // Rect: dimensions for geometry queries
lineFragment.glyphOrigin // Point: where glyphs start drawing
lineFragment.characterRange // Range: in the PARENT element's string (NOT document!)
lineFragment.attributedString // The line's OWN attributed string (separate copy)
```
**Critical coordinate conversion:**
```
Document coordinates
→ Layout fragment frame (layoutFragmentFrame)
→ Line fragment typographic bounds (relative to fragment)
→ Glyph origin (within line)
```
To get a point in document coordinates from a line fragment:
```swift
let docPoint = CGPoint(
x: layoutFragment.layoutFragmentFrame.origin.x + lineFragment.typographicBounds.origin.x + localPoint.x,
y: layoutFragment.layoutFragmentFrame.origin.y + lineFragment.typographicBounds.origin.y + localPoint.y
)
```
### Line Fragment and Paragraphs
- **TextKit 1:** Layout manager manages line fragments directly. No explicit paragraph grouping.
- **TextKit 2:** `NSTextLayoutFragment` ≈ paragraph. Contains 1+ `NSTextLineFragment` for each visual line the paragraph wraps into.
### Extra Line Fragment
When text ends with `\n` (or document is empty), an extra empty line fragment is generated for the cursor position:
- **TextKit 1:** `extraLineFragmentRect`, `extraLineFragmentUsedRect` on NSLayoutManager
- **TextKit 2:** Requires `.ensuresExtraLineFragment` option in enumeration. Known bug (FB15131180) where the frame may be incorrect.
### Exclusion Paths and Line Fragments
When `NSTextContainer.exclusionPaths` contains paths, a single visual line can split into multiple line fragments:
```
┌──────────────────────────────────────┐
│ Text flows ┌──────┐ around the │
│ naturally │ IMAGE │ exclusion │
│ around the └──────┘ path here │
└──────────────────────────────────────┘
```
The text container's `lineFragmentRect(forProposedRect:at:writingDirection:remainingRect:)` returns:
1. The largest available rectangle not intersecting exclusion paths
2. A **remainder rectangle** for content on the other side
### Line Fragment Padding
```swift
textContainer.lineFragmentPadding = 5.0 // Default: 5.0 points
```
- Insets text within the line fragment on each end
- Purely visual — the fragment rect itself is not reduced
- **NOT for document margins** — use `textContainerInset` on the text view
- **NOT for paragraph indentation** — use `NSParagraphStyle.headIndent`
## Common Pitfalls
1. **`renderingSurfaceBounds` not expanded for custom fragments** — Text clipped at diacritics, descenders, or custom backgrounds. Always expand if drawing outside the default bounds.
2. **NSTextLineFragment.characterRange is local** — Relative to the line's attributed string, NOT the document. Must convert through parent element.
3. **Assuming viewport layout means all text is laid out** — Only visible + buffer is laid out. Off-screen metrics are estimates.
4. **Font changes in didProcessEditing** — Bypass fixAttributes font substitution. Characters with missing glyphs may not render.
5. **Confusing line fragment padding with margins** — Padding is small (5pt default) and internal to the fragment. Use textContainerInset for margins.
6. **Querying full document height in TextKit 2**`usageBoundsForTextContainer.height` is an estimate. It changes as you scroll. If exact height is required, use TextKit 1.
## Related Skills
- For font fallback, rendering-attribute APIs, custom drawing hooks, and Core Text detail, see [rendering-pipeline.md](rendering-pipeline.md).
- Use `/skill apple-text-textkit2-ref` for the broader TextKit 2 API surface.
- Use `/skill apple-text-layout-invalidation` when the question is about what recomputes, not how it renders.
- Use `/skill apple-text-attachments-ref` when inline views or glyph-like content affect fragment behavior.