Home

The layout pass

What a proposal is, and how to read a layout that came out wrong

A proposal is a ProposedViewSize — two CGFloat? values, width and height. The optionality is the interesting part, because nil is not zero and not infinity. It means “tell me what you would like”.

struct ProposedViewSize {
    var width: CGFloat?
    var height: CGFloat?
}

Three proposals have names and are worth recognising, because containers use them to interrogate their children before deciding anything:

  • .unspecified — both dimensions nil. “What is your ideal size?” A Text replies with the width of its content on one line.
  • .zero — both zero. “What is the smallest you can be?” A Text replies with the width of its longest unbreakable word.
  • .infinity — both infinite. “What is the largest you can usefully be?” A Text replies with its one-line width again; a Rectangle replies with infinity.

A stack asks its children these questions to work out who is flexible and who is not, then makes real proposals based on the answers.

How the common views reply

Knowing the reply of each leaf view removes most of the guesswork:

View Reply
Text As much width as offered, up to what it needs; height to fit the wrapped result
Image Its natural pixel size, ignoring the proposal entirely — unless .resizable()
Rectangle, Color, Spacer Exactly what was proposed, however large
HStack / VStack The sum of its children along the axis, the largest along the other
.frame(width:height:) Exactly that, regardless of what the child chose

Image is the one that catches people. An unmodified Image is its own size and no proposal changes that, which is why a large photo overflows its container until .resizable() is added — and why .resizable() alone distorts it until a .aspectRatio or .scaledToFit is added too.

Warning

.frame(width: 100) proposes 100 to the child but does not clip it. A child that chooses to be 200 wide will be 200 wide, centred, drawing outside the frame’s bounds and over its neighbours. Add .clipped() if you need the promise enforced — the frame reports 100 to its parent either way, which is how a view ends up overlapping something it appears to have room for.

Reading a broken layout

Two questions, in this order, answer nearly everything:

1. What was proposed to this view? Walk up the tree. Every wrapping modifier and every container between here and the root may have changed the proposal.

2. What did it choose? Walk down. A child that ignores proposals — Image, a fixed frame, a Text with .fixedSize() — ends the conversation regardless of what was offered.

The practical tool is a temporary border, on both views:

ProfileCard(user: user)
    .border(.red)                    // what this view chose
    .padding()
    .border(.blue)                   // what the padding wrapper became

Two borders in different colours show you the proposal and the reply as two rectangles. If red fills blue, the child accepted; if red is small inside blue, it chose its own size and the difference is where your layout went.

.fixedSize(), the escape hatch

.fixedSize() proposes nil to the child — “take your ideal size” — and is the standard fix for text that has been truncated by a parent that offered too little.

Text("A long line that keeps getting truncated")
    .fixedSize(horizontal: false, vertical: true)

That specific spelling is the one worth memorising: accept whatever width you are offered, but take as much height as you need. It is the fix for text truncated to one line inside a stack, and it comes up constantly.

Used with both true, the view takes its ideal size in both dimensions and will happily overflow its parent. That is occasionally what you want and is more often the beginning of a different bug.

Why layout runs more than once

The pass is not a single top-down sweep. A stack queries its children with .zero, .unspecified and .infinity to classify them before proposing anything real, so a child’s sizing logic may run several times per frame with different proposals.

This is normally invisible, and matters in two cases. Expensive work inside a view’s sizing path runs more often than you think — which is one reason GeometryReader inside a List row can be slow. And a layout that depends on state it also writes can oscillate, which is what the “Bound preference … tried to update multiple times per frame” runtime warning is telling you.