Skip to content

Exclusion Paths, Multi-Container & Text Tables

Use when wrapping text around images or shapes, creating multi-column text layout, linking text containers for magazine/book layout, configuring NSTextContainer exclusion paths, or building non-rectangular text regions. Covers exclusionPaths, linked containers, multi-container layout in TextKit 1 and 2, and NSTextBlock/NSTextTable for in-text tables.

Reference Skills

Use when wrapping text around images or shapes, creating multi-column text layout, linking text containers for magazine/book layout, configuring NSTextContainer exclusion paths, or building non-rectangular text regions. Covers exclusionPaths, linked containers, multi-container layout in TextKit 1 and 2, and NSTextBlock/NSTextTable for in-text tables.

Family: TextKit Runtime And Layout

Use this skill when text needs to flow around objects, across columns, or render as tables.

  • You need text to wrap around an image or arbitrary shape.
  • You need multi-column or multi-page text layout.
  • You need linked text containers (text flows from one to the next).
  • You need in-text tables using NSTextTable/NSTextBlock (AppKit).
  • You need non-rectangular text containers.
  • Text wraps around a shape -> exclusionPaths
  • Text flows across columns/pages -> linked NSTextContainer array
  • Table of data inside a text view -> NSTextTable + NSTextTableBlock (AppKit) or NSTextAttachmentViewProvider (UIKit)
  • Non-rectangular text region -> subclass NSTextContainer and override lineFragmentRect(forProposedRect:...)

NSTextContainer.exclusionPaths is an array of UIBezierPath/NSBezierPath objects that define “holes” where text cannot appear. The text system flows text around these shapes.

// Create a circular exclusion in the top-right corner
let circlePath = UIBezierPath(
ovalIn: CGRect(x: 200, y: 20, width: 120, height: 120)
)
textView.textContainer.exclusionPaths = [circlePath]

Text will wrap around the circle. Multiple paths are supported:

textView.textContainer.exclusionPaths = [imageRect, pullQuoteRect, sidebarRect]

Exclusion paths use the text container’s coordinate system, not the text view’s:

// Convert from text view coordinates to text container coordinates
let containerOrigin = textView.textContainerOrigin // UITextView
// or
let containerInset = textView.textContainerInset // UITextView
let containerPoint = CGPoint(
x: viewPoint.x - containerInset.left,
y: viewPoint.y - containerInset.top
)

Dynamic Exclusion Paths (Image Follows Scroll)

Section titled “Dynamic Exclusion Paths (Image Follows Scroll)”
func updateExclusionForFloatingImage(_ imageView: UIImageView) {
let imageFrame = textView.convert(imageView.frame, from: imageView.superview)
let containerInset = textView.textContainerInset
let exclusionRect = CGRect(
x: imageFrame.origin.x - containerInset.left,
y: imageFrame.origin.y - containerInset.top,
width: imageFrame.width + 8, // padding
height: imageFrame.height + 8
)
textView.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionRect)]
}

Performance warning: Changing exclusionPaths invalidates the entire layout. For frequently-moving exclusions (e.g., during scroll), batch updates and avoid per-frame changes.

// L-shaped exclusion
let path = UIBezierPath()
path.move(to: CGPoint(x: 150, y: 0))
path.addLine(to: CGPoint(x: 300, y: 0))
path.addLine(to: CGPoint(x: 300, y: 200))
path.addLine(to: CGPoint(x: 200, y: 200))
path.addLine(to: CGPoint(x: 200, y: 100))
path.addLine(to: CGPoint(x: 150, y: 100))
path.close()
textView.textContainer.exclusionPaths = [path]
BehaviorTextKit 1TextKit 2
exclusionPaths propertyYesYes
Re-layout on changeFull relayoutViewport relayout
Performance with many pathsDegradesBetter (viewport-scoped)
Custom container subclassOverride lineFragmentRect(forProposedRect:...)Same method

A single NSLayoutManager (TK1) or NSTextLayoutManager (TK2) can manage text across multiple NSTextContainer instances. When text overflows the first container, it flows into the second, and so on. This is how you build multi-column, multi-page, or magazine-style layouts.

let textStorage = NSTextStorage(attributedString: content)
let layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)
// Column 1
let container1 = NSTextContainer(size: CGSize(width: 300, height: 500))
layoutManager.addTextContainer(container1)
let textView1 = UITextView(frame: .zero, textContainer: container1)
// Column 2
let container2 = NSTextContainer(size: CGSize(width: 300, height: 500))
layoutManager.addTextContainer(container2)
let textView2 = UITextView(frame: .zero, textContainer: container2)
// Text automatically flows from container1 -> container2

