SwiftUI Custom Layout Protocol: Build Your Own Layouts in iOS 26

Build production-ready custom SwiftUI layouts in iOS 26. Walk through ProposedViewSize, layout caches, LayoutValueKey, and animating between layouts with AnyLayout.

SwiftUI Layout Protocol Guide (iOS 26)

Updated: June 14, 2026

The SwiftUI Layout protocol in iOS 26 lets you build a custom container view by implementing just two methods: sizeThatFits(proposal:subviews:cache:) reports how much space your layout needs, and placeSubviews(in:proposal:subviews:cache:) positions each child inside that space. It's been available since iOS 16, refined through iOS 26, and gives you the same primitive that HStack, VStack, and Grid are built on. I'll walk you through the full protocol, three production-ready custom layouts, layout caches, LayoutValueKey, and how to animate between layouts with AnyLayout.

  • A custom layout is a struct that conforms to Layout and implements sizeThatFits and placeSubviews; SwiftUI invokes it during the measure-then-place layout pass.
  • ProposedViewSize carries optional width and height. A nil means "any size you want", .zero asks for the minimum, and .infinity asks for the maximum.
  • Cache expensive intermediate measurements by implementing makeCache and updateCache. SwiftUI calls placeSubviews after sizeThatFits with the same cache, so you can reuse the work.
  • Attach per-child data through a LayoutValueKey and read it inside the layout via subviews[i][YourKey.self]. That's how priorities, spans, and weights get passed in.
  • Wrap your Layout in AnyLayout to swap between layouts with a transition and animate every child's position automatically.
  • Always honor the proposal. Returning a size larger than what was proposed forces parents to clip or scroll, breaks layout contracts, and produces the "view is too wide" warnings in Xcode 26.

What is the SwiftUI Layout protocol?

The Layout protocol is the type that HStack, VStack, ZStack, and Grid all conform to. When you write your own layout you get the same first-class status. SwiftUI hands you a list of subview proxies, asks how big you'd like to be given a size proposal, then asks you to place each child. No view tree manipulation, no nested GeometryReader, no preference-key plumbing. Just two methods and a value-type container.

iOS 26 ships with Swift 6.2's strict concurrency on by default, and Layout is implicitly main-actor isolated because it participates in the SwiftUI render loop. If you import the framework you can write the layout as a plain struct; you don't need to mark anything @MainActor manually. The protocol itself hasn't changed in iOS 26. What changed is that previews, transitions, and the new scrollPosition APIs interact better with custom layouts, so masonry grids and tag clouds finally compose cleanly inside ScrollView.

The minimum supported deployment target is iOS 16 / macOS 13 / watchOS 9 / tvOS 16 / visionOS 1. Anything you build with Layout runs on every Apple platform that supports SwiftUI 4 and later. Apple's official Layout protocol reference covers the type-level details; this article focuses on building things that ship.

The layout pass and ProposedViewSize

Every SwiftUI layout pass runs in two phases. First the parent proposes a size to your layout via ProposedViewSize. You return the size you actually want from sizeThatFits. Then SwiftUI proposes a final size (typically the one you just returned) and calls placeSubviews so you can assign each child a position. The same cache value is threaded through both calls, so you can reuse measurement work.

ProposedViewSize is a struct with two CGFloat? fields, width and height. The optional is meaningful. A nil width means "you decide", .zero width means "give me the smallest you can", and .infinity width means "take whatever you need". You'll see all three in practice: .fixedSize() proposes nil, an ideal-size pass proposes nil, and ScrollView proposes .infinity on the scroll axis.

Replace optionals with sensible defaults using proposal.replacingUnspecifiedDimensions(), which returns a concrete CGSize using your ideal width and height. Most layouts call it on entry, then constrain children explicitly. Here's the full protocol. Note that the associated Cache type defaults to Void, so the cache parameter is invisible until you opt in.

protocol Layout: Animatable {
    associatedtype Cache = Void
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    ) -> CGSize
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    )
    func makeCache(subviews: Subviews) -> Cache
    func updateCache(_ cache: inout Cache, subviews: Subviews)
}

