Use um Layout personalizado do SwiftUI para dar à primeira célula dois espaços de coluna e, em seguida, preencher as células restantes em linhas sucessivas. Este exemplo para macOS recalcula suas colunas quando a largura da janela muda e mede a altura de cada linha com base em seu conteúdo.
A tarefa é criar uma pequena paleta para desktop: um bloco introdutório seguido por onze botões numerados. O primeiro bloco fica mais largo sempre que cabem pelo menos duas colunas. Em larguras reduzidas, ele passa a ocupar uma única coluna, para que o conteúdo possa continuar na vertical. Todos os rótulos e seleções do exemplo são sintéticos.
Qual contêiner do SwiftUI dá suporte a essa disposição?
O Grid da Apple permite que uma célula ocupe várias colunas usando gridCellColumns(_:) dentro de um GridRow. No entanto, você precisa fornecer as linhas. Uma view colocada diretamente dentro de Grid, fora de um GridRow, ocupa toda a grade. Nenhuma das duas opções organiza automaticamente uma coleção plana em linhas que dependem da largura. Consulte a documentação de Grid e a documentação de gridCellColumns(_:) da Apple.
LazyVGrid preenche as linhas automaticamente: distribui as células pelas colunas e continua nas linhas seguintes. A configuração de colunas documentada não oferece o mecanismo de GridRow para fazer uma célula individual ocupar várias colunas. Aumentar visualmente a largura da primeira célula não estabelece uma alocação documentada de duas colunas. Consulte a documentação de LazyVGrid da Apple.
Escolha o contêiner de acordo com o comportamento necessário:
| Requisito | Abordagem adequada |
|---|---|
| Linhas explícitas com uma célula que ocupa duas colunas | Grid, GridRow e gridCellColumns(2) |
| Linhas preenchidas automaticamente com células comuns | LazyVGrid |
| Linhas preenchidas automaticamente e primeira célula ocupando dois espaços | Um Layout personalizado que implemente essa regra de distribuição |
Para este último caso, o ponto de extensão compatível da Apple é o protocolo Layout. Seus dois métodos essenciais informam o tamanho do contêiner e posicionam suas células. A implementação abaixo é um algoritmo de distribuição original baseado nesses métodos documentados. Layout — Apple
Defina as regras de dimensionamento antes de escrever o layout
Este exemplo usa uma largura mínima de coluna comum de 96 pontos e um espaçamento de 8 pontos. As colunas se expandem igualmente para ocupar a largura disponível. A primeira célula ocupa dois espaços, incluindo o espaçamento entre eles; cada outra ocupa um. As células preenchem as linhas na ordem em que aparecem, e cada linha tem a altura de sua célula mais alta.
Para a largura disponível W, a largura mínima da coluna m e o espaçamento g, a quantidade de colunas é max(1, floor((W + g) / (m + g))). A largura efetiva de cada coluna comum é (W - (columns - 1) × g) / columns. Assim, um bloco de dois espaços recebe 2 × columnWidth + g pontos.
Por exemplo, uma área de conteúdo com 408 pontos comporta quatro colunas de 96 pontos separadas por três espaçamentos de 8 pontos. O bloco introdutório tem 200 pontos de largura. Dois botões comuns cabem ao lado dele, e os botões seguintes preenchem quatro espaços por linha. Estes são cálculos ilustrativos baseados nas constantes escolhidas, não medições de um app em execução.
O SwiftUI pode propor um tamanho não especificado, zero ou infinito ao avaliar a flexibilidade de um layout. Essas propostas exigem respostas deliberadas. Esta implementação usa até quatro colunas para sua largura ideal, uma coluna para a largura mínima e colunas suficientes para que todas as células compartilhem uma linha quando for solicitada a largura máxima. Ela sempre retorna uma largura finita. A Apple explica o significado dessas propostas em ProposedViewSize.
Implemente o layout em um app para macOS
Crie um app SwiftUI para macOS no Xcode e defina o destino de implantação como macOS 13 ou posterior, versão em que Layout está disponível. Adicione um arquivo Swift chamado WideFirstGrid.swift com o tipo de layout completo a seguir. Layout — Apple
import SwiftUI
struct WideFirstGrid: Layout {
let minimumColumnWidth: CGFloat = 96
let gap: CGFloat = 8
struct Plan {
var size: CGSize
var frames: [CGRect]
}
func makePlan(width proposedWidth: CGFloat?, subviews: Subviews) -> Plan {
guard !subviews.isEmpty else {
return Plan(size: .zero, frames: [])
}
let totalSlots = subviews.count + (subviews.count > 1 ? 1 : 0)
let idealColumns = min(4, totalSlots)
let idealWidth = CGFloat(idealColumns) * minimumColumnWidth
+ CGFloat(idealColumns - 1) * gap
let maximumWidth = CGFloat(totalSlots) * minimumColumnWidth
+ CGFloat(totalSlots - 1) * gap
let width: CGFloat
if let proposedWidth {
if proposedWidth.isFinite {
width = max(minimumColumnWidth, proposedWidth)
} else {
width = maximumWidth
}
} else {
width = idealWidth
}
let columns = max(1, Int(floor(
(width + gap) / (minimumColumnWidth + gap)
)))
let columnWidth = (width - CGFloat(columns - 1) * gap)
/ CGFloat(columns)
var frames: [CGRect] = []
var column = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
for (index, subview) in subviews.enumerated() {
let span = index == 0 ? min(2, columns) : 1
if column + span > columns {
y += rowHeight + gap
column = 0
rowHeight = 0
}
let cellWidth = CGFloat(span) * columnWidth
+ CGFloat(span - 1) * gap
let measured = subview.sizeThatFits(
ProposedViewSize(width: cellWidth, height: nil)
)
frames.append(CGRect(
x: CGFloat(column) * (columnWidth + gap),
y: y,
width: cellWidth,
height: measured.height
))
rowHeight = max(rowHeight, measured.height)
column += span
}
return Plan(
size: CGSize(width: width, height: y + rowHeight),
frames: frames
)
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
makePlan(width: proposal.width, subviews: subviews).size
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let plan = makePlan(width: bounds.width, subviews: subviews)
for (subview, frame) in zip(subviews, plan.frames) {
subview.place(
at: CGPoint(
x: bounds.minX + frame.minX,
y: bounds.minY + frame.minY
),
anchor: .topLeading,
proposal: ProposedViewSize(width: frame.width, height: nil)
)
}
}
}
A mesma função de planejamento orienta a medição e o posicionamento. Cada célula é medida com a largura alocada e uma altura não especificada, e depois posicionada usando essa mesma proposta de tamanho. Isso mantém consistentes a quebra de texto e os cálculos de altura das linhas. A Apple exige especificamente cálculos compatíveis entre o dimensionamento e o posicionamento. placeSubviews(in:proposal:subviews:cache:) — Apple
O posicionamento soma bounds.minX e bounds.minY a cada posição calculada. Os limites alocados pelo elemento pai não precisam começar em zero. Cada célula recebe uma chamada de posicionamento, e essas chamadas ocorrem somente dentro de placeSubviews, conforme exigido pelo contrato de posicionamento de LayoutSubview.
Adicione os blocos sintéticos e as ações dos botões
Substitua o arquivo de entrada gerado para o app pelo código a seguir, mantendo exatamente uma declaração @main no projeto. Junto com WideFirstGrid.swift, este é o exemplo completo. O texto introdutório é a primeira célula do layout; cada iteração fornece um botão.
import SwiftUI
@main
struct WideFirstGridDemoApp: App {
var body: some Scene {
WindowGroup {
PaletteView()
.frame(minWidth: 240, minHeight: 260)
}
}
}
struct PaletteView: View {
@State private var selectedNumber: Int? = nil
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Tile palette")
.font(.title2)
Text(selectedNumber.map { "Selected tile: \($0)" }
?? "Choose a numbered tile")
ScrollView {
WideFirstGrid {
Text("Choose a tile from this sample collection")
.font(.headline)
.padding(12)
.frame(
maxWidth: .infinity,
minHeight: 64,
alignment: .leading
)
.background(
Color.blue.opacity(0.12),
in: RoundedRectangle(cornerRadius: 10)
)
ForEach(1...11, id: \.self) { number in
Button {
selectedNumber = number
} label: {
Text("Tile \(number)")
.padding(12)
.frame(maxWidth: .infinity, minHeight: 64)
.background(
Color.gray.opacity(0.12),
in: RoundedRectangle(cornerRadius: 10)
)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(16)
}
}
A coleção numérica fixa dá a cada botão um identificador exclusivo e estável por meio de id: \.self. O layout calcula a geometria através dos proxies de subview; a seleção permanece no estado da view e só muda na ação do botão. A Apple documenta que ForEach exige elementos identificáveis ou um identificador explícito. ForEach — Apple
O ScrollView comporta linhas adicionais. O layout calcula deliberadamente a altura natural do conteúdo, em vez de comprimir as linhas para caber na altura proposta. Um rótulo de texto mais alto aumenta a altura da linha correspondente, e as linhas seguintes descem de acordo. As células mais baixas permanecem alinhadas ao topo dessa linha.
Verifique o redimensionamento, as larguras reduzidas e o ciclo de vida do layout
Compile e execute o destino para macOS e redimensione a janela horizontalmente. Os resultados esperados a seguir usam a largura disponível para o conteúdo da grade, depois do preenchimento ao redor. Eles consideram o bloco introdutório mais onze botões, totalizando doze células.
| Largura do conteúdo da grade | Colunas | Largura da primeira célula | Total de linhas |
|---|---|---|---|
| 96 pontos | 1 | 96 pontos | 12 |
| 200 pontos | 2 | 200 pontos | 7 |
| 304 pontos | 3 | 200 pontos | 5 |
| 408 pontos | 4 | 200 pontos | 4 |
A largura mínima da janela significa que o caso de 96 pontos exige um contêiner de teste mais estreito ou uma redução temporária da largura mínima da janela. Teste também um layout vazio, um layout com apenas o bloco introdutório e um rótulo introdutório mais longo. O conteúdo vazio retorna tamanho zero; um único primeiro bloco pode ocupar os dois espaços disponíveis sem exigir outra célula.
A aritmética de distribuição foi verificada com um cálculo Python determinístico nas quatro larguras acima, incluindo verificações de sobreposição de espaços e de ultrapassagem do limite de colunas. O aplicativo SwiftUI foi inspecionado, mas não compilado nem executado no ambiente de pesquisa, que não tinha um compilador Swift. Confirme o comportamento visual no Xcode antes de adaptar o exemplo.
O SwiftUI pode chamar sizeThatFits repetidamente com diferentes propostas antes de escolher um tamanho. Evite alterar a seleção, posicionar células ou executar outras ações do app durante a medição. Este exemplo usa o cache vazio padrão e recalcula seu plano, impedindo que uma largura anterior deixe posições de linhas desatualizadas. A Apple descreve esse comportamento de medição repetida em sizeThatFits(proposal:subviews:cache:).
Entenda os limites antes de estender o layout
O mínimo de 96 pontos é uma política de layout deliberada. Se um elemento pai oferecer menos largura, o layout ainda informa 96 pontos e pode ultrapassar o espaço alocado. A largura mínima da janela definida no exemplo evita essa situação. Para oferecer suporte a contêineres mais estreitos, reduza a largura mínima da coluna e confira os rótulos nessa largura.
Uma proposta de tamanho é uma solicitação: uma célula pode escolher um tamanho diferente. Os blocos fornecidos foram projetados para aceitar a largura alocada. Substituí-los por conteúdo que exija uma largura fixa maior pode causar sobreposição; revise a política de dimensionamento antes de introduzir essas células. place(at:anchor:proposal:) — Apple
Esta implementação também usa posicionamento físico da esquerda para a direita e mede todas as células. Ela não implementa ordenação dependente da direção nem criação sob demanda. Para coleções consideravelmente maiores, avalie o desempenho antes de escolher outro contêiner. A Apple recomenda trocar uma grade comum por uma grade sob demanda quando a avaliação de desempenho demonstrar uma melhoria significativa; essa mudança ainda exige reconsiderar o requisito de expansão de colunas. Grid — Apple
Para uma pequena paleta de Mac, a função de planejamento compartilhada facilita a adaptação do comportamento: altere a largura mínima da coluna ou o espaçamento, mantenha consistentes a medição e o posicionamento e preserve a alternativa de uma coluna. Assim, o primeiro bloco permanece largo quando há espaço, enquanto os botões restantes fluem para o próximo espaço disponível.
Fontes
- Grid — Apple
- gridCellColumns(_:) — Apple
- LazyVGrid — Apple
- Layout — Apple
- ProposedViewSize — Apple
- sizeThatFits(proposal:subviews:cache:) — Apple
- placeSubviews(in:proposal:subviews:cache:) — Apple
- place(at:anchor:proposal:) — Apple
- ForEach — Apple