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
Layoutprotocol) - Basic understanding of SwiftUI views
- Xcode 14 or later
Understanding the Layout Protocol
The Layout protocol requires two methods:
sizeThatFits- Calculate how much space the layout needsplaceSubviews- Position each child view within the bounds
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:
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
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:
- Start at position (0, 0)
- For each view, check if it fits on the current line
- If it doesn't fit and we're not at the start of a line, wrap to the next line
- Record the view's position and update the current position
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:
lineHeight = max(lineHeight, size.height)When we wrap, we use this to position the next row correctly.
Basic Usage
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:
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:
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.
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
| Approach | Pros | Cons |
|---|---|---|
Layout protocol | Native, performant, clean API | iOS 16+ only |
GeometryReader | Works on older iOS | Causes layout passes, harder to implement |
LazyVGrid | Built-in, lazy loading | Columns are fixed, not true flow |
| Third-party libraries | Feature-rich | External dependency |
Conclusion
The Layout protocol makes building custom layouts in SwiftUI straightforward. Our FlowLayout:
- Wraps content naturally - Like CSS
flex-wrap: wrap - Handles variable heights - Each row adapts to its tallest item
- Supports custom spacing - Both horizontal and vertical
- 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.