Key rules:

  • Container order matters — layoutManager.textContainers is an ordered array
  • Text fills containers in order; overflow goes to the next
  • Each container can have its own exclusionPaths
  • Each container gets its own UITextView/NSTextView
  • You manage the views’ frames yourself (the text system only handles text flow)

TextKit 2 uses a slightly different model. NSTextLayoutManager manages a single NSTextContainer by default, but you can use NSTextContentManager with multiple layout managers:

let contentManager = NSTextContentStorage()
contentManager.attributedString = content
// Layout manager per column
let layoutManager1 = NSTextLayoutManager()
let container1 = NSTextContainer(size: CGSize(width: 300, height: 500))
layoutManager1.textContainer = container1
contentManager.addTextLayoutManager(layoutManager1)
let layoutManager2 = NSTextLayoutManager()
let container2 = NSTextContainer(size: CGSize(width: 300, height: 500))
layoutManager2.textContainer = container2
contentManager.addTextLayoutManager(layoutManager2)
// TextKit 1: Check if text overflows a container
let glyphRange = layoutManager.glyphRange(for: container1)
let charRange = layoutManager.characterRange(forGlyphRange: glyphRange,
actualGlyphRange: nil)
let hasOverflow = charRange.upperBound < textStorage.length
class TwoColumnView: UIView {
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
var leftTextView: UITextView!
var rightTextView: UITextView!
func setup() {
textStorage.addLayoutManager(layoutManager)
let leftContainer = NSTextContainer(size: .zero)
leftContainer.widthTracksTextView = true
leftContainer.heightTracksTextView = true
layoutManager.addTextContainer(leftContainer)
leftTextView = UITextView(frame: .zero, textContainer: leftContainer)
leftTextView.isEditable = false
addSubview(leftTextView)
let rightContainer = NSTextContainer(size: .zero)
rightContainer.widthTracksTextView = true
rightContainer.heightTracksTextView = true
layoutManager.addTextContainer(rightContainer)
rightTextView = UITextView(frame: .zero, textContainer: rightContainer)
rightTextView.isEditable = false
addSubview(rightTextView)
}
override func layoutSubviews() {
super.layoutSubviews()
let columnWidth = (bounds.width - 16) / 2 // 16pt gap
leftTextView.frame = CGRect(x: 0, y: 0,
width: columnWidth, height: bounds.height)
rightTextView.frame = CGRect(x: columnWidth + 16, y: 0,
width: columnWidth, height: bounds.height)
}
}

For truly non-rectangular text regions (circular, triangular, path-based):

class CircularTextContainer: NSTextContainer {
override func lineFragmentRect(
forProposedRect proposedRect: CGRect,
at characterIndex: Int,
writingDirection baseWritingDirection: NSWritingDirection,
remaining remainingRect: UnsafeMutablePointer<CGRect>?
) -> CGRect {
// Start with the standard rect
var result = super.lineFragmentRect(
forProposedRect: proposedRect,
at: characterIndex,
writingDirection: baseWritingDirection,
remaining: remainingRect
)
// Constrain to a circle
let center = CGPoint(x: size.width / 2, y: size.height / 2)
let radius = min(size.width, size.height) / 2
let y = proposedRect.origin.y + proposedRect.height / 2
let dy = y - center.y
guard abs(dy) < radius else { return .zero }
let dx = sqrt(radius * radius - dy * dy)
let minX = center.x - dx + lineFragmentPadding
let maxX = center.x + dx - lineFragmentPadding
result.origin.x = minX
result.size.width = maxX - minX
return result
}
override var isSimpleRectangularTextContainer: Bool { false }
}

isSimpleRectangularTextContainer — Return false when you override lineFragmentRect(forProposedRect:...). This tells the text system it can’t take layout shortcuts.

AppKit provides NSTextTable and NSTextTableBlock for rendering tables directly inside attributed strings. These are paragraph-level attributes — each table cell is a paragraph whose NSParagraphStyle.textBlocks includes an NSTextTableBlock.

Platform availability: Primarily AppKit (NSTextView). UIKit has the classes but rendering support is limited.