A minimal custom layout: equal-width flow

So, let's build the smallest useful custom layout. A horizontal flow that gives every child the same width, wraps to the next row when it runs out of horizontal space, and reports back the total height it consumed. This is the layout I reach for first when I'm building tag clouds, chip groups, or filter bars. Honestly, I've shipped some version of it in every SwiftUI app I've touched.

import SwiftUI

struct EqualFlowLayout: Layout {
    var itemWidth: CGFloat = 120
    var spacing: CGFloat = 8

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let maxWidth = proposal.replacingUnspecifiedDimensions().width
        let perRow = max(1, Int((maxWidth + spacing) / (itemWidth + spacing)))
        let rows = Int(ceil(Double(subviews.count) / Double(perRow)))
        let rowHeights = subviews.map { sub in
            sub.sizeThatFits(.init(width: itemWidth, height: nil)).height
        }
        // Tallest item per row, summed across rows.
        let perRowMax = stride(from: 0, to: rowHeights.count, by: perRow).map {
            rowHeights[$0.. bounds.maxX {
                x = bounds.minX
                y += rowMaxHeight + spacing
                rowMaxHeight = 0
            }
            sub.place(at: CGPoint(x: x, y: y), proposal: proposalForChild)
            x += itemWidth + spacing
            rowMaxHeight = max(rowMaxHeight, size.height)
        }
    }
}

Call it like any other container: EqualFlowLayout { ForEach(tags) { Tag($0) } }. Notice how we propose itemWidth with a nil height, letting each child decide how tall it wants to be. Honoring the proposal this way is what keeps the parent's layout warnings quiet in Xcode 26's View Layout instrument.

Building a radial layout

Radial layouts are the showpiece example in Apple's WWDC22 "Compose custom layouts with SwiftUI" talk, mostly because they prove the protocol is general-purpose. Here we'll wrap children evenly around a circle whose radius is half the smaller proposed dimension, then place each child centered on its angle.

struct RadialLayout: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        proposal.replacingUnspecifiedDimensions()
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        guard !subviews.isEmpty else { return }
        let radius = min(bounds.width, bounds.height) / 2
        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        let step = Angle.degrees(360.0 / Double(subviews.count))

        for (index, sub) in subviews.enumerated() {
            let angle = step * Double(index) - .degrees(90)
            let x = center.x + radius * cos(angle.radians)
            let y = center.y + radius * sin(angle.radians)
            sub.place(
                at: CGPoint(x: x, y: y),
                anchor: .center,
                proposal: .unspecified
            )
        }
    }
}

The interesting line is sub.place(at:anchor:proposal:). The anchor parameter lets you place a child by its center, top-leading, or any UnitPoint, with no extra math required. Proposing .unspecified tells each child to pick its own ideal size, which is what you want for a button or icon being placed around a clock face. This pairs nicely with the gesture system if you want to drag items along the ring; the same coordinate space applies, and our SwiftUI gestures guide covers the drag math.

A masonry layout with a layout cache

Masonry (the Pinterest-style packed-columns layout) is the canonical case for a layout cache. Computing column assignments is expensive because every item placement depends on the running column heights, and SwiftUI calls sizeThatFits and placeSubviews with the same input multiple times during a single pass. Doing the work once and reusing it doubled frame-rate in one of my apps where I'd shipped the uncached version first. Lesson learned.

struct MasonryLayout: Layout {
    var columns: Int = 2
    var spacing: CGFloat = 12

    struct CacheData {
        var columnHeights: [CGFloat]
        var positions: [CGPoint]
        var totalSize: CGSize
    }

    func makeCache(subviews: Subviews) -> CacheData {
        CacheData(columnHeights: [], positions: [], totalSize: .zero)
    }

    func updateCache(_ cache: inout CacheData, subviews: Subviews) {
        cache = CacheData(columnHeights: [], positions: [], totalSize: .zero)
    }

