```html SwiftUI: How to Build Custom Layout (iOS 17+, 2026)

How to Build a Custom Layout in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: Layout protocol Updated: May 11, 2026
TL;DR

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

  1. sizeThatFits — measure phase. SwiftUI calls this first (possibly multiple times) to ask "how big do you want to be given this proposal?" Here we read proposal.replacingUnspecifiedDimensions() to get a concrete size, derive the radius, and populate the cache so we don't redo trigonometry in the placement phase.
  2. placeSubviews — placement phase. Called once after sizing is resolved. We iterate the Subviews proxy collection and call subview.place(at:anchor:proposal:), converting polar coordinates (angle + radius) to Cartesian points relative to the container's bounds.
  3. LayoutValueKey — per-subview configuration. The RadiusKey type lets any child opt into a custom radius via .layoutRadius(_:). The layout reads it with subview[RadiusKey.self] and falls back to the computed default when nil.
  4. Cache via makeCache(subviews:). The RadialCache struct stores the precomputed angles array and radius so placeSubviews skips trigonometry entirely. SwiftUI invalidates the cache automatically when subviews change.
  5. Anchor-based placement. Passing anchor: .center to place(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 Layout protocol shipped in iOS 16, but LayoutValueKey and several ProposedViewSize helpers 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.width or proposal.height is nil (unspecified) or .infinity, arithmetic to derive a radius or column width will produce NaN or infinity. Always guard with replacingUnspecifiedDimensions() or a nil-coalescing fallback before dividing.
  • ⚠️ Cache invalidation is automatic — don't store view state in it. SwiftUI calls makeCache whenever subviews are added or removed, and may call updateCache on 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.

```