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