Stacks, frames and alignment
How space gets divided, and how things line up across rows
A stack has to divide a finite proposal among children who each want a different amount. The algorithm is worth knowing because it explains every “why did that view get all the space?”.
- The stack subtracts its spacing from the proposal.
- It asks each child for its minimum (
.zero) and maximum (.infinity) size along the axis. - It sorts children by flexibility — the gap between those two numbers.
- It proposes to the least flexible child first, giving it an equal share of what remains.
- Whatever that child does not use returns to the pool for the rest.
Inflexible children — Text, Image, a fixed frame — are served first and take what they need.
Flexible ones — Spacer, Color, a view with maxWidth: .infinity — divide the leftovers.
HStack {
Text("Name") // inflexible: takes what it needs
Spacer() // flexible: absorbs the rest
Text("Value") // inflexible
}
This is why a long Text beside a Spacer never truncates until the space genuinely runs out, and
why two Spacers in one stack split the remainder evenly.
Tip
Spacer is not empty space. It is a view that accepts any proposal and draws nothing, with a
minimum length of its own. Two Spacers around a Text is the idiomatic way to centre it in a
stack while letting it push them aside when it grows.
layoutPriority overrides the sort
When two children are equally flexible and you want one to win, layoutPriority moves it to the
front of step 4:
HStack {
Text(title).layoutPriority(1) // served first, takes what it needs
Text(subtitle) // gets the remainder, truncates if short
}
Use it sparingly. It is a real tool for the “title should never truncate, subtitle may” case, and it is also a common way to paper over a layout whose actual problem is a parent proposing too little.
Alignment inside a stack
The alignment parameter of a stack decides how children narrower than the stack are positioned along the other axis:
VStack(alignment: .leading) { … } // children line up on their leading edges
HStack(alignment: .top) { … } // children line up on their top edges
The subtlety is that alignment is computed from an alignment guide, and the default guide for
.leading is the view’s own leading edge — but not always. .firstTextBaseline aligns an image
with the baseline of the text next to it, which is what you actually want in a row combining the two
and is impossible to get right by hand with padding.
HStack(alignment: .firstTextBaseline) {
Image(systemName: "star.fill")
Text("Featured")
}
Lining things up across rows
Alignment inside one stack is easy. Lining up a column of labels across separate rows — so every value starts at the same x, whatever the label’s length — is the problem custom alignment guides exist for.
extension HorizontalAlignment {
private enum ValueColumn: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[.leading]
}
}
static let valueColumn = HorizontalAlignment(ValueColumn.self)
}
Then use it as the VStack’s alignment, and mark the point in each row that should line up:
VStack(alignment: .valueColumn) {
HStack {
Text("Name")
Text(user.name).alignmentGuide(.valueColumn) { $0[.leading] }
}
HStack {
Text("Email address")
Text(user.email).alignmentGuide(.valueColumn) { $0[.leading] }
}
}
The VStack collects the guide value from each child, takes the maximum, and shifts the rows so they
agree. No GeometryReader, no measuring pass, no state — it happens inside the normal layout pass.
Warning
This is the tool people reach for GeometryReader and a PreferenceKey to build. Alignment
guides do it in the layout pass with no extra frame of lag, and they are the right answer far
more often than the measurement approach is.
The frame variants
Three shapes of frame, doing genuinely different things:
.frame(width:height:)— proposes exactly that to the child and reports exactly that to the parent. The child may still choose to be bigger and overflow..frame(minWidth:idealWidth:maxWidth:…)— clamps the proposal into a range.maxWidth: .infinityis the standard “fill the available width”, andminWidth:is how you stop something collapsing..frame(alignment:)— with either of the above, decides where the child sits when it chose to be smaller than the frame.
The one people write by accident is .frame(maxWidth: .infinity) on something that then still does
not fill the width — because maxWidth raises the ceiling on the proposal, and a Text remains
free to choose less. Add alignment: .leading if what you actually wanted was the text pinned left
in a full-width row.