// Create a 3-column table
let table = NSTextTable()
table.numberOfColumns = 3
table.collapsesBorders = true
// Create a cell: row 0, column 0
let cell = NSTextTableBlock(table: table,
startingRow: 0, rowSpan: 1,
startingColumn: 0, columnSpan: 1)
cell.setContentWidth(33.33, type: .percentageValueType)
cell.backgroundColor = .controlBackgroundColor
cell.setWidth(0.5, type: .absoluteValueType, for: .border)
cell.setBorderColor(.separatorColor)
cell.setValue(4, type: .absoluteValueType, for: .padding)
// Attach to paragraph style
let style = NSMutableParagraphStyle()
style.textBlocks = [cell]
// Create the cell content
let cellText = NSAttributedString(
string: "Cell content\n", // Note: must end with newline
attributes: [
.paragraphStyle: style,
.font: NSFont.systemFont(ofSize: 13)
]
)
func makeTable(rows: Int, columns: Int, data: [[String]]) -> NSAttributedString {
let table = NSTextTable()
table.numberOfColumns = columns
table.collapsesBorders = true
let result = NSMutableAttributedString()
for row in 0..<rows {
for col in 0..<columns {
let cell = NSTextTableBlock(table: table,
startingRow: row, rowSpan: 1,
startingColumn: col, columnSpan: 1)
cell.setContentWidth(CGFloat(100 / columns),
type: .percentageValueType)
cell.setValue(4, type: .absoluteValueType, for: .padding)
cell.setWidth(0.5, type: .absoluteValueType, for: .border)
cell.setBorderColor(.separatorColor)
if row == 0 {
cell.backgroundColor = .controlAccentColor.withAlphaComponent(0.1)
}
let style = NSMutableParagraphStyle()
style.textBlocks = [cell]
let text = data[row][col] + "\n" // Each cell ends with newline
result.append(NSAttributedString(string: text, attributes: [
.paragraphStyle: style,
.font: row == 0 ? NSFont.boldSystemFont(ofSize: 13)
: NSFont.systemFont(ofSize: 13)
]))
}
}
return result
}
PropertyPurpose
backgroundColorCell background color
setBorderColor(_:for:)Per-edge border color
setWidth(_:type:for:edge:)Margin, border, or padding per edge
setWidth(_:type:for:)Margin, border, or padding for all edges of a layer
setContentWidth(_:type:)Cell content width (absolute or percentage)
verticalAlignment.top, .middle, .bottom, .baseline
setValue(_:type:for:)Set dimension values (minWidth, maxWidth, minHeight, maxHeight)
cell.setWidth(1, type: .absoluteValueType, for: .border) // Border layer
cell.setValue(8, type: .absoluteValueType, for: .padding) // Padding layer
cell.setValue(4, type: .absoluteValueType, for: .margin) // Margin layer

Since UIKit doesn’t fully support NSTextTable rendering, use NSTextAttachmentViewProvider (TextKit 2) to embed a UITableView or custom view:

// See /skill apple-text-attachments-ref for full NSTextAttachmentViewProvider pattern
class TableAttachmentViewProvider: NSTextAttachmentViewProvider {
override func loadView() {
let tableView = MyCompactTableView(data: extractData(from: textAttachment))
view = tableView
}
override func attachmentBounds(
for attributes: [NSAttributedString.Key: Any],
location: NSTextLocation,
textContainer: NSTextContainer?,
proposedLineFragment: CGRect,
position: CGPoint
) -> CGRect {
// Full-width, calculated height
let width = proposedLineFragment.width
let height = calculateTableHeight(for: width)
return CGRect(x: 0, y: 0, width: width, height: height)
}
}

For ordered/unordered lists inside attributed strings:

