What You Will Build

A tag/chip selection interface where tags wrap to the next line when they run out of horizontal space, creating a flowing layout. Selected tags highlight in blue with a spring animation, and a summary section shows all selected tags. The flow layout is built using the iOS 16 custom Layout protocol.

Why This Pattern Matters

Tag selection is used in content filtering, interest selection during onboarding, and search refinement. SwiftUI does not provide a built-in flow layout, so building one with the Layout protocol is an essential skill for any non-trivial UI.

Step 1: The FlowLayout

The custom Layout protocol requires two methods: sizeThatFits to calculate the total size, and placeSubviews to position each child:

private struct FlowLayout: Layout {
    var spacing: CGFloat = 8

    func sizeThatFits(proposal: ProposedViewSize,
                      subviews: Subviews, cache: inout ()) -> CGSize {
        let result = arrange(proposal: proposal, subviews: subviews)
        return result.size
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
                        subviews: Subviews, cache: inout ()) {
        let result = arrange(proposal: proposal, subviews: subviews)
        for (index, subview) in subviews.enumerated() {
            if index < result.positions.count {
                subview.place(
                    at: CGPoint(
                        x: bounds.minX + result.positions[index].x,
                        y: bounds.minY + result.positions[index].y),
                    proposal: ProposedViewSize(result.sizes[index]))
            }
        }
    }

    private func arrange(proposal: ProposedViewSize,
                          subviews: Subviews)
        -> (positions: [CGPoint], sizes: [CGSize], size: CGSize) {
        let maxWidth = proposal.width ?? .infinity
        var positions: [CGPoint] = []
        var sizes: [CGSize] = []
        var x: CGFloat = 0
        var y: CGFloat = 0
        var rowHeight: CGFloat = 0
        var maxX: CGFloat = 0

        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x + size.width > maxWidth && x > 0 {
                x = 0
                y += rowHeight + spacing
                rowHeight = 0
            }
            positions.append(CGPoint(x: x, y: y))
            sizes.append(size)
            rowHeight = max(rowHeight, size.height)
            x += size.width + spacing
            maxX = max(maxX, x)
        }
        return (positions, sizes,
                CGSize(width: maxX, height: y + rowHeight))
    }
}

Step 2: The TagChip Component

private struct TagChip: View {
    let tag: String
    let isSelected: Bool
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Text(tag)
                .font(.subheadline)
                .fontWeight(isSelected ? .semibold : .regular)
                .padding(.horizontal, 14)
                .padding(.vertical, 8)
                .background(isSelected ? .blue : .gray.opacity(0.15),
                            in: Capsule())
                .foregroundStyle(isSelected ? .white : .primary)
        }
    }
}

Step 3: Putting It Together

struct TagViewDemo: View {
    @State private var tags: [String] = [
        "SwiftUI", "iOS", "Xcode", "Design", "Animation",
        "UIKit", "Combine", "Swift", "MVVM", "API",
        "CoreData", "Widget", "Charts", "Maps"
    ]
    @State private var selectedTags: Set<String> = []

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 20) {
                Text("Select Tags")
                    .font(.title2.bold())
                    .padding(.horizontal)

                FlowLayout(spacing: 8) {
                    ForEach(tags, id: \.self) { tag in
                        TagChip(tag: tag,
                                isSelected: selectedTags.contains(tag)) {
                            withAnimation(.spring(response: 0.3,
                                                   dampingFraction: 0.7)) {
                                if selectedTags.contains(tag) {
                                    selectedTags.remove(tag)
                                } else {
                                    selectedTags.insert(tag)
                                }
                            }
                        }
                    }
                }
                .padding(.horizontal)

                if !selectedTags.isEmpty {
                    VStack(alignment: .leading, spacing: 8) {
                        Text("Selected (\(selectedTags.count))")
                            .font(.headline)
                        Text(selectedTags.sorted().joined(separator: ", "))
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                    .padding()
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .background(.ultraThinMaterial,
                                in: RoundedRectangle(cornerRadius: 12))
                    .padding(.horizontal)
                }
            }
            .padding(.vertical)
        }
    }
}

Tips and Pitfalls

  • The Layout protocol requires iOS 16+. For iOS 15 support, you can use a GeometryReader-based flow layout instead, though the code is significantly more complex.
  • Set vs Array for selection: Using Set<String> for selectedTags gives O(1) contains/insert/remove operations, which matters when you have many tags.
  • The arrange() function is called twice: Once in sizeThatFits and once in placeSubviews. For performance with many items, use the cache parameter to store the result.
  • Adding new tags: Extend this with a text field and "Add" button. Insert new tags into the array and the FlowLayout will automatically reflow.
  • Accessibility: Since TagChip uses a Button, VoiceOver automatically announces each tag as tappable. Add .accessibilityAddTraits(.isSelected) when a tag is selected.

iOS Version: iOS 16+ (uses Layout protocol)

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.