Build A Wrapping Chip Layout With The SwiftUI Layout Protocol
Wrapping chips are one of those layouts that start innocent. You have a handful of filters, tags, or category pills, and they should flow onto the next line when they run out of room.
The tempting version uses GeometryReader, measures the width, stores offsets in state, and hopes the second layout pass does not get weird. It can work, but it is also easy to make fragile. SwiftUI already has a better place for this logic: the Layout protocol.
Layout requires iOS 16, iPadOS 16, macOS 13, tvOS 16, watchOS 9, or newer. If your app supports older OS versions, gate this view with availability checks or keep a simpler fallback layout.
Here is a reusable wrapping layout first. Then we will walk through the important pieces and use it for selectable filter chips.
import SwiftUI
struct WrappingChipsLayout: Layout {
var horizontalSpacing: CGFloat = 8
var verticalSpacing: CGFloat = 8
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let sizes = measuredSizes(for: subviews, maxWidth: proposal.width)
let rows = makeRows(from: sizes, maxWidth: proposal.width ?? .infinity)
return CGSize(
width: rows.map(\.width).max() ?? 0,
height: totalHeight(of: rows)
)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let sizes = measuredSizes(for: subviews, maxWidth: bounds.width)
let rows = makeRows(from: sizes, maxWidth: bounds.width)
var y = bounds.minY
for row in rows {
var x = bounds.minX
for element in row.elements {
let yOffset = (row.height - element.size.height) / 2
subviews[element.index].place(
at: CGPoint(x: x, y: y + yOffset),
anchor: .topLeading,
proposal: ProposedViewSize(
width: element.size.width,
height: element.size.height
)
)
x += element.size.width + horizontalSpacing
}
y += row.height + verticalSpacing
}
}
}
That is the whole layout surface. A Layout does two jobs. In sizeThatFits, SwiftUI asks, “Given this proposed size, how much room do you want?” In placeSubviews, SwiftUI gives the layout actual bounds and asks it to put each child somewhere inside them.
The proposal is not a command. A parent might propose a width and leave height unspecified, propose no size at all, or probe with small and large sizes while it decides how the view should fit. Your layout reports the size it wants, but the final bounds you receive in placeSubviews may still be different. That is why this layout calculates rows from the proposal in sizeThatFits, then calculates them again from bounds.width when placing.
Add the row helper
The row helper is where the wrapping behavior lives. It turns an array of measured chip sizes into rows, starting a new row when the next chip would exceed the available width.
private extension WrappingChipsLayout {
struct Row {
struct Element {
var index: Int
var size: CGSize
}
var elements: [Element] = []
var width: CGFloat = 0
var height: CGFloat = 0
mutating func append(index: Int, size: CGSize, spacing: CGFloat) {
if !elements.isEmpty {
width += spacing
}
elements.append(Element(index: index, size: size))
width += size.width
height = max(height, size.height)
}
}
func measuredSizes(for subviews: Subviews, maxWidth: CGFloat?) -> [CGSize] {
let proposalWidth: CGFloat?
if let maxWidth, maxWidth.isFinite, maxWidth > 0 {
proposalWidth = maxWidth
} else {
proposalWidth = nil
}
return subviews.map { subview in
subview.sizeThatFits(
ProposedViewSize(width: proposalWidth, height: nil)
)
}
}
func makeRows(from sizes: [CGSize], maxWidth: CGFloat) -> [Row] {
let availableWidth = maxWidth.isFinite ? maxWidth : .infinity
var rows: [Row] = []
var currentRow = Row()
for (index, size) in sizes.enumerated() {
let spacing = currentRow.elements.isEmpty ? 0 : horizontalSpacing
let proposedWidth = currentRow.width + spacing + size.width
if proposedWidth > availableWidth, !currentRow.elements.isEmpty {
rows.append(currentRow)
currentRow = Row()
}
currentRow.append(
index: index,
size: size,
spacing: currentRow.elements.isEmpty ? 0 : horizontalSpacing
)
}
if !currentRow.elements.isEmpty {
rows.append(currentRow)
}
return rows
}
func totalHeight(of rows: [Row]) -> CGFloat {
rows.enumerated().reduce(CGFloat.zero) { result, pair in
let isLastRow = pair.offset == rows.count - 1
return result + pair.element.height + (isLastRow ? 0 : verticalSpacing)
}
}
}
There are two production details worth calling out.
First, we measure each subview with the available width when we know it. That gives long text a chance to wrap or truncate according to the chip view’s own rules instead of always reporting an ideal one-line width. When no useful width exists, we pass nil for the width and let the child report its natural size.
This version favors natural chip sizing when a parent probes with a zero or unspecified width. That is what I usually want for filter chips, but if you put this layout inside a highly compressible container, test those tiny proposals and adapt the measuring policy if the parent expects a smaller minimum size.
Second, the row stores both width and height. Width decides when to wrap. Height decides how far down the next row starts. Because chips can have different heights at larger Dynamic Type sizes, assuming a fixed row height is where a lot of custom flow layouts start to wobble.
Spacing belongs in the layout
You can use SwiftUI’s built-in stacks for simple horizontal or vertical spacing, but a wrapping layout owns both axes. The helper adds horizontalSpacing only between chips in the same row, and totalHeight adds verticalSpacing only between rows.
That sounds tiny, but it avoids two common off-by-one layout bugs: extra trailing width after the last chip in a row, and extra bottom height after the last row. Those are the bugs that make a layout look slightly too tall inside a form, popover, or sidebar.
The placement code also centers each chip vertically within its row:
let yOffset = (row.height - element.size.height) / 2
That matters once labels, badges, or mixed fonts enter the chip content. Without it, every chip starts at the row’s top edge, which can make shorter chips look like they are floating upward.
Use it for filter chips
Now the layout can be used like any other SwiftUI container. Here is a small filter picker with selectable chips:
struct ProductFilter: Identifiable, Hashable {
let id: String
var title: String
}
struct FilterChipPicker: View {
@State private var selectedFilters: Set<ProductFilter.ID> = []
private let filters = [
ProductFilter(id: "sale", title: "On sale"),
ProductFilter(id: "today", title: "Available today"),
ProductFilter(id: "shipping", title: "Free shipping"),
ProductFilter(id: "new", title: "New arrivals"),
ProductFilter(id: "small-business", title: "Small business"),
ProductFilter(id: "rated", title: "Highly rated"),
ProductFilter(id: "under-50", title: "Under $50"),
ProductFilter(id: "eco", title: "Eco packaging")
]
var body: some View {
WrappingChipsLayout(horizontalSpacing: 8, verticalSpacing: 8) {
ForEach(filters) { filter in
FilterChip(
title: filter.title,
isSelected: selectedFilters.contains(filter.id)
) {
toggle(filter)
}
}
}
.padding()
}
private func toggle(_ filter: ProductFilter) {
if selectedFilters.contains(filter.id) {
selectedFilters.remove(filter.id)
} else {
selectedFilters.insert(filter.id)
}
}
}
And here is the chip view:
struct FilterChip: View {
var title: String
var isSelected: Bool
var action: () -> Void
@ScaledMetric(relativeTo: .body) private var horizontalPadding = 12
@ScaledMetric(relativeTo: .body) private var verticalPadding = 7
var body: some View {
Button(action: action) {
Text(title)
.font(.callout.weight(.semibold))
.lineLimit(2)
.multilineTextAlignment(.center)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
}
.buttonStyle(.plain)
.foregroundStyle(isSelected ? .white : .primary)
.background {
Capsule()
.fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.12))
}
.overlay {
Capsule()
.strokeBorder(
isSelected ? Color.accentColor : Color.secondary.opacity(0.22),
lineWidth: 1
)
}
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
The chip is intentionally responsible for its own text behavior. The layout does not know whether a child is a button, a label, an image, or a custom view. It asks each child how big it wants to be, groups those sizes into rows, and places the child with the size it measured.
@ScaledMetric keeps the padding proportional as Dynamic Type changes. The Text uses the environment’s font metrics automatically, and the layout remeasures the subviews when those inputs change. That is the main win over offset-based layouts: there is no separate state to keep in sync when the user changes text size, a chip becomes selected, or a localized label gets longer.
A few practical edges
If you have a very large number of chips, add a cache. The Layout protocol lets you define a cache type and reuse work between measuring and placement. For a short filter list, recalculating the rows is simpler and plenty fast. For hundreds of tokens in an editor, caching measured sizes and rows is worth the extra code.
If your chip content can become wider than the available row, decide that inside the chip. Use lineLimit, minimumScaleFactor, truncation, or a max-width frame based on the product’s needs. A layout should arrange children; it should not secretly rewrite their content rules.
Also test the layout where it will actually live. A wrapping chip group inside a Form, a toolbar popover, a macOS sidebar, and an iPhone sheet will all receive different proposals from their parents. The code above handles those differences by treating the proposal as input, then using final bounds for placement.
Wrap-up
The Layout protocol is a great fit for flow-style UI because it gives the wrapping logic one clear home. Measure the chips, build rows from the available width, report the row size from sizeThatFits, and place the same rows inside placeSubviews.
Once that loop is in place, the rest is ordinary SwiftUI: buttons, fonts, accessibility traits, scaled padding, and selection state. That is exactly where a production chip picker should be boring.