SwiftUI Layout Protocol: Building Custom Layouts From sizeThatFits To placeSubviews
iOS 16 added the Layout protocol to SwiftUI, the public API for building custom container views that participate in SwiftUI’s layout pass1. Before Layout, custom container shapes required either GeometryReader hacks (which break composition because they request the full proposed size) or custom ViewModifier work that fights the system. Layout is the right answer: a two-method protocol (sizeThatFits and placeSubviews) plus optional spacing and caching extensions, with a contract that integrates cleanly with SwiftUI’s parent-proposes-child-disposes layout model.
The post walks the protocol against Apple’s documentation. The frame is “what Layout actually contracts on” because the misuse pattern (treating Layout as a coordinate-space tool rather than a size-negotiation tool) produces layouts that work on one screen and fail on another, and the cluster’s What SwiftUI Is Made Of post argued that SwiftUI’s architecture is best understood by reading its public protocols.
TL;DR
Layoutis a protocol with two required methods:sizeThatFits(proposal:subviews:cache:)returns the layout’s preferred size given the parent’s proposal;placeSubviews(in:proposal:subviews:cache:)positions each child by calling itsplace(at:anchor:proposal:)method2.- The
proposalparameter is aProposedViewSizewithwidthandheightas optional CGFloats.nilmeans “use your ideal size”; a finite value is the parent’s offer;.infinitymeans “use as much as you want.” Subviewsis a typealias forLayoutSubviews, a collection ofLayoutSubviewproxies. Each proxy can be queried for its size given any proposal and placed at any point. The proxies are the only way Layout interacts with children.- Custom layout values flow from children to parent through
LayoutValueKeytypes attached via.layoutValue(...)on child views, readable fromLayoutSubviewsubscripts inside the layout methods. - The
cacheis for amortizing computation betweensizeThatFitsandplaceSubviews(each pass calls both, often with the same intermediate values). Type the cache as a struct that holds the precomputed sizes; build it once, reuse across both methods.
The Protocol Contract
A Layout is a struct (typically) that declares two methods Apple’s framework calls during the layout pass2:
struct DiagonalLayout: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
// Compute and return the size your layout wants
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
// Position each subview by calling subview.place(...)
}
}
Use it like a built-in container:
DiagonalLayout {
Text("First")
Text("Second")
Text("Third")
}
The framework calls sizeThatFits with the proposed size from the parent (a ProposedViewSize), then calls placeSubviews with the bounds the layout has been granted. The two methods together describe the layout’s behavior: how big it wants to be, and where each child goes within that allocation.
ProposedViewSize: The Parent’s Offer
Layout in SwiftUI follows a parent-proposes-child-disposes contract3. The parent passes a proposed size; the child returns its actual size; the parent positions the child within its own bounds. Layout participates in this contract via ProposedViewSize:
struct ProposedViewSize {
var width: CGFloat?
var height: CGFloat?
}
The optional axes carry semantic meaning:
nilfor an axis means “use your ideal/natural size.” ATextproposed.zeroreturns its minimum width (one character per line); proposednilreturns its ideal width (one line, no wrap).- A finite value means “the parent offers this much space; you decide what to do.” A
Textproposed 100pt width may wrap, may use less, may use exactly 100. .infinitymeans “use as much as you want.” AColorproposed.infinitytakes the full available space.
The convention ProposedViewSize.unspecified (width: nil, height: nil) is the request for ideal size; ProposedViewSize.zero is the request for minimum size; ProposedViewSize.infinity is the request for greedy expansion.
A custom Layout’s sizeThatFits should respect the proposal: return a size the layout actually wants for the proposed bounds, not always the same hardcoded value. Hardcoded sizes break the layout’s ability to adapt to different containers (a card view, a list cell, a sheet).
Reading Subview Sizes Through LayoutSubview
Inside sizeThatFits, the layout asks each child what size it wants for various proposals. The query goes through the LayoutSubview proxy4:
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let proposed = ProposedViewSize(
width: proposal.width.map { $0 / CGFloat(subviews.count) },
height: proposal.height
)
let sizes = subviews.map { $0.sizeThatFits(proposed) }
let totalWidth = sizes.reduce(0) { $0 + $1.width }
let maxHeight = sizes.map(\.height).max() ?? 0
return CGSize(width: totalWidth, height: maxHeight)
}
The subviews.map { $0.sizeThatFits(proposal) } pattern is how a layout discovers what sizes its children want. The LayoutSubview proxy’s sizeThatFits(_:) method is not the same as the Layout protocol method; it’s the proxy’s query into the child’s preferred size given a proposal. The two share a name because they participate in the same negotiation, but they’re different layers of the contract.
A layout that wants to know the children’s sizes calls proxy.sizeThatFits(_:). A layout that wants to position children calls proxy.place(at:anchor:proposal:) inside placeSubviews.
Placing Subviews
placeSubviews is where the layout makes positioning decisions2:
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var x = bounds.minX
let y = bounds.midY
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
subview.place(
at: CGPoint(x: x + size.width / 2, y: y),
anchor: .center,
proposal: ProposedViewSize(size)
)
x += size.width
}
}
The place(at:anchor:proposal:) call positions a single subview. Three parameters:
at: the position in the parent’s coordinate space.anchor: which point of the subview is atat..centerputs the subview’s center atat;.topLeadingputs the top-left corner there.proposal: the size the subview should render at. Pass the size returned from the same subview’ssizeThatFitsto honor its preference, or pass a custom proposal to constrain it.
Every subview must be placed exactly once per placeSubviews call. Skipping a subview leaves it unpositioned (it disappears from the rendered layout); placing one twice is a runtime error.
Custom Layout Values Through LayoutValueKey
When a child needs to communicate something to its parent layout (a priority, a span, a category), the channel is LayoutValueKey5:
struct PriorityKey: LayoutValueKey {
static let defaultValue: Int = 0
}
extension View {
func layoutPriority(_ value: Int) -> some View {
layoutValue(key: PriorityKey.self, value: value)
}
}
// Inside the Layout:
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let sortedSubviews = subviews.sorted {
$0[PriorityKey.self] > $1[PriorityKey.self]
}
// ... place sortedSubviews
}
The LayoutValueKey protocol provides a typed channel for parent-child communication. The child attaches a value via the layout-value modifier; the parent reads it via the LayoutSubview subscript. Each key has a default value for subviews that don’t specify one explicitly.
The pattern is conceptually what built-in modifiers like .layoutPriority(_:) express. The framework exposes that specific value through a dedicated priority: Double property on LayoutSubview rather than through a public LayoutValueKey, so the proxy access for layout priority is subview.priority rather than a key subscript. Custom layouts declare their own LayoutValueKey types for any other structured data they need from children.
The cache Parameter
Both layout methods receive a cache: inout parameter. The cache is the layout’s place to amortize work between sizeThatFits and placeSubviews6:
struct DiagonalLayout: Layout {
struct Cache {
var sizes: [CGSize]
}
func makeCache(subviews: Subviews) -> Cache {
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
return Cache(sizes: sizes)
}
func updateCache(_ cache: inout Cache, subviews: Subviews) {
cache.sizes = subviews.map { $0.sizeThatFits(.unspecified) }
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
let totalWidth = cache.sizes.reduce(0) { $0 + $1.width }
let totalHeight = cache.sizes.reduce(0) { $0 + $1.height }
return CGSize(width: totalWidth, height: totalHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
var x = bounds.minX
var y = bounds.minY
for (subview, size) in zip(subviews, cache.sizes) {
subview.place(
at: CGPoint(x: x, y: y),
anchor: .topLeading,
proposal: ProposedViewSize(size)
)
x += size.width
y += size.height
}
}
}
The default cache type is Void. Most layouts can ignore the cache; it earns its place when the size computation is genuinely expensive (recursive measurements, dynamic sizing decisions) and the same intermediates feed both layout methods.
makeCache(subviews:) runs once per layout pass; updateCache(_:subviews:) runs when the subviews change between passes. The pattern lets the layout invalidate cached state correctly when the children themselves change.
Common Custom Layouts Worth Building
Three patterns worth building yourself:
Flow layout (wrapping items). Items wrap onto multiple rows when they overflow the available width. Apple’s HStack doesn’t wrap. A custom Layout can: measure each child, place left-to-right, advance to the next row when the row width exceeds the proposal’s width.
Diagonal stack. Items stagger diagonally (each child positioned slightly down-and-right from the previous). Useful for stacked card UIs, gallery preview layouts, parallax-feeling stacks.
Pie/circle layout. Items arranged around the circumference of a circle. Useful for radial menus, time-based UIs, equal-spacing categorical labels.
Each of these is implementable with sizeThatFits + placeSubviews + (optionally) a custom cache. The framework handles the parent-proposes-child-disposes negotiation; the developer handles the placement math.
Common Layout Failures
Three patterns that produce broken custom layouts:
Hardcoded sizes that ignore the proposal. A layout that always returns CGSize(width: 200, height: 100) doesn’t adapt to its container. The result: the layout looks fine in the simulator but breaks on smaller screens, in different orientations, or inside resizable containers.
Skipping subviews in placeSubviews. Every subview must be placed exactly once per call. A for loop that has a continue for some condition leaves those subviews unpositioned; they disappear from the rendered output.
Using GeometryReader inside the children of a custom Layout. GeometryReader always proposes the full received space to its content, which fights the layout’s per-child proposals. The combination produces nonsense sizes. Custom layouts shouldn’t put GeometryReader inside themselves; if a child needs to know its allocated size, the layout protocol’s proposal mechanism is the right channel.
When To Reach For Layout (And When Not To)
Three signals that a custom Layout is the right tool:
- The shape isn’t expressible with HStack/VStack/ZStack/Grid composition. Pie layouts, masonry grids, custom flow wrapping. The built-in primitives can’t compose into these shapes.
- Per-child information drives positioning. Layouts where children have priorities, weights, or categories that the parent uses to position them.
LayoutValueKeyis the right channel. - The layout’s sizing depends on negotiating with children. Layouts that ask “what’s the smallest height that fits the longest line?” or “what width gives equal columns to N children?” need access to
subviews.sizeThatFits(...)queries.
Three signals that built-in composition is enough:
- Standard horizontal/vertical/depth stacking.
HStack,VStack,ZStackcover the common cases. - Grid with regular rows/columns.
GridandLazyVGrid/LazyHGridhandle most grid cases. - A bit of overlay positioning.
.overlay,.background,ZStackwith alignment cover most “X on top of Y” patterns.
The rule of thumb: don’t build a custom Layout for a shape the built-ins handle. Build one when the shape is genuinely beyond the built-ins’ expression set.
What This Pattern Means For iOS 26+ Apps
Three takeaways.
-
Honor the proposal in
sizeThatFits. A layout that returns the same size regardless ofproposaldoesn’t participate in SwiftUI’s layout system properly. Read the proposal, return a size appropriate to it. -
Use
LayoutValueKeyfor structured parent-child communication. Passing data through view-modifier-attached keys is the SwiftUI-native pattern. Don’t reach for@Environmentor customPreferenceKeyfor data that’s specifically about layout-level decisions;LayoutValueKeyis the typed channel for that. -
Build a cache only when measurement is expensive. The default
Voidcache is fine for most layouts. Reach for a custom cache type only when the same expensive computation appears in bothsizeThatFitsandplaceSubviews.
The full Apple Ecosystem cluster: typed App Intents; MCP servers; the routing question; Foundation Models; the runtime vs tooling LLM distinction; three surfaces; the single source of truth pattern; Two MCP Servers; hooks for Apple development; Live Activities; the watchOS runtime; SwiftUI internals; RealityKit’s spatial mental model; SwiftData schema discipline; Liquid Glass patterns; multi-platform shipping; the platform matrix; Vision framework; Symbol Effects; Core ML inference; Writing Tools API; Swift Testing; Privacy Manifest; Accessibility as platform; SF Pro typography; visionOS spatial patterns; Speech framework; SwiftData migrations; tvOS focus engine; @Observable internals; what I refuse to write about. The hub is at the Apple Ecosystem Series. For broader iOS-with-AI-agents context, see the iOS Agent Development guide.
FAQ
Why not just use GeometryReader?
GeometryReader always proposes its full received size to its content (it has no opinion about what its content wants). The result is that any view inside a GeometryReader gets infinity proposed for the axes the reader doesn’t constrain, and views like Text size themselves greedily. The composition fights itself: the reader passes through unchanged, the content asks for max size, the layout breaks. Layout is the right tool because it lets the developer make explicit per-child decisions about proposed size.
Can I write a custom HStack replacement?
Yes. A HStack-equivalent custom Layout reads the children’s preferred sizes, sums their widths, takes the max height, and places them left-to-right. The actual HStack does more (spacing, alignment, layout priority resolution), but the basic shape is straightforward in Layout. The exercise is a useful way to internalize how the protocol works.
How do I support .layoutPriority(_:) in my custom layout?
Read it through the LayoutSubview proxy’s dedicated priority: Double property: subview.priority. SwiftUI exposes .layoutPriority(_:) directly on the proxy rather than through a public LayoutValueKey. The default value is 0. Use the priority when distributing extra space (give it preferentially to high-priority children) or when truncating (truncate low-priority children first).
What’s the difference between proposal: .infinity and proposal: .zero?
.infinity proposes max size on each axis (width: .infinity, height: .infinity). Children that respond to greedy proposals (like Color) take the full available space. .zero proposes minimum size (width: 0, height: 0). Children return their minimum size (Text returns the size of its longest unbreakable token). The two are useful endpoints for measuring children’s sizing range; many layouts use .unspecified (both nil) to ask “what’s your ideal size?”.
Does Layout work on watchOS, tvOS, and visionOS?
Yes. The Layout protocol is in SwiftUI’s cross-platform core. Custom layouts work the same way across iOS, iPadOS, macOS, watchOS, tvOS, and visionOS. The cluster’s Apple Platform Matrix post argues that platform inclusion is a product decision; the SwiftUI Layout mechanism is platform-agnostic for the cases where multiple platforms apply.
How does Layout interact with @Observable models?
Layout is a struct that holds no observable state directly; it doesn’t track changes. When a model updates, the parent view’s body re-evaluates, which causes the Layout to re-run with whatever children the body produces. The Layout is reactive through the body it lives inside, not through observation hooks of its own. The cluster’s @Observable internals post covers the observation side.
References
-
Apple Developer Documentation:
Layout. The protocol reference coveringsizeThatFitsandplaceSubviewsrequirements, plus the optionalmakeCache,updateCache,spacing, and explicit-alignment hooks. ↩ -
Apple Developer Documentation:
sizeThatFits(proposal:subviews:cache:)andplaceSubviews(in:proposal:subviews:cache:). The two required methods of theLayoutprotocol. ↩↩↩ -
Apple Developer Documentation:
ProposedViewSize. The two-optional-CGFloat type that carries the parent’s size proposal, with the convention values.unspecified,.zero, and.infinity. ↩ -
Apple Developer Documentation:
LayoutSubview. The proxy type representing a child view insideLayoutmethods, withsizeThatFits(_:)for querying preferred sizes andplace(at:anchor:proposal:)for positioning. ↩ -
Apple Developer Documentation:
LayoutValueKeyandlayoutValue(key:value:). The typed channel for child-to-parent layout-level data, accessed via subscript onLayoutSubview. ↩ -
Apple Developer: Composing custom layouts with SwiftUI. The Apple guide covering caching, alignment guides, and when to reach for
Layoutversus built-in containers. ↩