How to Build a Custom Layout in SwiftUI
Conform a struct to the Layout protocol and implement sizeThatFits and placeSubviews to take full control over how SwiftUI measures and positions your child views. No UIKit needed — this is pure SwiftUI geometry.
struct EqualWidthHStack: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let maxW = proposal.width ?? .infinity
let colW = maxW / CGFloat(subviews.count)
let h = subviews.map { $0.sizeThatFits(.init(width: colW, height: proposal.height)).height }.max() ?? 0
return CGSize(width: maxW, height: h)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let colW = bounds.width / CGFloat(subviews.count)
for (i, subview) in subviews.enumerated() {
let x = bounds.minX + colW * CGFloat(i) + colW / 2
subview.place(at: CGPoint(x: x, y: bounds.midY), anchor: .center,
proposal: .init(width: colW, height: bounds.height))
}
}
}
Full implementation
The example below implements a radial (circular) layout — child views are placed evenly around a circle whose radius adapts to the proposed container size. The LayoutValueKey mechanism lets individual subviews opt into a custom radius override, demonstrating how to thread per-view configuration through the Layout API. A cache is used to store expensive trigonometry results and avoid recalculation on every pass.
import SwiftUI
// MARK: - LayoutValueKey for per-child radius override
private struct RadiusKey: LayoutValueKey {
static let defaultValue: CGFloat? = nil
}
extension View {
/// Opt this subview into a specific orbit radius.
func layoutRadius(_ r: CGFloat) -> some View {
layoutValue(key: RadiusKey.self, value: r)
}
}
// MARK: - Cache
struct RadialCache {
var radius: CGFloat = 0
var angles: [CGFloat] = []
}
// MARK: - Custom Layout
struct RadialLayout: Layout {
/// Padding inside the proposed bounds before computing the radius.
var padding: CGFloat = 12
func makeCache(subviews: Subviews) -> RadialCache {
RadialCache()
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout RadialCache
) -> CGSize {
// Use the smaller dimension to keep the circle fully visible.
let size = proposal.replacingUnspecifiedDimensions()
let diameter = min(size.width, size.height)
cache.radius = diameter / 2 - padding
cache.angles = angles(for: subviews.count)
// Report back the full proposed size so we don't shrink the container.
return CGSize(width: size.width, height: size.height)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout RadialCache
) {
let center = CGPoint(x: bounds.midX, y: bounds.midY)
for (index, subview) in subviews.enumerated() {
// Allow individual subviews to specify their own radius.
let r = subview[RadiusKey.self] ?? cache.radius
let angle = cache.angles[index]
let x = center.x + r * cos(angle)
let y = center.y + r * sin(angle)
// Propose the subview's ideal size in both axes.
let idealSize = subview.sizeThatFits(.unspecified)
subview.place(
at: CGPoint(x: x, y: y),
anchor: .center,
proposal: .init(width: idealSize.width, height: idealSize.height)
)
}
}
// MARK: - Helpers
private func angles(for count: Int) -> [CGFloat] {
guard count > 0 else { return [] }
let step = (2 * CGFloat.pi) / CGFloat(count)
// Start at the top (-π/2) so the first item appears at 12 o'clock.
return (0..
How it works
-
sizeThatFits— measure phase. SwiftUI calls this first (possibly multiple times) to ask "how big do you want to be given this proposal?" Here we readproposal.replacingUnspecifiedDimensions()to get a concrete size, derive the radius, and populate the cache so we don't redo trigonometry in the placement phase. -
placeSubviews— placement phase. Called once after sizing is resolved. We iterate theSubviewsproxy collection and callsubview.place(at:anchor:proposal:), converting polar coordinates (angle + radius) to Cartesian points relative to the container'sbounds. -
LayoutValueKey— per-subview configuration. TheRadiusKeytype lets any child opt into a custom radius via.layoutRadius(_:). The layout reads it withsubview[RadiusKey.self]and falls back to the computed default when nil. -
Cache via
makeCache(subviews:). TheRadialCachestruct stores the precomputed angles array and radius soplaceSubviewsskips trigonometry entirely. SwiftUI invalidates the cache automatically when subviews change. -
Anchor-based placement.
Passing
anchor: .centertoplace(at:)means the coordinate we provide is the subview's center — much easier than manually offsetting by half the child's width/height.
Variants
Animated layout transitions
Because Layout is a value type, you can interpolate between two layouts using AnyLayout and withAnimation — SwiftUI automatically morphs subview positions.
struct ToggleLayoutDemo: View {
@State private var useRadial = true
var layout: AnyLayout {
useRadial
? AnyLayout(RadialLayout(padding: 24))
: AnyLayout(HStackLayout(spacing: 8))
}
var body: some View {
VStack(spacing: 24) {
layout {
ForEach(["🍎","🍊","🍋","🍇","🍓"], id: \.self) { e in
Text(e).font(.largeTitle)
.accessibilityLabel(e)
}
}
.frame(height: 280)
Button(useRadial ? "Switch to HStack" : "Switch to Radial") {
withAnimation(.spring(duration: 0.5)) {
useRadial.toggle()
}
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
#Preview { ToggleLayoutDemo() }
Spacing via LayoutSubview priorities
You can read each child's layout priority with subview.priority and allocate extra space proportionally — the same mechanism HStack uses when distributing flexible space. Combine this with subview.flexibility(in:) (returns ViewDimensions flexibility ranges) to build shrinking/growing logic identical to the built-in stacks.
Common pitfalls
-
⚠️ iOS 16 availability. The
Layoutprotocol shipped in iOS 16, butLayoutValueKeyand severalProposedViewSizehelpers were refined in iOS 17. Always set your deployment target to iOS 17+ to avoid subtle behavioural differences and missing overloads. -
⚠️ Infinite proposals crash sizing. When
proposal.widthorproposal.heightisnil(unspecified) or.infinity, arithmetic to derive a radius or column width will produce NaN or infinity. Always guard withreplacingUnspecifiedDimensions()or a nil-coalescing fallback before dividing. -
⚠️ Cache invalidation is automatic — don't store view state in it. SwiftUI calls
makeCachewhenever subviews are added or removed, and may callupdateCacheon every layout pass. Keep the cache lightweight (geometry, not data models) and never rely on it persisting across arbitrary view updates. -
⚠️ Accessibility positions follow layout coordinates. VoiceOver reads subviews in source order by default, but swipe navigation follows visual position if you use
.accessibilitySortPriority. For non-linear layouts like radial, double-check the reading order with the Accessibility Inspector.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a custom layout in SwiftUI for iOS 17+. Use the Layout protocol (sizeThatFits, placeSubviews, makeCache). Support a LayoutValueKey so individual subviews can override placement parameters. Make it accessible (VoiceOver labels, correct reading order). Add a #Preview with realistic sample data showing at least 6 child views.
In Soarias's Build phase, paste this prompt into the Claude Code panel after scaffolding your feature file — it drops a production-ready layout struct directly into your target, skipping the boilerplate of UIKit's layoutSubviews override entirely.
Related
FAQ
Does this work on iOS 16?
The Layout protocol was introduced in iOS 16, so the basics compile. However, several APIs used here — including improved ProposedViewSize helpers and LayoutValueKey refinements — behave more predictably on iOS 17+. We strongly recommend targeting iOS 17 to avoid edge-case sizing bugs and get the full feature set.
When should I use a custom Layout instead of a ZStack + GeometryReader?
Use the Layout protocol whenever you need to measure all children before placing any of them — for example, equal-width columns, radial arrangements, or flow wrapping. GeometryReader only tells you about its parent's size, not its siblings', so multi-pass measurement that reads sibling dimensions is impossible without Layout. If you just need to overlay one view on another, a plain ZStack is simpler and cheaper.
What's the UIKit equivalent?
The closest UIKit equivalent is overriding layoutSubviews() in a UIView subclass, paired with intrinsicContentSize for sizing — or implementing a UICollectionViewLayout for collection-style arrangements. The SwiftUI Layout protocol is considerably less boilerplate, integrates natively with animations via AnyLayout, and requires no coordinate-system translation between parent and child frames.
Last reviewed: 2026-05-11 by the Soarias team.