cides the final size for each child within the available space.
- Positioning Phase (Top-Down):
- The parent places each child using
placeSubviews(in:proposal:subviews:cache:) or implicit positioning logic.
- Children are informed of their final origin and size.
- Views render based on the resolved geometry.
Architecture: The Layout Protocol
For complex layouts, the Layout protocol (introduced in iOS 16) is the standard. It decouples layout logic from view rendering and provides a cache mechanism to store expensive calculations.
Implementation Steps
- Define the Layout Struct: Conform to
Layout.
- Implement
sizeThatFits: Calculate the total size required by subviews based on the proposal. Use cache to store intermediate results.
- Implement
placeSubviews: Iterate over subviews and assign positions using the cached data.
- Integrate: Use the layout in a view hierarchy.
Code Example: Custom Flow Layout
This implementation demonstrates efficient caching and proposal handling.
import SwiftUI
struct FlowLayout: Layout {
var spacing: CGFloat = 8
// Cache stores row heights and subview indices for fast placement
struct Cache {
var rowHeights: [CGFloat] = []
var subviewRanges: [Range<Int>] = []
var totalHeight: CGFloat = 0
}
func makeCache(subviews: Subviews) -> Cache {
Cache()
}
func updateCache(_ cache: inout Cache, subviews: Subviews) {
// Invalidate cache if subviews change;
// SwiftUI calls this automatically when needed.
// For this example, we recalculate in sizeThatFits.
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var currentWidth: CGFloat = 0
var currentRowHeight: CGFloat = 0
var totalHeight: CGFloat = 0
var rowHeights: [CGFloat] = []
var subviewRanges: [Range<Int>] = []
var startIndex = 0
for index in subviews.indices {
let subview = subviews[index]
// Ask subview for its size with the remaining width
let size = subview.sizeThatFits(.unspecified)
let itemWidth = size.width + (index > startIndex ? spacing : 0)
if currentWidth + itemWidth > maxWidth && currentWidth > 0 {
// Wrap to next row
rowHeights.append(currentRowHeight)
subviewRanges.append(startIndex..<index)
totalHeight += currentRowHeight + spacing
currentWidth = 0
currentRowHeight = 0
startIndex = index
}
currentWidth += itemWidth
currentRowHeight = max(currentRowHeight, size.height)
}
// Handle last row
if currentWidth > 0 {
rowHeights.append(currentRowHeight)
subviewRanges.append(startIndex..<subviews.count)
totalHeight += currentRowHeight
}
cache.rowHeights = rowHeights
cache.subviewRanges = subviewRanges
cache.totalHeight = totalHeight
return CGSize(width: maxWidth, height: totalHeight)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) {
var yOffset = bounds.minY
for (rowIndex, range) in cache.subviewRanges.enumerated() {
var xOffset = bounds.minX
let rowHeight = cache.rowHeights[rowIndex]
for subviewIndex in range {
let subview = subviews[subviewIndex]
let size = subview.sizeThatFits(.unspecified)
// Center subview vertically within the row
let yCenter = yOffset + (rowHeight - size.height) / 2
subview.place(
at: CGPoint(x: xOffset, y: yCenter),
proposal: .init(width: size.width, height: size.height),
anchor: .topLeading
)
xOffset += size.width + spacing
}
yOffset += rowHeight + spacing
}
}
}
Rationale for Architecture Decisions
- Caching: The
Cache struct prevents recalculating row breaks during the positioning phase. sizeThatFits is called multiple times during layout resolution; caching ensures O(N) complexity rather than O(N²).
- Proposal Usage:
subview.sizeThatFits(.unspecified) allows the subview to report its intrinsic size. If you know the available width for a subview, pass a constrained proposal to allow the subview to adjust (e.g., text wrapping).
- Separation of Concerns:
Layout separates geometry calculation from view rendering. This allows the same layout logic to be reused across different view types without duplicating code in body.
Pitfall Guide
1. Misusing GeometryReader for Sizing
Mistake: Wrapping a view in GeometryReader to get its parent's size.
Impact: GeometryReader forces the child to adopt the parent's full size, overriding intrinsic content sizing. This breaks VStack/HStack flexibility and increases layout passes.
Fix: Use Layout for custom arrangements or alignmentGuide for positional adjustments. Use @Environment(\.sizeCategory) for text scaling needs.
2. Assuming frame Sets Bounds
Mistake: Believing frame(width: 100) guarantees a width of 100.
Impact: frame is a proposal. If the parent container has a max width of 50, the view will be 50. Developers often see truncated content and assume the modifier failed.
Fix: Understand that frame sets minimum/maximum constraints. Use frame(minWidth: 100) if you need a hard floor, and ensure parent containers allow expansion.
3. Ignoring layoutPriority
Mistake: Views competing for space in a stack shrinking unexpectedly.
Impact: By default, all views have a priority of 0. When space is constrained, views shrink based on intrinsic content size. Critical views may disappear.
Fix: Apply .layoutPriority(1) to views that must retain size. This influences the stack's distribution algorithm during space constraints.
4. Overusing Spacer Without Flex Understanding
Mistake: Using multiple Spacer() views to distribute items evenly.
Impact: Spacer creates flexible space. If you have Item - Spacer - Item - Spacer - Item, the spacers share available space equally. This is correct, but developers often add fixed-width spacers or misuse alignment, causing unpredictable gaps on different devices.
Fix: Use HStack(spacing: 16) for uniform gaps. Use Spacer() only for pushing items to edges. For complex distributions, use Layout.
5. Modifying State During Layout
Mistake: Triggering @State updates inside onAppear or computed properties that affect layout.
Impact: This causes layout thrashing. The view updates, triggers a layout pass, which triggers another state update, leading to infinite loops or performance stutter.
Fix: Perform side effects in .task or .onAppear carefully. Ensure state updates do not directly cause the view to re-layout in a way that triggers the same update.
6. Confusion Between alignmentGuide and offset
Mistake: Using .offset to align a view relative to siblings.
Impact: offset moves the view visually but does not change its layout slot. The view still occupies its original space in the parent's calculation, leading to overlaps or empty gaps.
Fix: Use .alignmentGuide to adjust the view's position relative to the container's alignment system without changing its layout footprint.
7. Not Using Layout for Dynamic Collections
Mistake: Building custom grids with ForEach inside VStack/HStack.
Impact: This creates deep view hierarchies and inefficient layout passes. The system cannot optimize the arrangement.
Fix: Use LazyVGrid/LazyHGrid for standard grids. For non-standard arrangements (e.g., masonry, flow), implement a custom Layout.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Row/Column | HStack / VStack | Optimized native implementation; low overhead. | Low |
| Grid with Uniform Cells | LazyVGrid / LazyHGrid | Lazy loading; automatic wrapping; native caching. | Low |
| Dynamic Flow/Wrapping | Layout Protocol | Custom logic with caching; handles variable widths efficiently. | Medium (Dev time) |
| Overlapping Layers | ZStack + alignmentGuide | Precise positioning without layout thrashing. | Low |
| Responsive Complex Layout | Layout + Environment | Reacts to size classes and dynamic type via proposal. | Medium |
| Quick Prototype | GeometryReader | Fast implementation; acceptable for non-critical paths. | High (Performance) |
Configuration Template
Copy this template to create a performant custom layout with caching.
import SwiftUI
struct CustomLayout: Layout {
var spacing: CGFloat = 10
struct Cache {
var sizes: [CGSize] = []
var positions: [CGPoint] = []
}
func makeCache(subviews: Subviews) -> Cache {
Cache()
}
func updateCache(_ cache: inout Cache, subviews: Subviews) {
// Optional: Pre-calculate if needed
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) -> CGSize {
var totalSize = CGSize.zero
// Calculate total size based on subviews
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
cache.sizes.append(size)
totalSize.width = max(totalSize.width, size.width)
totalSize.height += size.height + spacing
}
totalSize.height -= spacing // Remove last spacing
return totalSize
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) {
var yOffset = bounds.minY
for (index, subview) in subviews.enumerated() {
let size = cache.sizes[index]
let position = CGPoint(x: bounds.midX - size.width / 2, y: yOffset)
subview.place(
at: position,
proposal: .init(width: size.width, height: size.height),
anchor: .topLeading
)
yOffset += size.height + spacing
}
}
}
Quick Start Guide
- Define the Layout: Create a struct conforming to
Layout. Add properties for configuration (e.g., spacing, alignment).
- Implement Cache: Add a
Cache struct to store calculated sizes or positions. Implement makeCache and updateCache.
- Calculate Sizes: In
sizeThatFits, iterate over subviews, call sizeThatFits on each, and compute the total required size. Store results in cache.
- Place Subviews: In
placeSubviews, use cache data to determine positions. Call subview.place(at:proposal:anchor:) for each item.
- Integrate: Use
CustomLayout { ... } in your view body. Pass child views inside the closure.
Conclusion
Mastering the SwiftUI layout system requires abandoning the imperative frame-based mindset and embracing the proposer-proposed negotiation model. The layout engine is a rigorous algorithm that resolves geometry through bottom-up sizing and top-down positioning. By leveraging the Layout protocol, respecting proposal constraints, and avoiding common pitfalls like GeometryReader misuse, developers can build responsive, performant, and maintainable interfaces. The shift to Layout in iOS 16 provides the tools necessary to handle complex arrangements with efficiency, closing the gap between declarative convenience and imperative control. Use the decision matrix and checklist to audit existing code and prioritize migrations to Layout for any custom arrangement logic.