let list = NSTextList(markerFormat: .decimal, options: 0)
list.startingItemNumber = 1
let style = NSMutableParagraphStyle()
style.textLists = [list]
style.headIndent = 24 // Indent for wrapped lines
style.firstLineHeadIndent = 0 // Marker hangs in the margin
let item = NSAttributedString(
string: "\t\(list.marker(forItemNumber: 1))\tFirst item\n",
attributes: [.paragraphStyle: style, .font: UIFont.systemFont(ofSize: 15)]
)
FormatAppearance
.decimal1. 2. 3.
.octal1. 2. 3. (base 8)
.lowercaseAlphaa. b. c.
.uppercaseAlphaA. B. C.
.lowercaseRomani. ii. iii.
.uppercaseRomanI. II. III.
.discbullet (filled circle)
.circleopen circle
.squarefilled square
.diamonddiamond
.hyphenhyphen
let outerList = NSTextList(markerFormat: .decimal, options: 0)
let innerList = NSTextList(markerFormat: .lowercaseAlpha, options: 0)
let innerStyle = NSMutableParagraphStyle()
innerStyle.textLists = [outerList, innerList] // Nesting = array order
innerStyle.headIndent = 48 // Double indent
  1. Exclusion path coordinates — Must be in text container coordinates, not view coordinates. Account for textContainerInset and textContainerOrigin.

  2. Exclusion path performance — Each change triggers full relayout in TextKit 1. Batch changes; don’t update per-frame during animations.

  3. NSTextTable on UIKit — The classes exist but rendering is incomplete. Use attachment view providers on iOS instead.

  4. Each table cell must end with \n — NSTextTable cells are paragraph-level. Missing the trailing newline merges cells.

  5. Multi-container editing — Editing in linked containers is fragile. Works well for read-only; editing across container boundaries requires careful cursor management.

  6. isSimpleRectangularTextContainer — If you subclass NSTextContainer and override lineFragmentRect, you must return false or layout may use incorrect fast paths.

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

  • 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.
  • apple-text-viewport-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.
  • apple-text-formatting-ref: Use when the user already knows the formatting problem and needs exact text-formatting attributes such as NSAttributedString.Key values, underline styles, shadows, lists, tables, or view-compatibility rules. Reach for this when the job is verifying concrete formatting APIs, not choosing the text model.
