How does SwiftUI’s layout negotiation work, and why do frame and padding order change the result?
Predict first
What sizes result from frame and padding in each order?
import SwiftUI
func size<V: View>(_ v: sending V, _ w: CGFloat = 400, _ h: CGFloat = 400) -> CGSize {
UIHostingController(rootView: v).sizeThatFits(in: CGSize(width: w, height: h))
}
print(size(Color.red.frame(width: 100, height: 100).padding(10)))
print(size(Color.red.padding(10).frame(width: 100, height: 100)))
- (100, 100) then (120, 120)
- (120, 120) then (100, 100)
- (120, 120) then (120, 120)
Predict first
How do stack spacing and a ZStack affect the size of the same two children?
import SwiftUI
func size<V: View>(_ v: sending V, _ w: CGFloat = 400, _ h: CGFloat = 400) -> CGSize {
UIHostingController(rootView: v).sizeThatFits(in: CGSize(width: w, height: h))
}
print(size(HStack { Color.red.frame(width: 50, height: 20); Color.blue.frame(width: 70, height: 30) }))
print(size(HStack(spacing: 0) { Color.red.frame(width: 50, height: 20); Color.blue.frame(width: 70, height: 30) }))
print(size(ZStack { Color.red.frame(width: 50, height: 20); Color.blue.frame(width: 70, height: 30) }))
- (120, 30), (120, 30), (120, 50)
- (128, 30), (120, 30), (70, 30)
- (128, 50), (120, 50), (70, 30)
Mental model. A parent saying “you may have up to 100 points”. The child answers “I only need 60”, and the parent then decides where to put the 60. The parent cannot say “you will be 100”.
Layout is three steps repeated down the tree. The parent proposes a size to its child (possibly “unspecified”, “zero” or “infinity” to ask for the ideal, minimum or maximum). The child chooses its own size based on the proposal and reports it, and it is free to ignore the offer: Text takes what its text needs, Color and shapes take everything they are offered, Spacer takes as much as it may. The parent then places the child within its own bounds, by alignment. A parent never forces a size onto a child, it can only propose and place the answer.
That explains modifiers. .frame(width: 100, height: 100) is a view that proposes 100×100 to its child and reports 100×100 itself, regardless of what the child chose. .padding(10) proposes the size minus 10 on each side and reports the child’s size plus 20. So Color.red.frame(width: 100, height: 100).padding(10) is 120×120 (the frame, then padding around it), while Color.red.padding(10).frame(width: 100, height: 100) is 100×100 (padding inside the frame). Stacks run the same negotiation across their children: they offer each child a share of the space, least flexible first, add spacing (8 points by default), and size themselves to fit. .layoutPriority changes who is asked first. .fixedSize() proposes the ideal size, ignoring what the parent offered, which stops text from wrapping or truncating. Alignment guides decide where a child sits and how siblings line up across a stack.
Frame then padding wraps the 100-point frame in 10 more on each side. Padding then frame forces a 100-point frame around the padded content.
An HStack adds 8 points of default spacing between children, so 50 + 8 + 70 = 128, with the taller child setting the height. A ZStack overlays, so it is as large as its largest child.
Common traps
- Expecting
.frameto resize the content. It only proposes a size; a view that ignores proposals (such as an image withoutresizable()) stays its own size. - Getting modifier order wrong with
background,paddingandframe. Each one wraps what is before it.
They might follow up with
- Which views take all the space they are offered?
Color, shapes,GeometryReaderand flexible frames. - How does a stack distribute space? It asks children from least to most flexible, giving each a share of what remains.
Related questions
- 8.1.1 What is a View, what does some View mean, and why do modifiers wrap rather than mutate?
- 8.5.2 When do you use GeometryReader, and how do safe areas work in SwiftUI?
- 8.5.3 How does the Layout protocol work, and when is a custom layout the right tool?
When do you use GeometryReader, and how do safe areas work in SwiftUI? →