Back to blog
FILE 0x19·THE TEXT VIEW THAT ASKED FOR THE WHOLE SCREEN

The text view that asked for the whole screen

August 10, 2026 · swiftui, uikit, debugging, ios

My chat app's composer stopped wrapping. Paste a paragraph in and it rendered as one line disappearing off the right edge of the iPad, send button somewhere past the bezel.

The obvious suspects were all innocent. The text container was configured for word wrapping. There were no newlines involved. The field was in a normal HStack with a couple of buttons. Nothing anywhere said "don't wrap."

The composer isn't a TextField

Some context on why this is a UIViewRepresentable at all. A SwiftUI TextField is a value re-created on every body evaluation of its enclosing view. Autocorrect acceptance, QuickType insertion and the double-space-for- period shortcut each commit their result as two edits in quick succession — the word, then the trailing space. A re-render landing between those two discards the second one. The space appears and is instantly deleted.

A UITextView owns its own text storage, so nothing in SwiftUI's render cycle can reach in and reset it mid-composition. That's the whole reason it's there:

let tv = UITextView()
tv.isScrollEnabled = false   // grow to fit; the frame follows the content
tv.textContainer.widthTracksTextView = true
tv.textContainer.lineBreakMode = .byWordWrapping

That isScrollEnabled = false is what makes the field grow line by line instead of scrolling inside a fixed box. It is also what broke wrapping.

What's actually happening

With scrolling disabled, UITextView starts publishing an intrinsicContentSize. Everyone knows about the height half of that — it's how the growing-composer trick works at all. The part I'd never had to think about is that it reports a width too, and that width is the width the text would need on a single unwrapped line.

So the view is standing there saying "I would like 2,300 points, please."

Normally a layout says no. But horizontal compression resistance defaults to 750, which outranks the width the enclosing stack is trying to impose. SwiftUI consults the intrinsic size, finds the view unwilling to be squeezed, and hands over the width it asked for. The text container then dutifully tracks that width and wraps inside it — at 2,300 points, which is to say never.

The wrapping configuration was correct the whole time. It was wrapping to a width nobody had told it was wrong.

The fix

Four parts, and I needed all four.

1. Stop asking for a width. Withdraw the width from the intrinsic size and keep the height, which is the half we actually want:

final class WrappingTextView: UITextView {
    override var intrinsicContentSize: CGSize {
        CGSize(width: UIView.noIntrinsicMetric, height: super.intrinsicContentSize.height)
    }
}

2. Agree to be squeezed.

tv.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)

3. Take the width the layout offers. This is the one I'd have missed. A UIViewRepresentable sizes itself from the view's intrinsic size unless you implement sizeThatFits, so returning the proposed width is what actually ends the negotiation:

func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
    guard let width = proposal.width, width > 0, width < .greatestFiniteMagnitude else { return nil }
    let fitting = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)).height
    return CGSize(width: width, height: min(max(fitting, minHeight), cap))
}

4. Re-measure when the width changes. Height is computed by measuring against the current width, so before the first layout pass there's no width to measure against — and a paragraph measured against a width of zero reports the height of one line. Bail out early and let layoutSubviews call back:

guard tv.bounds.width > 1 else { return }

That last one also covers rotation and Stage Manager resizes, which is a real case on iPad and not a hypothetical one.

Proving it, which is the actual point

"It compiles" would have been a fine-sounding lie here — the broken version compiled too. The assertion that catches this is two-sided:

XCTAssertLessThanOrEqual(field.maxX, window.maxX + 1)      // didn't run off the edge
XCTAssertGreaterThan(field.height, oneLineHeight + 1)      // and did grow past one line

Either alone passes for the wrong reason. Staying inside the window is also true of a field that clips its text; growing taller is also true of a field that does both. Together they're only true of wrapping.

What I'd do differently

I spent the first few minutes reading the text container configuration, because "text doesn't wrap" sounds like a text-container problem. It wasn't a text problem at all — it was a layout negotiation where one participant had a much stronger opinion than the other, and I'd never noticed which side had the priority. When a view renders at an absurd size, the first question is what size it asked for, not what it was told.