Full SKILL.md source
SKILL.md
---
name: apple-text-exclusion-paths
description: >
Use when wrapping text around images or shapes, creating multi-column text layout,
linking text containers for magazine/book layout, configuring NSTextContainer
exclusion paths, or building non-rectangular text regions. Covers exclusionPaths,
linked containers, multi-container layout in TextKit 1 and 2, and NSTextBlock/NSTextTable
for in-text tables.
license: MIT
---
# Exclusion Paths, Multi-Container & Text Tables
Use this skill when text needs to flow around objects, across columns, or render as tables.
## When to Use
- You need text to wrap around an image or arbitrary shape.
- You need multi-column or multi-page text layout.
- You need linked text containers (text flows from one to the next).
- You need in-text tables using NSTextTable/NSTextBlock (AppKit).
- You need non-rectangular text containers.
## Quick Decision
- Text wraps around a shape -> `exclusionPaths`
- Text flows across columns/pages -> linked `NSTextContainer` array
- Table of data inside a text view -> `NSTextTable` + `NSTextTableBlock` (AppKit) or `NSTextAttachmentViewProvider` (UIKit)
- Non-rectangular text region -> subclass `NSTextContainer` and override `lineFragmentRect(forProposedRect:...)`
## Exclusion Paths
### What They Are
`NSTextContainer.exclusionPaths` is an array of `UIBezierPath`/`NSBezierPath` objects that define "holes" where text cannot appear. The text system flows text around these shapes.
### Basic Usage
```swift
// Create a circular exclusion in the top-right corner
let circlePath = UIBezierPath(
ovalIn: CGRect(x: 200, y: 20, width: 120, height: 120)
)
textView.textContainer.exclusionPaths = [circlePath]
```
Text will wrap around the circle. Multiple paths are supported:
```swift
textView.textContainer.exclusionPaths = [imageRect, pullQuoteRect, sidebarRect]
```
### Coordinate System
Exclusion paths use the **text container's coordinate system**, not the text view's:
```swift
// Convert from text view coordinates to text container coordinates
let containerOrigin = textView.textContainerOrigin // UITextView
// or
let containerInset = textView.textContainerInset // UITextView
let containerPoint = CGPoint(
x: viewPoint.x - containerInset.left,
y: viewPoint.y - containerInset.top
)
```
### Dynamic Exclusion Paths (Image Follows Scroll)
```swift
func updateExclusionForFloatingImage(_ imageView: UIImageView) {
let imageFrame = textView.convert(imageView.frame, from: imageView.superview)
let containerInset = textView.textContainerInset
let exclusionRect = CGRect(
x: imageFrame.origin.x - containerInset.left,
y: imageFrame.origin.y - containerInset.top,
width: imageFrame.width + 8, // padding
height: imageFrame.height + 8
)
textView.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionRect)]
}
```
**Performance warning:** Changing `exclusionPaths` invalidates the entire layout. For frequently-moving exclusions (e.g., during scroll), batch updates and avoid per-frame changes.
### Complex Shapes
```swift
// L-shaped exclusion
let path = UIBezierPath()
path.move(to: CGPoint(x: 150, y: 0))
path.addLine(to: CGPoint(x: 300, y: 0))
path.addLine(to: CGPoint(x: 300, y: 200))
path.addLine(to: CGPoint(x: 200, y: 200))
path.addLine(to: CGPoint(x: 200, y: 100))
path.addLine(to: CGPoint(x: 150, y: 100))
path.close()
textView.textContainer.exclusionPaths = [path]
```
### TextKit 1 vs TextKit 2
| Behavior | TextKit 1 | TextKit 2 |
|----------|-----------|-----------|
| `exclusionPaths` property | Yes | Yes |
| Re-layout on change | Full relayout | Viewport relayout |
| Performance with many paths | Degrades | Better (viewport-scoped) |
| Custom container subclass | Override `lineFragmentRect(forProposedRect:...)` | Same method |
## Multi-Container (Linked) Layout
### What It Is
A single `NSLayoutManager` (TK1) or `NSTextLayoutManager` (TK2) can manage text across **multiple** `NSTextContainer` instances. When text overflows the first container, it flows into the second, and so on. This is how you build multi-column, multi-page, or magazine-style layouts.
### TextKit 1 — Multiple Containers
```swift
let textStorage = NSTextStorage(attributedString: content)
let layoutManager = NSLayoutManager()
textStorage.addLayoutManager(layoutManager)
// Column 1
let container1 = NSTextContainer(size: CGSize(width: 300, height: 500))
layoutManager.addTextContainer(container1)
let textView1 = UITextView(frame: .zero, textContainer: container1)
// Column 2
let container2 = NSTextContainer(size: CGSize(width: 300, height: 500))
layoutManager.addTextContainer(container2)
let textView2 = UITextView(frame: .zero, textContainer: container2)
// Text automatically flows from container1 -> container2
```
**Key rules:**
- Container order matters — `layoutManager.textContainers` is an ordered array
- Text fills containers in order; overflow goes to the next
- Each container can have its own `exclusionPaths`
- Each container gets its own `UITextView`/`NSTextView`
- You manage the views' frames yourself (the text system only handles text flow)
### TextKit 2 — Multiple Containers
TextKit 2 uses a slightly different model. `NSTextLayoutManager` manages a single `NSTextContainer` by default, but you can use `NSTextContentManager` with multiple layout managers:
```swift
let contentManager = NSTextContentStorage()
contentManager.attributedString = content
// Layout manager per column
let layoutManager1 = NSTextLayoutManager()
let container1 = NSTextContainer(size: CGSize(width: 300, height: 500))
layoutManager1.textContainer = container1
contentManager.addTextLayoutManager(layoutManager1)
let layoutManager2 = NSTextLayoutManager()
let container2 = NSTextContainer(size: CGSize(width: 300, height: 500))
layoutManager2.textContainer = container2
contentManager.addTextLayoutManager(layoutManager2)
```
### Detecting Overflow
```swift
// TextKit 1: Check if text overflows a container
let glyphRange = layoutManager.glyphRange(for: container1)
let charRange = layoutManager.characterRange(forGlyphRange: glyphRange,
actualGlyphRange: nil)
let hasOverflow = charRange.upperBound < textStorage.length
```
### Practical: Two-Column Layout
```swift
class TwoColumnView: UIView {
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
var leftTextView: UITextView!
var rightTextView: UITextView!
func setup() {
textStorage.addLayoutManager(layoutManager)
let leftContainer = NSTextContainer(size: .zero)
leftContainer.widthTracksTextView = true
leftContainer.heightTracksTextView = true
layoutManager.addTextContainer(leftContainer)
leftTextView = UITextView(frame: .zero, textContainer: leftContainer)
leftTextView.isEditable = false
addSubview(leftTextView)
let rightContainer = NSTextContainer(size: .zero)
rightContainer.widthTracksTextView = true
rightContainer.heightTracksTextView = true
layoutManager.addTextContainer(rightContainer)
rightTextView = UITextView(frame: .zero, textContainer: rightContainer)
rightTextView.isEditable = false
addSubview(rightTextView)
}
override func layoutSubviews() {
super.layoutSubviews()
let columnWidth = (bounds.width - 16) / 2 // 16pt gap
leftTextView.frame = CGRect(x: 0, y: 0,
width: columnWidth, height: bounds.height)
rightTextView.frame = CGRect(x: columnWidth + 16, y: 0,
width: columnWidth, height: bounds.height)
}
}
```
## NSTextContainer Subclassing
For truly non-rectangular text regions (circular, triangular, path-based):
```swift
class CircularTextContainer: NSTextContainer {
override func lineFragmentRect(
forProposedRect proposedRect: CGRect,
at characterIndex: Int,
writingDirection baseWritingDirection: NSWritingDirection,
remaining remainingRect: UnsafeMutablePointer<CGRect>?
) -> CGRect {
// Start with the standard rect
var result = super.lineFragmentRect(
forProposedRect: proposedRect,
at: characterIndex,
writingDirection: baseWritingDirection,
remaining: remainingRect
)
// Constrain to a circle
let center = CGPoint(x: size.width / 2, y: size.height / 2)
let radius = min(size.width, size.height) / 2
let y = proposedRect.origin.y + proposedRect.height / 2
let dy = y - center.y
guard abs(dy) < radius else { return .zero }
let dx = sqrt(radius * radius - dy * dy)
let minX = center.x - dx + lineFragmentPadding
let maxX = center.x + dx - lineFragmentPadding
result.origin.x = minX
result.size.width = maxX - minX
return result
}
override var isSimpleRectangularTextContainer: Bool { false }
}
```
**`isSimpleRectangularTextContainer`** — Return `false` when you override `lineFragmentRect(forProposedRect:...)`. This tells the text system it can't take layout shortcuts.
## NSTextTable / NSTextBlock (AppKit)
### What They Are
AppKit provides `NSTextTable` and `NSTextTableBlock` for rendering tables directly inside attributed strings. These are **paragraph-level attributes** — each table cell is a paragraph whose `NSParagraphStyle.textBlocks` includes an `NSTextTableBlock`.
**Platform availability:** Primarily AppKit (NSTextView). UIKit has the classes but rendering support is limited.
### Creating a Table
```swift
// Create a 3-column table
let table = NSTextTable()
table.numberOfColumns = 3
table.collapsesBorders = true
// Create a cell: row 0, column 0
let cell = NSTextTableBlock(table: table,
startingRow: 0, rowSpan: 1,
startingColumn: 0, columnSpan: 1)
cell.setContentWidth(33.33, type: .percentageValueType)
cell.backgroundColor = .controlBackgroundColor
cell.setWidth(0.5, type: .absoluteValueType, for: .border)
cell.setBorderColor(.separatorColor)
cell.setValue(4, type: .absoluteValueType, for: .padding)
// Attach to paragraph style
let style = NSMutableParagraphStyle()
style.textBlocks = [cell]
// Create the cell content
let cellText = NSAttributedString(
string: "Cell content\n", // Note: must end with newline
attributes: [
.paragraphStyle: style,
.font: NSFont.systemFont(ofSize: 13)
]
)
```
### Building a Full Table
```swift
func makeTable(rows: Int, columns: Int, data: [[String]]) -> NSAttributedString {
let table = NSTextTable()
table.numberOfColumns = columns
table.collapsesBorders = true
let result = NSMutableAttributedString()
for row in 0..<rows {
for col in 0..<columns {
let cell = NSTextTableBlock(table: table,
startingRow: row, rowSpan: 1,
startingColumn: col, columnSpan: 1)
cell.setContentWidth(CGFloat(100 / columns),
type: .percentageValueType)
cell.setValue(4, type: .absoluteValueType, for: .padding)
cell.setWidth(0.5, type: .absoluteValueType, for: .border)
cell.setBorderColor(.separatorColor)
if row == 0 {
cell.backgroundColor = .controlAccentColor.withAlphaComponent(0.1)
}
let style = NSMutableParagraphStyle()
style.textBlocks = [cell]
let text = data[row][col] + "\n" // Each cell ends with newline
result.append(NSAttributedString(string: text, attributes: [
.paragraphStyle: style,
.font: row == 0 ? NSFont.boldSystemFont(ofSize: 13)
: NSFont.systemFont(ofSize: 13)
]))
}
}
return result
}
```
### NSTextBlock Properties
| Property | Purpose |
|----------|---------|
| `backgroundColor` | Cell background color |
| `setBorderColor(_:for:)` | Per-edge border color |
| `setWidth(_:type:for:edge:)` | Margin, border, or padding per edge |
| `setWidth(_:type:for:)` | Margin, border, or padding for all edges of a layer |
| `setContentWidth(_:type:)` | Cell content width (absolute or percentage) |
| `verticalAlignment` | `.top`, `.middle`, `.bottom`, `.baseline` |
| `setValue(_:type:for:)` | Set dimension values (minWidth, maxWidth, minHeight, maxHeight) |
### NSTextBlock.Layer
```swift
cell.setWidth(1, type: .absoluteValueType, for: .border) // Border layer
cell.setValue(8, type: .absoluteValueType, for: .padding) // Padding layer
cell.setValue(4, type: .absoluteValueType, for: .margin) // Margin layer
```
### UIKit Alternative: Tables via Attachments
Since UIKit doesn't fully support NSTextTable rendering, use `NSTextAttachmentViewProvider` (TextKit 2) to embed a `UITableView` or custom view:
```swift
// See /skill apple-text-attachments-ref for full NSTextAttachmentViewProvider pattern
class TableAttachmentViewProvider: NSTextAttachmentViewProvider {
override func loadView() {
let tableView = MyCompactTableView(data: extractData(from: textAttachment))
view = tableView
}
override func attachmentBounds(
for attributes: [NSAttributedString.Key: Any],
location: NSTextLocation,
textContainer: NSTextContainer?,
proposedLineFragment: CGRect,
position: CGPoint
) -> CGRect {
// Full-width, calculated height
let width = proposedLineFragment.width
let height = calculateTableHeight(for: width)
return CGRect(x: 0, y: 0, width: width, height: height)
}
}
```
## NSTextList
For ordered/unordered lists inside attributed strings:
```swift
let list = NSTextList(markerFormat: .decimal, options: 0)
list.startingItemNumber = 1
let style = NSMutableParagraphStyle()
style.textLists = [list]
style.headIndent = 24 // Indent for wrapped lines
style.firstLineHeadIndent = 0 // Marker hangs in the margin
let item = NSAttributedString(
string: "\t\(list.marker(forItemNumber: 1))\tFirst item\n",
attributes: [.paragraphStyle: style, .font: UIFont.systemFont(ofSize: 15)]
)
```
### Marker Formats
| Format | Appearance |
|--------|-----------|
| `.decimal` | 1. 2. 3. |
| `.octal` | 1. 2. 3. (base 8) |
| `.lowercaseAlpha` | a. b. c. |
| `.uppercaseAlpha` | A. B. C. |
| `.lowercaseRoman` | i. ii. iii. |
| `.uppercaseRoman` | I. II. III. |
| `.disc` | bullet (filled circle) |
| `.circle` | open circle |
| `.square` | filled square |
| `.diamond` | diamond |
| `.hyphen` | hyphen |
### Nested Lists
```swift
let outerList = NSTextList(markerFormat: .decimal, options: 0)
let innerList = NSTextList(markerFormat: .lowercaseAlpha, options: 0)
let innerStyle = NSMutableParagraphStyle()
innerStyle.textLists = [outerList, innerList] // Nesting = array order
innerStyle.headIndent = 48 // Double indent
```
## Pitfalls
1. **Exclusion path coordinates** — Must be in text container coordinates, not view coordinates. Account for `textContainerInset` and `textContainerOrigin`.
2. **Exclusion path performance** — Each change triggers full relayout in TextKit 1. Batch changes; don't update per-frame during animations.
3. **NSTextTable on UIKit** — The classes exist but rendering is incomplete. Use attachment view providers on iOS instead.
4. **Each table cell must end with `\n`** — NSTextTable cells are paragraph-level. Missing the trailing newline merges cells.
5. **Multi-container editing** — Editing in linked containers is fragile. Works well for read-only; editing across container boundaries requires careful cursor management.
6. **`isSimpleRectangularTextContainer`** — If you subclass `NSTextContainer` and override `lineFragmentRect`, you must return `false` or layout may use incorrect fast paths.
## Related Skills
- For embedding interactive views inline -> `/skill apple-text-attachments-ref`
- For viewport-scoped layout with exclusions -> `/skill apple-text-viewport-rendering`
- For paragraph style formatting details -> `/skill apple-text-formatting-ref`
- For layout invalidation after changing exclusion paths -> `/skill apple-text-layout-invalidation`