Skip to content

Criando um Flow Layout em SwiftUI: Views que Quebram Linha como CSS Flexbox

Introdução

Uma das funcionalidades de layout mais solicitadas em SwiftUI é o flow layout—um container que organiza itens horizontalmente e os move para a próxima linha quando não cabem. Se você já usou CSS Flexbox com flex-wrap: wrap, sabe exatamente do que estamos falando.

SwiftUI não fornece um flow layout nativo, mas desde o iOS 16, o protocolo Layout nos permite construir um. Neste artigo, vamos criar um FlowLayout pronto para produção, perfeito para:

  • Nuvens de tags - Exibindo tags de largura variável
  • Coleções de chips - Chips de filtro, chips de sugestão
  • Badges de habilidades - Mostrando listas de skills ou categorias
  • Qualquer conteúdo que quebra linha - Onde os itens têm larguras diferentes

Pré-requisitos

  • iOS 16+ / macOS 13+ (necessário para o protocolo Layout)
  • Conhecimento básico de views em SwiftUI
  • Xcode 14 ou posterior

Entendendo o Protocolo Layout

O protocolo Layout requer dois métodos:

  1. sizeThatFits - Calcula quanto espaço o layout precisa
  2. placeSubviews - Posiciona cada view filha dentro dos limites
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
    )
}

O insight principal é que calculamos o layout uma vez e usamos a mesma lógica tanto para dimensionamento quanto para posicionamento.

A Implementação do FlowLayout

Aqui está uma implementação completa, pronta para produção:

swift
import SwiftUI

/// Um layout que organiza seus filhos em um fluxo horizontal,
/// quebrando para a próxima linha quando necessário.
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 {
            // Verifica se precisamos quebrar para a próxima linha
            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))
    }
}

Como Funciona

Vamos detalhar o algoritmo:

1. Medindo as Subviews

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

Pedimos a cada subview seu tamanho ideal usando .unspecified, que diz para a view "me dê seu tamanho natural sem restrições."

2. O Algoritmo de Layout

A lógica principal na função layout:

  1. Começa na posição (0, 0)
  2. Para cada view, verifica se cabe na linha atual
  3. Se não couber e não estivermos no início de uma linha, quebra para a próxima linha
  4. Registra a posição da view e atualiza a posição atual
swift
if currentX + size.width > containerWidth, currentX > 0 {
    // Quebra para a próxima linha
    currentX = 0
    currentY += lineHeight + verticalSpacing
    lineHeight = 0
}

Por que verificar currentX > 0?

Isso previne um loop infinito quando uma única view é mais larga que o container. Se o primeiro item de uma linha for muito largo, colocamos ele assim mesmo ao invés de quebrar infinitamente.

3. Rastreando a Altura da Linha

Cada linha pode ter itens de alturas diferentes. Rastreamos a altura máxima da linha atual:

swift
lineHeight = max(lineHeight, size.height)

Quando quebramos, usamos isso para posicionar a próxima linha corretamente.

Uso Básico

swift
struct TagCloudView: View {
    let tags = ["Swift", "SwiftUI", "Desenvolvimento iOS", "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()
    }
}

Exemplo Real: Chips de Sugestão

Aqui está um exemplo mais completo mostrando chips interativos:

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 = [
        "Rotina matinal ☀️",
        "Exercício 💪",
        "Ler um livro 📚",
        "Beber água 💧",
        "Meditação 🧘",
        "Ligar para um amigo 📞",
        "Aprender algo novo 🧠"
    ]

    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()
    }
}

Adicionando Suporte a Alinhamento

Você pode querer centralizar ou alinhar à direita seu flow layout. Aqui está uma versão estendida:

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

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

    // ... implementação de sizeThatFits e placeSubviews ...

    private func layout(
        sizes: [CGSize],
        containerWidth: CGFloat
    ) -> (offsets: [CGPoint], size: CGSize) {
        // Primeira passagem: calcular linhas
        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
        }

        // Segunda passagem: calcular offsets com alinhamento
        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))
    }
}

Considerações de Performance

Calculando Layout Duas Vezes

Note que calculamos tamanhos tanto em sizeThatFits quanto em placeSubviews. Para a maioria dos casos isso é suficiente, mas se você tiver centenas de itens, considere usar o parâmetro cache para armazenar valores calculados.

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
    }

    // ... resto da implementação usa cache.sizes
}

Comparação com Outras Abordagens

AbordagemPrósContras
Protocolo LayoutNativo, performático, API limpaApenas iOS 16+
GeometryReaderFunciona em iOS mais antigosCausa passagens de layout, mais difícil de implementar
LazyVGridNativo, carregamento lazyColunas são fixas, não é flow verdadeiro
Bibliotecas de terceirosCheias de recursosDependência externa

Conclusão

O protocolo Layout torna a construção de layouts customizados em SwiftUI algo direto. Nosso FlowLayout:

  1. Quebra conteúdo naturalmente - Como CSS flex-wrap: wrap
  2. Lida com alturas variáveis - Cada linha se adapta ao item mais alto
  3. Suporta espaçamento customizado - Tanto horizontal quanto vertical
  4. Está pronto para produção - Lida com casos extremos como itens muito grandes

A implementação completa tem menos de 60 linhas de código e zero dependências. Use para nuvens de tags, chips de filtro, badges de habilidades, ou qualquer UI que precise de comportamento de quebra flexível.