    private func compute(
        in width: CGFloat,
        subviews: Subviews,
        cache: inout CacheData
    ) {
        let columnWidth = (width - spacing * CGFloat(columns - 1)) / CGFloat(columns)
        var heights = Array(repeating: CGFloat(0), count: columns)
        var positions: [CGPoint] = []

        for sub in subviews {
            let size = sub.sizeThatFits(
                .init(width: columnWidth, height: nil)
            )
            let shortest = heights.indices.min(by: { heights[$0] < heights[$1] }) ?? 0
            let x = CGFloat(shortest) * (columnWidth + spacing)
            let y = heights[shortest]
            positions.append(CGPoint(x: x, y: y))
            heights[shortest] = y + size.height + spacing
        }

        cache.columnHeights = heights
        cache.positions = positions
        cache.totalSize = CGSize(
            width: width,
            height: (heights.max() ?? 0) - spacing
        )
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout CacheData
    ) -> CGSize {
        let width = proposal.replacingUnspecifiedDimensions().width
        compute(in: width, subviews: subviews, cache: &cache)
        return cache.totalSize
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout CacheData
    ) {
        if cache.positions.count != subviews.count {
            compute(in: bounds.width, subviews: subviews, cache: &cache)
        }
        let columnWidth =
            (bounds.width - spacing * CGFloat(columns - 1)) / CGFloat(columns)
        for (i, sub) in subviews.enumerated() {
            let origin = CGPoint(
                x: bounds.minX + cache.positions[i].x,
                y: bounds.minY + cache.positions[i].y
            )
            sub.place(
                at: origin,
                proposal: .init(width: columnWidth, height: nil)
            )
        }
    }
}

SwiftUI calls updateCache whenever the subview list changes structurally. Our implementation invalidates the cache eagerly because masonry has no incremental update strategy; a smarter version could detect appends and only recompute the tail. For most apps the eager invalidation is fine. The cost is one extra pass per data change, not per frame.

LayoutValueKey: passing per-child data

Sometimes the layout decision depends on data the child carries: a span count for a grid cell, a priority for a chip, a Z-order for a stack. LayoutValueKey is SwiftUI's typed back-channel from the child to the parent layout. You define a key with a default value, expose a convenience modifier, and read it on each subview proxy inside the layout. It composes cleanly with the @Observable macro when the per-child value comes from a model.

private struct ColumnSpanKey: LayoutValueKey {
    static let defaultValue: Int = 1
}

extension View {
    func columnSpan(_ span: Int) -> some View {
        layoutValue(key: ColumnSpanKey.self, value: span)
    }
}

// Inside the layout:
for (i, sub) in subviews.enumerated() {
    let span = sub[ColumnSpanKey.self]
    // ...use span to compute width.
}

The value type can be anything Sendable. I commonly use enums for alignment, structs for "minimum width plus weight", and integers for span. Because the value is read through a subview proxy rather than the view itself, the layout never instantiates the child. No extra allocation, no preference-key bouncing.

How do you animate between layouts in SwiftUI?

Wrap two or more layouts in AnyLayout and assign the chosen one to a state variable. Because AnyLayout conforms to Layout, SwiftUI sees a single container whose identity is stable across layout changes, and interpolates every child's position with the surrounding withAnimation block. This is the cleanest way to morph between a grid, a radial layout, and a stack without losing child identity or restarting transitions. The same pattern composes with the spring presets from our SwiftUI animations guide.

struct LayoutMorphDemo: View {
    @State private var radial = false

    var body: some View {
        let layout = radial
            ? AnyLayout(RadialLayout())
            : AnyLayout(EqualFlowLayout())

        VStack {
            layout {
                ForEach(0..<12) { i in
                    Circle()
                        .fill(.tint)
                        .frame(width: 44, height: 44)
                        .overlay(Text("\(i)").foregroundStyle(.white))
                }
            }
            .frame(height: 320)
            .animation(.snappy, value: radial)

            Button(radial ? "Show flow" : "Show radial") {
                radial.toggle()
            }
        }
    }
}

