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 dimensionsnil. “What is your ideal size?” ATextreplies with the width of its content on one line..zero— both zero. “What is the smallest you can be?” ATextreplies with the width of its longest unbreakable word..infinity— both infinite. “What is the largest you can usefully be?” ATextreplies with its one-line width again; aRectanglereplies 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.