How to implement flow layout in SwiftUI
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
-
Layout protocol conformance —
FlowLayoutadoptsLayout, the iOS 16+ API that lets custom containers participate in SwiftUI's two-pass measurement system — just likeHStackorVStackdo internally. -
Typed cache —
makeCache(subviews:)returns aCachestruct holding pre-computed[CGRect]frames. SwiftUI passes this value back into bothsizeThatFitsandplaceSubviews, so expensive geometry math runs only once per layout pass. -
Row-wrap logic in
fill(cache:subviews:width:)— for each subview, we ask for its natural size viasizeThatFits(.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. -
Placement —
placeSubviewsiterates the cached frames and callssubview.place(at:proposal:), offsetting bybounds.originso the layout composes correctly inside scroll views, sheets, and other containers. -
Stale-cache guard — the early return in
placeSubviewsdetects whenbounds.widthchanged 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
-
iOS 16 vs iOS 17 cache invalidation. On iOS 16, the
Layoutprotocol exists but cache invalidation is less reliable when subviews change identity. On iOS 17+, the engine correctly invalidates and callsmakeCacheagain. Guard with@available(iOS 16, *)if you need back-deployment, but prefer targeting iOS 17+ for predictable behavior. -
Calling
sizeThatFits(.unspecified)inside a lazy container. Lazy stacks defer measurement, so aFlowLayoutinside aLazyVStackmay be proposedCGSize(width: .infinity, …). Always clamp the proposed width withproposal.replacingUnspecifiedDimensions()or provide an explicit.frame(maxWidth: .infinity)on the layout container. -
Dynamic Type breaking chip widths. When a user bumps text size to Accessibility XL,
sizeThatFits(.unspecified)returns a wider chip than designed for. Set a.lineLimit(1)on chip labels and handle overflow gracefully (truncation or vertical expansion), otherwise chips overflow container bounds silently.
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?
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?
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?
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.