If the morph feels jumpy, give each child an explicit .transition(.scale) or apply .geometryGroup() on the container. geometryGroup() isolates the children's coordinate space so SwiftUI animates positions independent of the parent's frame, which is exactly what you want when an outer container resizes during the morph.

How is Layout different from GeometryReader?

GeometryReader reads its proposed size and exposes it through a closure. It does not participate in the parent's layout algorithm, and it always claims all available space. Layout participates: you decide your own size, you place children explicitly, and you affect the parent's layout decisions. The two solve different problems, and you should reach for Layout any time you'd otherwise nest GeometryReader just to position siblings.

FeatureLayout protocolGeometryReader
Returns its own sizeYes, via sizeThatFitsNo, claims all proposed space
Places childrenYes, via placeSubviewsIndirectly through frames
Receives a proposalProposedViewSizeConcrete GeometryProxy
Supports a cacheYes, typed and reused across passesNo
AnimatableYes (Animatable conformance)Only the children animate
Best forCustom containers, morphs, gridsReading sizes, backgrounds, gauges

One rule of thumb: if the answer to "what positions should my children be at?" depends only on the children and the available space, you want Layout. If it depends on a parent's coordinate space, or on a measurement that needs to leave the closure, reach for GeometryReader or, in iOS 26, onGeometryChange(for:of:action:), which is the modern, non-rendering way to observe sizes.

Common mistakes and debugging

These are the failure modes I see most often when reviewing pull requests that introduce a custom Layout. I've shipped a couple of them myself, so no judgment.

  • Returning more than the proposal. If sizeThatFits ignores proposal.width and returns a wider size, SwiftUI logs a layout warning and the parent clips. Always honor a finite proposal.
  • Measuring with .zero proposals. Many devs call sub.sizeThatFits(.zero) hoping for the natural size; that returns the minimum. Use .unspecified for ideal size, or pass a concrete proposal.
  • Mutating state inside the layout. Layout is a value type and SwiftUI may call it many times per pass. Store derived state in the cache, never in stored properties.
  • Skipping updateCache. If you cache anything dependent on the subview list, you must invalidate when the count or identifiers change, or stale positions appear after deletion.
  • Forgetting Animatable. If your layout has a numeric parameter (like a fold angle), conform animatableData so SwiftUI interpolates it during transitions.

To debug a misbehaving layout, turn on Xcode 26's Debug → View Debugging → Show View Frames in a running simulator. Each layout call is annotated with its proposal and return size. Apple's sizeThatFits documentation spells out the contract; reading it once is worth two hours of staring at print statements (ask me how I know).

Frequently Asked Questions

Is the Layout protocol available on iOS 15?

No. The Layout protocol requires iOS 16 / macOS 13 / watchOS 9 / tvOS 16 or later. On iOS 15 you'll have to fall back to HStack/VStack composition or GeometryReader-based positioning, which is the common reason teams gate custom layouts behind #available.

Do I need to implement makeCache and updateCache?

Only when caching helps. The associated Cache type defaults to Void, so simple layouts skip both methods entirely. Implement them when sizeThatFits does non-trivial work whose result is reused by placeSubviews. Masonry, justified text, and dependency-graph layouts are the typical cases.

Can a custom Layout be used inside a ScrollView?

Yes. Inside ScrollView the scroll axis is proposed as .infinity, so your layout should compute and return its actual content size on that axis. As long as sizeThatFits reports the real height (for a vertical scroll), the scroll view sizes correctly and gestures work.

How do I animate a child appearing inside a custom Layout?

Wrap the child in ForEach with a stable identifier and attach a .transition(...) modifier. Because Layout participates in SwiftUI's identity system, inserted and removed children animate against the layout's current placement automatically when wrapped in withAnimation.

What's the difference between Layout and a ViewModifier?

A ViewModifier transforms a single view; it can clip, decorate, or wrap. A Layout arranges many children with awareness of one another. If you find yourself sizing siblings relative to each other inside a modifier, that's the signal to refactor into a Layout.

Editorial Team
About the Author Editorial Team

Our team of expert writers and editors.