apple-text-exclusion-paths
Use when text needs to wrap around images or shapes, building multi-column text layout, or using exclusion paths, linked containers, and NSTextTable
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
NSTextContainerarray - Table of data inside a text view ->
NSTextTable+NSTextTableBlock(AppKit) orNSTextAttachmentViewProvider(UIKit) - Non-rectangular text region -> subclass
NSTextContainerand overridelineFragmentRect(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
// 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]
Coordinate System
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)
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
// 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
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.textContainersis 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:
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
// 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
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):
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
// 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
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
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:
// 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:
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
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
-
Exclusion path coordinates — Must be in text container coordinates, not view coordinates. Account for
textContainerInsetandtextContainerOrigin. -
Exclusion path performance — Each change triggers full relayout in TextKit 1. Batch changes; don't update per-frame during animations.
-
NSTextTable on UIKit — The classes exist but rendering is incomplete. Use attachment view providers on iOS instead.
-
Each table cell must end with
\n— NSTextTable cells are paragraph-level. Missing the trailing newline merges cells. -
Multi-container editing — Editing in linked containers is fragile. Works well for read-only; editing across container boundaries requires careful cursor management.
-
isSimpleRectangularTextContainer— If you subclassNSTextContainerand overridelineFragmentRect, you must returnfalseor 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