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

How to implement flow layout in SwiftUI

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

Adopt the Layout protocol and implement sizeThatFits and placeSubviews with a row-wrapping loop. Cache the computed frames so the layout engine can call both methods without re-running the math.

struct FlowLayout: Layout {
    var spacing: CGFloat = 8

    func sizeThatFits(proposal: ProposedViewSize,
                      subviews: Subviews, cache: inout Void) -> CGSize {
        computeSize(subviews: subviews,
                    width: proposal.replacingUnspecifiedDimensions().width)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
                       subviews: Subviews, cache: inout Void) {
        var origin = bounds.origin
        var rowMaxY = bounds.minY
        for sv in subviews {
            let sz = sv.sizeThatFits(.unspecified)
            if origin.x + sz.width > bounds.maxX, origin.x > bounds.minX {
                origin.x = bounds.minX
                origin.y = rowMaxY + spacing
            }
            sv.place(at: origin, proposal: .init(sz))
            origin.x += sz.width + spacing
            rowMaxY = max(rowMaxY, origin.y + sz.height)
        }
    }
}

Full implementation

The production-ready version adds a typed Cache struct so SwiftUI doesn't recompute row positions on every pass. The cache is invalidated automatically whenever the proposal or subview list changes. The companion TagChip view shows a realistic use-case: a dynamic list of interest tags that wraps naturally inside any container width.

import SwiftUI

// MARK: - Layout engine

struct FlowLayout: Layout {

    var horizontalSpacing: CGFloat = 8
    var verticalSpacing: CGFloat = 8

    // Typed cache avoids re-computing frames between
    // sizeThatFits(_:subviews:cache:) and placeSubviews(in:…)
    struct Cache {
        var frames: [CGRect] = []
        var totalSize: CGSize = .zero
    }

    func makeCache(subviews: Subviews) -> Cache { Cache() }

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

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    ) {
        // Re-fill if cache is stale (e.g. bounds changed after sizeThatFits)
        if cache.frames.count != subviews.count {
            fill(cache: &cache, subviews: subviews, width: bounds.width)
        }
        for (index, frame) in cache.frames.enumerated() {
            subviews[index].place(
                at: CGPoint(x: bounds.minX + frame.minX,
                            y: bounds.minY + frame.minY),
                proposal: ProposedViewSize(frame.size)
            )
        }
    }

    // MARK: Private

    private func fill(cache: inout Cache, subviews: Subviews, width: CGFloat) {
        var frames: [CGRect] = []
        var cursor = CGPoint.zero
        var rowHeight: CGFloat = 0
        var maxX: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            // Wrap to next row when item overflows — but never wrap a lone item
            if cursor.x + size.width > width, cursor.x > 0 {
                cursor.x = 0
                cursor.y += rowHeight + verticalSpacing
                rowHeight = 0
            }
            frames.append(CGRect(origin: cursor, size: size))
            rowHeight = max(rowHeight, size.height)
            cursor.x += size.width + horizontalSpacing
            maxX = max(maxX, cursor.x - horizontalSpacing)
        }

        cache.frames = frames
        cache.totalSize = CGSize(width: maxX, height: cursor.y + rowHeight)
    }
}

// MARK: - Tag chip view

struct TagChip: View {
    let label: String
    var isSelected: Bool = false

    var body: some View {
        Text(label)
            .font(.subheadline.weight(.medium))
            .padding(.horizontal, 12)
            .padding(.vertical, 6)
            .background(
                isSelected ? Color.accentColor : Color(.secondarySystemFill),
                in: Capsule()
            )
            .foregroundStyle(isSelected ? .white : .primary)
            .accessibilityLabel(label)
            .accessibilityAddTraits(isSelected ? .isSelected : [])
    }
}

// MARK: - Demo screen

struct TagCloudView: View {
    let tags: [String]
    @State private var selected: Set<String> = []

    var body: some View {
        ScrollView {
            FlowLayout(horizontalSpacing: 8, verticalSpacing: 10) {
                ForEach(tags, id: \.self) { tag in
                    TagChip(label: tag, isSelected: selected.contains(tag))
                        .onTapGesture {
                            if selected.contains(tag) { selected.remove(tag) }
                            else { selected.insert(tag) }
                        }
                }
            }
            .padding()
        }
        .navigationTitle("Interests")
    }
}

