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 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

// 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

BehaviorTextKit 1TextKit 2
exclusionPaths propertyYesYes
Re-layout on changeFull relayoutViewport relayout
Performance with many pathsDegradesBetter (viewport-scoped)
Custom container subclassOverride 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.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:

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

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)

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

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

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

  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