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:
sizeThatFits- Calcula quanto espaço o layout precisaplaceSubviews- Posiciona cada view filha dentro dos limites
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:
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
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:
- Começa na posição (0, 0)
- Para cada view, verifica se cabe na linha atual
- Se não couber e não estivermos no início de uma linha, quebra para a próxima linha
- Registra a posição da view e atualiza a posição atual
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:
lineHeight = max(lineHeight, size.height)Quando quebramos, usamos isso para posicionar a próxima linha corretamente.
Uso Básico
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:
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:
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.
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
| Abordagem | Prós | Contras |
|---|---|---|
Protocolo Layout | Nativo, performático, API limpa | Apenas iOS 16+ |
GeometryReader | Funciona em iOS mais antigos | Causa passagens de layout, mais difícil de implementar |
LazyVGrid | Nativo, carregamento lazy | Colunas são fixas, não é flow verdadeiro |
| Bibliotecas de terceiros | Cheias de recursos | Dependência externa |
Conclusão
O protocolo Layout torna a construção de layouts customizados em SwiftUI algo direto. Nosso FlowLayout:
- Quebra conteúdo naturalmente - Como CSS
flex-wrap: wrap - Lida com alturas variáveis - Cada linha se adapta ao item mais alto
- Suporta espaçamento customizado - Tanto horizontal quanto vertical
- 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.

