Fix: NSTextBlock Breaks UITextView Autosizing in TextKit 2

Fix: NSTextBlock Breaks UITextView Autosizing in TextKit 2

Written by

in

Quick answer: NSTextBlock does not resize with a TextKit 2 text view and it also blocks autosizing, whether you size with constraints or with the old resizable-text-view logic. The practical fix is to remove the block from the paragraph style and paint paragraph backgrounds yourself in draw(_:), by enumerating NSTextLayoutFragments from the text layout manager.

Why this happens

NSTextBlock is a TextKit 1-era feature. Under TextKit 2 the layout is driven by NSTextLayoutManager and NSTextLayoutFragment objects, and a paragraph carrying a text block does not get a fragment frame that grows with the text. The block’s own geometry stays frozen at whatever was computed when it was first laid out.

That frozen geometry leaks into sizing. A UITextView or NSTextView that normally grows to fit its content stops tracking the text once a single paragraph has a text block attached. With Auto Layout it shows up as a view pinned at its initial size — the intrinsic content size never changes. With the older manual resizing behaviour (no constraints) the width stops following the text as well.

There is no flag or property that restores it. Setting a percentage content width on the block, or no width at all, makes no difference, and neither does invalidating the intrinsic content size, because the fragment itself is not being laid out at the new size. If you need paragraph-level backgrounds (or NSTextList, which also requires TextKit 2), the block has to go and the drawing has to move into your view.

How to fix it

1. Remove the text block from the paragraph style

Start by confirming the diagnosis: strip textBlocks from the style you apply and check that autosizing comes back.

let style = NSMutableParagraphStyle()
style.paragraphSpacing = 8
style.textBlocks = []              // no blocks: autosizing works

textStorage.addAttribute(.paragraphStyle, value: style, range: fullRange)

If the text view grows again, NSTextBlock is the cause and not your constraint setup. Keep the rest of the paragraph style — spacing, head indent, alignment — and replace only the block with drawn backgrounds.

2. Draw paragraph backgrounds from layout fragments

Override draw(_:) on your text view subclass, walk the layout fragments, and fill a rectangle behind each paragraph that should be highlighted. On iOS this is UITextView; the AppKit version is the same code with NSGraphicsContext/NSColor.setFill() instead of a CGContext.

import UIKit

final class RichTextView: UITextView {
    /// Paragraphs (index = paragraph number in the text) that get a background.
    var highlightedParagraphs: IndexSet = []
    var paragraphBackground: UIColor = .systemRed

    override func draw(_ rect: CGRect) {
        drawParagraphBackgrounds(in: rect)
        super.draw(rect)
    }

    private func drawParagraphBackgrounds(in dirtyRect: CGRect) {
        guard let manager = textLayoutManager,
              let context = UIGraphicsGetCurrentContext(),
              textStorage.length > 0 else { return }

        context.saveGState()
        defer { context.restoreGState() }
        context.setFillColor(paragraphBackground.cgColor)

        var paragraphIndex = 0
        manager.enumerateTextLayoutFragments(
            from: manager.documentRange.location,
            options: [.ensuresLayout]
        ) { fragment in
            defer { paragraphIndex += 1 }

            guard fragment.state == .layoutAvailable else { return true }
            guard highlightedParagraphs.contains(paragraphIndex) else { return true }

            var paint = fragment.layoutFragmentFrame
                .offsetBy(dx: textContainerInset.left, dy: textContainerInset.top)

            // Extend to the full width so the fill does not stop mid-line.
            paint.size.width = max(paint.width, bounds.width - textContainerInset.horizontal)

            guard paint.height > 0, paint.intersects(dirtyRect) else { return true }
            context.fill(paint)
            return true
        }
    }
}

Call setNeedsDisplay() whenever the highlighted paragraphs change. Two details worth knowing from the original report:

  • layoutFragmentFrame sits a few points above the visible glyph row, so the author nudged rect.y down slightly before filling. Check your own values and adjust by a constant if the fill looks off by a couple of points.
  • A fragment only counts as laid out once it reaches NSTextLayoutFragment.State.layoutAvailable. Skip fragments below that state or you get garbage frames.

3. Keep the enumeration cheap on long documents

Enumerating every fragment on every draw(_:) is fine for a page of text and wasteful for a large document. Two things help:

  • Skip fragments whose frame does not intersect dirtyRect before doing any work — the guard above already does this.
  • Start the enumeration near the visible area instead of at documentRange.location. You can get the fragment under a point with textLayoutManager.textLayoutFragment(for:) and use its rangeInElement location as the starting point.

If you need the NSRange of a fragment (for example to look up attributes), convert locations through the layout manager:

let start = manager.offset(from: manager.documentRange.location,
                          to: fragment.rangeInElement.location)
let end = manager.offset(from: manager.documentRange.location,
                         to: fragment.rangeInElement.endLocation)
let range = NSRange(location: start, length: end - start)

4. If you can live with TextKit 1

Touching layoutManager on the text view drops it back to TextKit 1, where NSTextBlock at least follows the old resizable-text-view sizing rules. That is a workaround, not a fix: you give up TextKit 2 layout, and you cannot use NSTextList styling that depends on it. For a new editor built around paragraph backgrounds, drawing fragments is the better trade.

How to prevent it

  • Treat NSTextBlock as unsupported for anything that must resize. Use it only in fixed-size text views.
  • When a text view stops resizing, first check whether any paragraph style in the storage has a non-empty textBlocks array.
  • Keep background painting in the view’s draw(_:), not in the attributed string, so layout stays predictable.
  • When mixing TextKit 1 and TextKit 2 code, avoid touching layoutManager on a view you want to keep on TextKit 2.
  • Add a test or debug assert that the intrinsic content size changes after appending a long paragraph.

FAQ

Why does removing the NSTextBlock restore autosizing?

Because the fragment no longer has a block attached, its frame grows normally with the text, and the text view’s intrinsic content size (or its old-style used rect) follows it. The block was the element that stopped the fragment from being re-measured.

Can I keep NSTextBlock and just invalidate the intrinsic content size?

No. Invalidating forces a fresh size query, but the block’s layout inside the fragment is still stale, so the reported size does not change. The block has to be removed and the background drawn separately.

Does the fragment-drawing approach work on macOS too?

Yes. NSTextView exposes the same textLayoutManager and enumerateTextLayoutFragments(from:options:using:) API. Fill the rect with NSColor.setFill() and NSRectFill inside draw(_:) instead of using a CGContext.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.