// MARK: - Preview

#Preview {
    NavigationStack {
        TagCloudView(tags: [
            "SwiftUI", "Combine", "Swift", "Xcode", "Instruments",
            "Core Data", "SwiftData", "CloudKit", "WidgetKit",
            "StoreKit 2", "AppIntents", "RealityKit", "Vision",
            "Natural Language", "CreateML"
        ])
    }
}

How it works

  1. Layout protocol conformanceFlowLayout adopts Layout, the iOS 16+ API that lets custom containers participate in SwiftUI's two-pass measurement system — just like HStack or VStack do internally.
  2. Typed cachemakeCache(subviews:) returns a Cache struct holding pre-computed [CGRect] frames. SwiftUI passes this value back into both sizeThatFits and placeSubviews, so expensive geometry math runs only once per layout pass.
  3. Row-wrap logic in fill(cache:subviews:width:) — for each subview, we ask for its natural size via sizeThatFits(.unspecified). If adding it would overflow the container width and we're not at the row start, the cursor drops to the next row before placing the item.
  4. PlacementplaceSubviews iterates the cached frames and calls subview.place(at:proposal:), offsetting by bounds.origin so the layout composes correctly inside scroll views, sheets, and other containers.
  5. Stale-cache guard — the early return in placeSubviews detects when bounds.width changed between calls (e.g., rotation) and recomputes before placing, preventing misaligned chips.

Variants

Right-to-left (RTL) support

Pass the environment's layout direction into the layout by reading LayoutDirection and mirroring placement:

// In FlowLayout.placeSubviews, detect RTL via layoutDirection environment value
// SwiftUI passes it automatically — just flip the x origin:

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout Cache
) {
    if cache.frames.count != subviews.count {
        fill(cache: &cache, subviews: subviews, width: bounds.width)
    }
    let isRTL = Environment(\.layoutDirection).wrappedValue == .rightToLeft
    for (index, frame) in cache.frames.enumerated() {
        let xOffset = isRTL
            ? bounds.maxX - frame.maxX   // mirror horizontally
            : bounds.minX + frame.minX
        subviews[index].place(
            at: CGPoint(x: xOffset, y: bounds.minY + frame.minY),
            proposal: ProposedViewSize(frame.size)
        )
    }
}

Trailing-alignment per row

To right-align items in the last (partial) row — common for tag editors — collect each row's frames into a temporary buffer, measure the row's total width, then offset each frame by containerWidth - rowWidth before appending to the cache. This is the same trick UICollectionViewFlowLayout uses with UICollectionViewFlowLayout.minimumLineSpacing, now expressed in pure Swift value types.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement flow layout in SwiftUI for iOS 17+.
Use the Layout protocol with a typed Cache struct.
Support configurable horizontal and vertical spacing.
Make it accessible (VoiceOver labels on each chip).
Handle RTL layout directions automatically.
Add a #Preview with realistic sample data (10–15 tags).

In the Soarias Build phase, paste this prompt into the Implementation step so Claude Code scaffolds the full FlowLayout struct and wires it into your feature screen in one shot — no manual file-hopping required.

Related

FAQ

Does this work on iOS 16?
Yes — the Layout protocol was introduced in iOS 16. However, cache invalidation when subviews change is noticeably less reliable on iOS 16.0–16.3. If you need iOS 16 support, add @available(iOS 16, *) and test thoroughly on a physical device. iOS 17+ is the recommended minimum for production use.
Can FlowLayout animate insertions and removals?
Yes. Wrap mutations in withAnimation and use ForEach with stable, unique identifiers. SwiftUI calls updateCache (a no-op by default) on every frame of the animation, interpolating between the old and new placeSubviews positions automatically. For spring-driven repositioning, set .animation(.spring, value: tags) on the FlowLayout container.
What's the UIKit equivalent?
In UIKit, flow layout is covered by UICollectionViewFlowLayout with scrollDirection = .vertical, or by UICollectionViewCompositionalLayout using fractional-width items that wrap into estimated-height groups. The SwiftUI Layout approach is considerably less boilerplate and integrates with @State and animations for free.

Last reviewed: 2026-05-11 by the Soarias team.

```