Writing a Layout
Two methods, and the negotiation becomes yours
The Layout protocol is the same one HStack conforms to. Implementing it means writing the two
halves of the negotiation from the previous chapters, from the parent’s side this time.
protocol Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
subviews: Subviews, cache: inout Cache)
}
sizeThatFits answers your parent’s proposal. placeSubviews puts the children down inside the
space you were given. That is the whole protocol.
A flow layout
The classic case SwiftUI has no built-in for: children laid left to right, wrapping to a new line when they run out of width. Tag chips, in other words.
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews,
cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var rowWidth: CGFloat = 0
var rowHeight: CGFloat = 0
var totalHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if rowWidth > 0, rowWidth + spacing + size.width > maxWidth {
totalHeight += rowHeight + spacing
rowWidth = size.width
rowHeight = size.height
} else {
rowWidth += rowWidth > 0 ? spacing + size.width : size.width
rowHeight = max(rowHeight, size.height)
}
}
return CGSize(width: maxWidth, height: totalHeight + rowHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
subviews: Subviews, cache: inout ()) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > bounds.minX, x + size.width > bounds.maxX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
Used like any other container:
FlowLayout(spacing: 8) {
ForEach(tags, id: \.self) { Chip(text: $0) }
}
Two details in there are the ones that matter. subview.sizeThatFits(.unspecified) is you asking a
child for its ideal size — the same question a stack asks. And place(at:proposal:) is you making
the proposal, having decided where it goes; the child still chooses its own size, and passing
ProposedViewSize(size) is you offering exactly what it asked for.
Warning
place(at:) positions the child’s top-left corner by default. If you are placing things
around a centre point, pass anchor: .center rather than subtracting half the size yourself —
the anchor is applied after the child chooses, so your arithmetic would be using the wrong
number.
The cache exists for a reason
sizeThatFits and placeSubviews both run, both call sizeThatFits on every child, and the whole
thing may run several times per frame as an ancestor probes with different proposals. The layout
above measures every child at least twice per pass.
For a handful of chips that is free. For a hundred, use the cache:
struct FlowLayout: Layout {
struct Cache {
var sizes: [CGSize]
}
func makeCache(subviews: Subviews) -> Cache {
Cache(sizes: subviews.map { $0.sizeThatFits(.unspecified) })
}
func updateCache(_ cache: inout Cache, subviews: Subviews) {
cache.sizes = subviews.map { $0.sizeThatFits(.unspecified) }
}
}
makeCache runs once, updateCache when the subviews change. Read cache.sizes[index] instead of
re-measuring.
Layout values, for per-child configuration
A child can pass information up to your layout, which is how layoutPriority works:
struct Weight: LayoutValueKey {
static let defaultValue: CGFloat = 1
}
extension View {
func weight(_ value: CGFloat) -> some View {
layoutValue(key: Weight.self, value: value)
}
}
Inside the layout, read it with subview[Weight.self]. This is the mechanism for “let this one
child behave differently” without adding a parameter to the container that every other child
ignores.
When not to write one
Layout is satisfying and it is not usually the answer.
If a VStack of HStacks does it, use that. If you need a grid, Grid already handles unequal
column widths and spanning. If you need equal columns, LazyVGrid is there and is lazy, which a
custom Layout is not — every child of a Layout is created and measured, so it is the wrong tool
for a thousand items.
Write one when the arrangement rule is genuinely custom — flowing, radial, masonry, a chart axis —
and the built-in containers would need GeometryReader and a preference round-trip to fake it. That
is the case where Layout replaces a frame of lag and a pile of state with two honest methods.