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.
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.
When to Use
Section titled “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
Section titled “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
Section titled “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.
Viewport Effects on Layout
Section titled “Viewport Effects on Layout”TextKit 2: Viewport-Based (Always)
Section titled “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:
// Delegate callbacks during viewport layout:
// 1. Before layout beginsfunc textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) { // Remove old fragment views}
// 2. For EACH visible layout fragmentfunc textViewportLayoutController(_ controller: NSTextViewportLayoutController, configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) { // Position and configure the fragment's view/layer let frame = textLayoutFragment.layoutFragmentFrame fragmentView.frame = frame}
// 3. After layout completesfunc 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
Section titled “TextKit 2 Viewport Gotchas”Estimated heights are unstable:
usageBoundsForTextContainerchanges 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
ensureLayoutfor the target range first
TextKit 1: Contiguous vs Non-Contiguous
Section titled “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:
boundingRectandlineFragmentRectcan 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
Line Fragments Deep Dive
Section titled “Line Fragments Deep Dive”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) │└─────────────────────────────────────────────┘| 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
Section titled “TextKit 2: NSTextLineFragment”let lineFragment: NSTextLineFragment
lineFragment.typographicBounds // Rect: dimensions for geometry querieslineFragment.glyphOrigin // Point: where glyphs start drawinglineFragment.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)Line Fragment and Paragraphs
Section titled “Line Fragment and Paragraphs”- TextKit 1: Layout manager manages line fragments directly. No explicit paragraph grouping.
- TextKit 2:
NSTextLayoutFragment≈ paragraph. Contains 1+NSTextLineFragmentfor each visual line the paragraph wraps into.
Extra Line Fragment
Section titled “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,extraLineFragmentUsedRecton NSLayoutManager - TextKit 2: Requires
.ensuresExtraLineFragmentoption in enumeration. Known bug (FB15131180) where the frame may be incorrect.
Exclusion Paths and Line Fragments
Section titled “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:
- The largest available rectangle not intersecting exclusion paths
- A remainder rectangle for content on the other side
Line Fragment Padding
Section titled “Line Fragment Padding”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
textContainerInseton the text view - NOT for paragraph indentation — use
NSParagraphStyle.headIndent
Common Pitfalls
Section titled “Common Pitfalls”renderingSurfaceBoundsnot expanded for custom fragments — Text clipped at diacritics, descenders, or custom backgrounds. Always expand if drawing outside the default bounds.- NSTextLineFragment.characterRange is local — Relative to the line’s attributed string, NOT the document. Must convert through parent element.
- Assuming viewport layout means all text is laid out — Only visible + buffer is laid out. Off-screen metrics are estimates.
- Font changes in didProcessEditing — Bypass fixAttributes font substitution. Characters with missing glyphs may not render.
- Confusing line fragment padding with margins — Padding is small (5pt default) and internal to the fragment. Use textContainerInset for margins.
- Querying full document height in TextKit 2 —
usageBoundsForTextContainer.heightis an estimate. It changes as you scroll. If exact height is required, use TextKit 1.
Documentation Scope
Section titled “Documentation Scope”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.
Related
Section titled “Related”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.
Sidecar Files
Section titled “Sidecar Files”skills/apple-text-viewport-rendering/rendering-pipeline.md
Full SKILL.md source
---name: apple-text-viewport-renderingdescription: "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 beginsfunc textViewportLayoutControllerWillLayout(_ controller: NSTextViewportLayoutController) { // Remove old fragment views}
// 2. For EACH visible layout fragmentfunc textViewportLayoutController(_ controller: NSTextViewportLayoutController, configureRenderingSurfaceFor textLayoutFragment: NSTextLayoutFragment) { // Position and configure the fragment's view/layer let frame = textLayoutFragment.layoutFragmentFrame fragmentView.frame = frame}
// 3. After layout completesfunc 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
```swiftlet lineFragment: NSTextLineFragment
lineFragment.typographicBounds // Rect: dimensions for geometry querieslineFragment.glyphOrigin // Point: where glyphs start drawinglineFragment.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:```swiftlet 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 paths2. A **remainder rectangle** for content on the other side
### Line Fragment Padding
```swifttextContainer.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.