Practical SwiftUI Layout: Measurement, Caching, and Custom Containers
SwiftUI’s Layout protocol lets a container participate directly in measurement and placement instead of relaying geometry through nested readers and preferences. A layout receives a proposal from its parent, asks subviews how large they want to be under chosen proposals, returns a container size, and places each subview. The challenge is not implementing two methods. It is keeping measurement deterministic, respecting unspecified dimensions, and caching only intermediate work that is safe to reuse.
State the rule before writing coordinates
Consider a flow of tags. Items fill a row, wrap when the remaining width is insufficient, and each row takes its tallest item. The rule must also define an unbounded width, no children, an item wider than the container, and right-to-left layout. If those decisions exist only inside coordinate arithmetic, special cases quickly become impossible to review.
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) -> CGSize {
let width = proposal.width ?? .infinity
let rows = arrange(subviews, in: width, spacing: spacing)
cache.rows = rows
return CGSize(
width: rows.containerWidth,
height: rows.containerHeight
)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) {
for item in cache.rows.items {
subviews[item.index].place(
at: CGPoint(x: bounds.minX + item.x, y: bounds.minY + item.y),
anchor: .topLeading,
proposal: ProposedViewSize(item.size)
)
}
}
}
The sample omits Cache and arrange to emphasize responsibilities. Production code must recompute when the cache or proposal is incompatible. It cannot assume sizeThatFits always runs immediately before placement with identical inputs.
A proposal is not a command
Each ProposedViewSize dimension can be concrete, zero, or unspecified. A subview returns a size appropriate to the proposal. For a tag, one pass may ask for an unconstrained ideal width before deciding whether a narrower proposal is necessary. Wrapping text changes height when width changes, so one ideal-size measurement may not be sufficient.
Do not translate nil into screen width or read a global device size. The layout can appear in a sheet, split view, resizable window, list, or preview. Return a finite, nonnegative size capable of containing the actual placement, with an intentional fallback for infinity and NaN.
Be careful about proposing zero. It is useful for discovering a minimum dimension but is not equivalent to “no constraint.” Similarly, .infinity asks a different question from an unspecified dimension. Treat those cases as parts of the protocol rather than values to normalize away.
Share one plan between measurement and placement
A common defect computes wrapping once in sizeThatFits and again through a slightly different path in placeSubviews. Floating-point or ordering differences make the reported height disagree with actual coordinates. Model the arrangement as a pure value containing each index, size, row, x and y, plus the overall size. Both protocol methods consume that plan.
The arrangement function can be tested without SwiftUI. Given a width and child sizes, assert row assignment, coordinates, and total height. Cover an empty input, exact fit, an oversized element, mixed heights, and spacing. The SwiftUI adapter is then responsible only for measuring subviews and applying the plan.
Use explicit rounding policy only where rendering requires it. Repeatedly rounding every intermediate coordinate can accumulate gaps. Let SwiftUI render fractional points unless measurement demonstrates a pixel-alignment problem.
Cache computation, not truth
A cache can retain subview measurements and an arrangement plan, but its key must account for inputs that affect the result: proposal, child count and sizes, spacing, and layout direction. Dynamic Type, text content, environment, or identity changes may invalidate prior work. First use Instruments to establish that repeated measurement is expensive; then add only the cache needed for that cost.
Do not store a Subview for arbitrary future calls or use the layout cache as application state. SwiftUI may recreate the layout value and manages cache lifetime. Correctness must not depend on a cache hit. updateCache can refresh data when the subview collection changes, but proposal-dependent plans still need the right key.
Include direction, spacing, and animation
Use layout direction to choose a starting edge instead of permanently equating leading with left. System spacing can be derived from subview spacing preferences; if the product requires a fixed gap, define its behavior both within and between rows. During animation, proposals and child dimensions can take intermediate values. The arrangement must remain finite and stable between endpoints, not only at rest.
Accessibility text sizes can turn a tag into multiple lines and radically increase row height. Test the largest sizes, long localizations, bold text, and right-to-left content. Visual flow should not scramble semantics. Keeping data and placement order aligned usually preserves a predictable VoiceOver reading order.
A high-quality custom layout begins with an explainable rule: what a proposal means, how children are measured, where wrapping occurs, and how the container size follows. Use one pure placement plan for both protocol methods, add caching only with evidence, and cover Dynamic Type, RTL, and intermediate animation sizes. If an HStack, Grid, or adaptive system container already expresses the requirement, prefer it; the Layout protocol earns its complexity when the product truly needs a new arrangement rule.