Skip to content

Building a Flow Layout in SwiftUI: Wrapping Views Like CSS Flexbox

Introduction

One of the most requested layout features in SwiftUI is a flow layout—a container that arranges items horizontally and wraps them to the next line when they don't fit. If you've used CSS Flexbox with flex-wrap: wrap, you know exactly what we're talking about.

SwiftUI doesn't provide a built-in flow layout, but since iOS 16, the Layout protocol lets us build one ourselves. In this article, we'll create a production-ready FlowLayout that's perfect for:

  • Tag clouds - Displaying variable-width tags
  • Chip collections - Filter chips, suggestion chips
  • Skill badges - Showing lists of skills or categories
  • Any wrapping content - Where items have different widths

Prerequisites

  • iOS 16+ / macOS 13+ (required for the Layout protocol)
  • Basic understanding of SwiftUI views
  • Xcode 14 or later

Understanding the Layout Protocol

The Layout protocol requires two methods:

  1. sizeThatFits - Calculate how much space the layout needs
  2. placeSubviews - Position each child view within the bounds
swift
protocol Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout CacheData
    ) -> CGSize

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout CacheData
    )
}

The key insight is that we calculate the layout once and use the same logic for both sizing and placement.

The FlowLayout Implementation

Here's a complete, production-ready implementation:

swift
import SwiftUI

/// A layout that arranges its children in a horizontal flow,
/// wrapping to the next line when needed.
struct FlowLayout: Layout {
    var horizontalSpacing: CGFloat = 8
    var verticalSpacing: CGFloat = 8

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        return layout(sizes: sizes, containerWidth: proposal.width ?? .infinity).size
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        let offsets = layout(sizes: sizes, containerWidth: bounds.width).offsets

        for (index, subview) in subviews.enumerated() {
            subview.place(
                at: CGPoint(
                    x: bounds.minX + offsets[index].x,
                    y: bounds.minY + offsets[index].y
                ),
                proposal: ProposedViewSize(sizes[index])
            )
        }
    }

    private func layout(
        sizes: [CGSize],
        containerWidth: CGFloat
    ) -> (offsets: [CGPoint], size: CGSize) {
        var offsets: [CGPoint] = []
        var currentX: CGFloat = 0
        var currentY: CGFloat = 0
        var lineHeight: CGFloat = 0
        var maxWidth: CGFloat = 0

        for size in sizes {
            // Check if we need to wrap to the next line
            if currentX + size.width > containerWidth, currentX > 0 {
                currentX = 0
                currentY += lineHeight + verticalSpacing
                lineHeight = 0
            }

            offsets.append(CGPoint(x: currentX, y: currentY))
            lineHeight = max(lineHeight, size.height)
            currentX += size.width + horizontalSpacing
            maxWidth = max(maxWidth, currentX - horizontalSpacing)
        }

        let totalHeight = currentY + lineHeight
        return (offsets, CGSize(width: maxWidth, height: totalHeight))
    }
}

How It Works

Let's break down the algorithm:

1. Measuring Subviews

swift
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }

We ask each subview for its ideal size using .unspecified, which tells the view "give me your natural size without constraints."

2. The Layout Algorithm

The core logic in the layout function:

  1. Start at position (0, 0)
  2. For each view, check if it fits on the current line
  3. If it doesn't fit and we're not at the start of a line, wrap to the next line
  4. Record the view's position and update the current position
swift
if currentX + size.width > containerWidth, currentX > 0 {
    // Wrap to next line
    currentX = 0
    currentY += lineHeight + verticalSpacing
    lineHeight = 0
}

Why check currentX > 0?

This prevents an infinite loop when a single view is wider than the container. If the first item on a line is too wide, we place it anyway rather than wrapping endlessly.

3. Tracking Line Height

Each line can have items of different heights. We track the maximum height of the current line:

swift
lineHeight = max(lineHeight, size.height)

When we wrap, we use this to position the next row correctly.

Basic Usage

swift
struct TagCloudView: View {
    let tags = ["Swift", "SwiftUI", "iOS Development", "Xcode",
                "UIKit", "Combine", "Async/Await", "Core Data"]

    var body: some View {
        FlowLayout(horizontalSpacing: 8, verticalSpacing: 8) {
            ForEach(tags, id: \.self) { tag in
                Text(tag)
                    .padding(.horizontal, 12)
                    .padding(.vertical, 8)
                    .background(Capsule().fill(Color.blue.opacity(0.2)))
            }
        }
        .padding()
    }
}

Real-World Example: Suggestion Chips

Here's a more complete example showing interactive chips:

swift
struct SuggestionChip: View {
    let title: String
    let isSelected: Bool
    let onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            HStack(spacing: 6) {
                Text(title)
                    .font(.subheadline)

                if isSelected {
                    Image(systemName: "checkmark")
                        .font(.system(size: 12, weight: .bold))
                        .foregroundStyle(.blue)
                }
            }
            .padding(.horizontal, 14)
            .padding(.vertical, 8)
            .background(chipBackground)
        }
        .buttonStyle(.plain)
    }

    private var chipBackground: some View {
        Capsule()
            .fill(isSelected ? Color.blue.opacity(0.15) : Color.gray.opacity(0.1))
            .overlay(
                Capsule()
                    .strokeBorder(
                        isSelected ? Color.blue : Color.clear,
                        lineWidth: 1.5
                    )
            )
    }
}

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

    let suggestions = [
        "Morning routine ☀️",
        "Exercise 💪",
        "Read a book 📚",
        "Drink water 💧",
        "Meditation 🧘",
        "Call a friend 📞",
        "Learn something new 🧠"
    ]

    var body: some View {
        FlowLayout(horizontalSpacing: 8, verticalSpacing: 10) {
            ForEach(suggestions, id: \.self) { suggestion in
                SuggestionChip(
                    title: suggestion,
                    isSelected: selected.contains(suggestion),
                    onTap: {
                        withAnimation(.spring(response: 0.3)) {
                            if selected.contains(suggestion) {
                                selected.remove(suggestion)
                            } else {
                                selected.insert(suggestion)
                            }
                        }
                    }
                )
            }
        }
        .padding()
    }
}

Adding Alignment Support

You might want to center or right-align your flow layout. Here's an extended version:

swift
struct AlignedFlowLayout: Layout {
    enum HorizontalAlignment {
        case leading, center, trailing
    }

    var horizontalSpacing: CGFloat = 8
    var verticalSpacing: CGFloat = 8
    var alignment: HorizontalAlignment = .leading

    // ... sizeThatFits and placeSubviews implementation ...

    private func layout(
        sizes: [CGSize],
        containerWidth: CGFloat
    ) -> (offsets: [CGPoint], size: CGSize) {
        // First pass: calculate lines
        var lines: [[Int]] = [[]]
        var lineWidths: [CGFloat] = [0]
        var currentX: CGFloat = 0

        for (index, size) in sizes.enumerated() {
            if currentX + size.width > containerWidth, currentX > 0 {
                lines.append([])
                lineWidths.append(0)
                currentX = 0
            }
            lines[lines.count - 1].append(index)
            lineWidths[lineWidths.count - 1] = currentX + size.width
            currentX += size.width + horizontalSpacing
        }

        // Second pass: calculate offsets with alignment
        var offsets = [CGPoint](repeating: .zero, count: sizes.count)
        var currentY: CGFloat = 0

        for (lineIndex, line) in lines.enumerated() {
            let lineWidth = lineWidths[lineIndex]
            let lineOffset: CGFloat = switch alignment {
                case .leading: 0
                case .center: (containerWidth - lineWidth) / 2
                case .trailing: containerWidth - lineWidth
            }

            var currentX = lineOffset
            var lineHeight: CGFloat = 0

            for index in line {
                offsets[index] = CGPoint(x: currentX, y: currentY)
                lineHeight = max(lineHeight, sizes[index].height)
                currentX += sizes[index].width + horizontalSpacing
            }

            currentY += lineHeight + verticalSpacing
        }

        return (offsets, CGSize(width: containerWidth, height: currentY - verticalSpacing))
    }
}

Performance Considerations

Computing Layout Twice

Notice that we calculate sizes in both sizeThatFits and placeSubviews. For most use cases this is fine, but if you have hundreds of items, consider using the cache parameter to store computed values.

swift
struct FlowLayout: Layout {
    struct CacheData {
        var sizes: [CGSize] = []
    }

    func makeCache(subviews: Subviews) -> CacheData {
        CacheData(sizes: subviews.map { $0.sizeThatFits(.unspecified) })
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout CacheData
    ) -> CGSize {
        layout(sizes: cache.sizes, containerWidth: proposal.width ?? .infinity).size
    }

    // ... rest of implementation uses cache.sizes
}

Comparison with Other Approaches

ApproachProsCons
Layout protocolNative, performant, clean APIiOS 16+ only
GeometryReaderWorks on older iOSCauses layout passes, harder to implement
LazyVGridBuilt-in, lazy loadingColumns are fixed, not true flow
Third-party librariesFeature-richExternal dependency

Conclusion

The Layout protocol makes building custom layouts in SwiftUI straightforward. Our FlowLayout:

  1. Wraps content naturally - Like CSS flex-wrap: wrap
  2. Handles variable heights - Each row adapts to its tallest item
  3. Supports custom spacing - Both horizontal and vertical
  4. Is production-ready - Handles edge cases like oversized items

The full implementation is under 60 lines of code and has zero dependencies. Use it for tag clouds, filter chips, skill badges, or any UI that needs flexible wrapping behavior.