← Todos os Posts

Protocolo Layout do SwiftUI: Construindo layouts personalizados de sizeThatFits a placeSubviews

O iOS 16 adicionou o protocolo Layout ao SwiftUI, a API pública para construir views container personalizadas que participam do passo de layout do SwiftUI1. Antes do Layout, formatos de container personalizados exigiam ou hacks com GeometryReader (que quebram a composição porque solicitam o tamanho proposto inteiro) ou trabalho com ViewModifier personalizado que luta contra o sistema. O Layout é a resposta certa: um protocolo de dois métodos (sizeThatFits e placeSubviews) mais extensões opcionais de espaçamento e cache, com um contrato que se integra de forma limpa ao modelo de layout do SwiftUI em que o pai propõe e o filho dispõe.

O post percorre o protocolo confrontando-o com a documentação da Apple. O enquadramento é “no que o Layout realmente contrata” porque o padrão de uso indevido (tratar o Layout como uma ferramenta de espaço de coordenadas em vez de uma ferramenta de negociação de tamanho) produz layouts que funcionam em uma tela e falham em outra, e o post What SwiftUI Is Made Of do cluster argumentou que a arquitetura do SwiftUI é melhor compreendida lendo seus protocolos públicos.

TL;DR

  • Layout é um protocolo com dois métodos obrigatórios: sizeThatFits(proposal:subviews:cache:) retorna o tamanho preferido do layout dado a proposta do pai; placeSubviews(in:proposal:subviews:cache:) posiciona cada filho chamando seu método place(at:anchor:proposal:)2.
  • O parâmetro proposal é um ProposedViewSize com width e height como CGFloats opcionais. nil significa “use seu tamanho ideal”; um valor finito é a oferta do pai; .infinity significa “use o quanto quiser.”
  • Subviews é um typealias para LayoutSubviews, uma coleção de proxies LayoutSubview. Cada proxy pode ser consultado quanto ao seu tamanho dada qualquer proposta e posicionado em qualquer ponto. Os proxies são a única forma como o Layout interage com os filhos.
  • Valores de layout personalizados fluem dos filhos para o pai através de tipos LayoutValueKey anexados via .layoutValue(...) em views filhas, legíveis a partir dos subscripts de LayoutSubview dentro dos métodos de layout.
  • O cache serve para amortizar computação entre sizeThatFits e placeSubviews (cada passo chama ambos, frequentemente com os mesmos valores intermediários). Tipe o cache como uma struct que armazena os tamanhos pré-computados; construa-o uma vez, reutilize entre os dois métodos.

O contrato do protocolo

Um Layout é uma struct (tipicamente) que declara dois métodos que o framework da Apple chama durante o passo de layout2:

struct DiagonalLayout: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        // Compute and return the size your layout wants
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        // Position each subview by calling subview.place(...)
    }
}

Use-o como um container nativo:

DiagonalLayout {
    Text("First")
    Text("Second")
    Text("Third")
}

O framework chama sizeThatFits com o tamanho proposto pelo pai (um ProposedViewSize), depois chama placeSubviews com os bounds que foram concedidos ao layout. Os dois métodos juntos descrevem o comportamento do layout: quão grande ele quer ser, e onde cada filho fica dentro dessa alocação.

ProposedViewSize: a oferta do pai

O layout no SwiftUI segue um contrato em que o pai propõe e o filho dispõe3. O pai passa um tamanho proposto; o filho retorna seu tamanho real; o pai posiciona o filho dentro de seus próprios bounds. O Layout participa desse contrato via ProposedViewSize:

struct ProposedViewSize {
    var width: CGFloat?
    var height: CGFloat?
}

Os eixos opcionais carregam significado semântico:

  • nil para um eixo significa “use seu tamanho ideal/natural.” Um Text com proposta .zero retorna sua largura mínima (um caractere por linha); com proposta nil retorna sua largura ideal (uma linha, sem quebra).
  • Um valor finito significa “o pai oferece este espaço; você decide o que fazer.” Um Text com proposta de largura 100pt pode quebrar, pode usar menos, pode usar exatamente 100.
  • .infinity significa “use o quanto quiser.” Um Color com proposta .infinity ocupa todo o espaço disponível.

A convenção ProposedViewSize.unspecified (width: nil, height: nil) é a solicitação do tamanho ideal; ProposedViewSize.zero é a solicitação do tamanho mínimo; ProposedViewSize.infinity é a solicitação de expansão gulosa.

O sizeThatFits de um Layout personalizado deve respeitar a proposta: retornar um tamanho que o layout realmente queira para os bounds propostos, não sempre o mesmo valor codificado. Tamanhos codificados quebram a capacidade do layout de se adaptar a containers diferentes (uma view de cartão, uma célula de lista, uma sheet).

Lendo tamanhos de subviews através de LayoutSubview

Dentro de sizeThatFits, o layout pergunta a cada filho qual tamanho ele quer para várias propostas. A consulta passa pelo proxy LayoutSubview4:

func sizeThatFits(
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout ()
) -> CGSize {
    let proposed = ProposedViewSize(
        width: proposal.width.map { $0 / CGFloat(subviews.count) },
        height: proposal.height
    )

    let sizes = subviews.map { $0.sizeThatFits(proposed) }
    let totalWidth = sizes.reduce(0) { $0 + $1.width }
    let maxHeight = sizes.map(\.height).max() ?? 0

    return CGSize(width: totalWidth, height: maxHeight)
}

O padrão subviews.map { $0.sizeThatFits(proposal) } é como um layout descobre quais tamanhos seus filhos querem. O método sizeThatFits(_:) do proxy LayoutSubview não é o mesmo que o método do protocolo Layout; é a consulta do proxy ao tamanho preferido do filho dada uma proposta. Os dois compartilham o nome porque participam da mesma negociação, mas são camadas diferentes do contrato.

Um layout que quer saber os tamanhos dos filhos chama proxy.sizeThatFits(_:). Um layout que quer posicionar filhos chama proxy.place(at:anchor:proposal:) dentro de placeSubviews.

Posicionando subviews

placeSubviews é onde o layout toma decisões de posicionamento2:

func placeSubviews(
    in bounds: CGRect,
    proposal: ProposedViewSize,
    subviews: Subviews,
    cache: inout ()
) {
    var x = bounds.minX
    let y = bounds.midY

    for subview in subviews {
        let size = subview.sizeThatFits(.unspecified)
        subview.place(
            at: CGPoint(x: x + size.width / 2, y: y),
            anchor: .center,
            proposal: ProposedViewSize(size)
        )
        x += size.width
    }
}

A chamada place(at:anchor:proposal:) posiciona uma única subview. Três parâmetros:

  • at: a posição no espaço de coordenadas do pai.
  • anchor: qual ponto da subview está em at. .center coloca o centro da subview em at; .topLeading coloca o canto superior esquerdo lá.
  • proposal: o tamanho com o qual a subview deve renderizar. Passe o tamanho retornado pelo sizeThatFits da própria subview para honrar sua preferência, ou passe uma proposta personalizada para restringi-la.

Toda subview deve ser posicionada exatamente uma vez por chamada de placeSubviews. Pular uma subview a deixa sem posição (ela desaparece do layout renderizado); posicionar uma duas vezes é um erro em runtime.

Valores de layout personalizados através de LayoutValueKey

Quando um filho precisa comunicar algo ao seu layout pai (uma prioridade, um span, uma categoria), o canal é LayoutValueKey5:

struct PriorityKey: LayoutValueKey {
    static let defaultValue: Int = 0
}

extension View {
    func layoutPriority(_ value: Int) -> some View {
        layoutValue(key: PriorityKey.self, value: value)
    }
}

// Inside the Layout:
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    let sortedSubviews = subviews.sorted {
        $0[PriorityKey.self] > $1[PriorityKey.self]
    }
    // ... place sortedSubviews
}

O protocolo LayoutValueKey fornece um canal tipado para comunicação pai-filho. O filho anexa um valor via o modificador layout-value; o pai o lê via o subscript de LayoutSubview. Cada chave tem um valor padrão para subviews que não especificam um explicitamente.

O padrão é conceitualmente o que modificadores nativos como .layoutPriority(_:) expressam. O framework expõe esse valor específico através de uma propriedade dedicada priority: Double em LayoutSubview em vez de através de uma LayoutValueKey pública, então o acesso pelo proxy à prioridade de layout é subview.priority em vez de um subscript de chave. Layouts personalizados declaram seus próprios tipos LayoutValueKey para qualquer outro dado estruturado que precisem dos filhos.

O parâmetro cache

Ambos os métodos de layout recebem um parâmetro cache: inout. O cache é o lugar do layout para amortizar trabalho entre sizeThatFits e placeSubviews6:

struct DiagonalLayout: Layout {
    struct Cache {
        var sizes: [CGSize]
    }

    func makeCache(subviews: Subviews) -> Cache {
        let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
        return Cache(sizes: sizes)
    }

    func updateCache(_ cache: inout Cache, subviews: Subviews) {
        cache.sizes = subviews.map { $0.sizeThatFits(.unspecified) }
    }

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
        let totalWidth = cache.sizes.reduce(0) { $0 + $1.width }
        let totalHeight = cache.sizes.reduce(0) { $0 + $1.height }
        return CGSize(width: totalWidth, height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
        var x = bounds.minX
        var y = bounds.minY
        for (subview, size) in zip(subviews, cache.sizes) {
            subview.place(
                at: CGPoint(x: x, y: y),
                anchor: .topLeading,
                proposal: ProposedViewSize(size)
            )
            x += size.width
            y += size.height
        }
    }
}

O tipo padrão de cache é Void. A maioria dos layouts pode ignorar o cache; ele justifica seu lugar quando a computação de tamanho é genuinamente cara (medições recursivas, decisões de dimensionamento dinâmico) e os mesmos intermediários alimentam ambos os métodos de layout.

makeCache(subviews:) roda uma vez por passo de layout; updateCache(_:subviews:) roda quando as subviews mudam entre passos. O padrão permite que o layout invalide o estado em cache corretamente quando os próprios filhos mudam.

Layouts personalizados comuns que vale a pena construir

Três padrões que vale a pena construir você mesmo:

Flow layout (itens com quebra). Itens quebram em múltiplas linhas quando ultrapassam a largura disponível. O HStack da Apple não quebra. Um Layout personalizado pode: medir cada filho, posicionar da esquerda para a direita, avançar para a próxima linha quando a largura da linha exceder a largura da proposta.

Stack diagonal. Itens escalonados diagonalmente (cada filho posicionado um pouco para baixo e para a direita do anterior). Útil para UIs de cartões empilhados, layouts de pré-visualização de galeria, stacks com sensação de paralaxe.

Layout em pizza/círculo. Itens dispostos ao redor da circunferência de um círculo. Útil para menus radiais, UIs baseadas em tempo, rótulos categóricos com espaçamento igual.

Cada um destes é implementável com sizeThatFits + placeSubviews + (opcionalmente) um cache personalizado. O framework lida com a negociação em que o pai propõe e o filho dispõe; o desenvolvedor lida com a matemática de posicionamento.

Falhas comuns de layout

Três padrões que produzem layouts personalizados quebrados:

Tamanhos codificados que ignoram a proposta. Um layout que sempre retorna CGSize(width: 200, height: 100) não se adapta ao seu container. O resultado: o layout parece bom no simulador, mas quebra em telas menores, em orientações diferentes ou dentro de containers redimensionáveis.

Pular subviews em placeSubviews. Toda subview deve ser posicionada exatamente uma vez por chamada. Um loop for que tem um continue para alguma condição deixa essas subviews sem posição; elas desaparecem da saída renderizada.

Usar GeometryReader dentro dos filhos de um Layout personalizado. O GeometryReader sempre propõe o espaço completo recebido para seu conteúdo, o que luta contra as propostas por filho do layout. A combinação produz tamanhos sem sentido. Layouts personalizados não devem colocar GeometryReader dentro de si mesmos; se um filho precisa saber seu tamanho alocado, o mecanismo de proposta do protocolo de layout é o canal certo.

Quando recorrer ao Layout (e quando não)

Três sinais de que um Layout personalizado é a ferramenta certa:

  1. O formato não é expressável com composição de HStack/VStack/ZStack/Grid. Layouts em pizza, grids tipo masonry, quebra de fluxo personalizada. As primitivas nativas não conseguem se compor nesses formatos.
  2. Informação por filho dirige o posicionamento. Layouts onde os filhos têm prioridades, pesos ou categorias que o pai usa para posicioná-los. LayoutValueKey é o canal certo.
  3. O dimensionamento do layout depende de negociar com os filhos. Layouts que perguntam “qual é a menor altura que cabe a linha mais longa?” ou “qual largura dá colunas iguais a N filhos?” precisam de acesso a consultas subviews.sizeThatFits(...).

Três sinais de que a composição nativa é suficiente:

  1. Empilhamento padrão horizontal/vertical/em profundidade. HStack, VStack, ZStack cobrem os casos comuns.
  2. Grid com linhas/colunas regulares. Grid e LazyVGrid/LazyHGrid lidam com a maioria dos casos de grid.
  3. Um pouco de posicionamento de overlay. .overlay, .background, ZStack com alinhamento cobrem a maioria dos padrões “X em cima de Y”.

A regra prática: não construa um Layout personalizado para um formato que os nativos lidam. Construa um quando o formato estiver genuinamente além do conjunto de expressão dos nativos.

O que esse padrão significa para apps iOS 26+

Três pontos para levar.

  1. Honre a proposta em sizeThatFits. Um layout que retorna o mesmo tamanho independentemente de proposal não participa do sistema de layout do SwiftUI corretamente. Leia a proposta, retorne um tamanho apropriado a ela.

  2. Use LayoutValueKey para comunicação pai-filho estruturada. Passar dados através de chaves anexadas por modificadores de view é o padrão nativo do SwiftUI. Não recorra a @Environment ou PreferenceKey personalizado para dados que são especificamente sobre decisões em nível de layout; LayoutValueKey é o canal tipado para isso.

  3. Construa um cache apenas quando a medição for cara. O cache padrão Void está bom para a maioria dos layouts. Recorra a um tipo de cache personalizado apenas quando a mesma computação cara aparecer tanto em sizeThatFits quanto em placeSubviews.

O cluster completo do Apple Ecosystem: App Intents tipados; servidores MCP; a questão de roteamento; Foundation Models; a distinção entre LLM de runtime vs tooling; três superfícies; o padrão de fonte única da verdade; Two MCP Servers; hooks para desenvolvimento Apple; Live Activities; o contrato de runtime do watchOS; internals do SwiftUI; o modelo mental espacial do RealityKit; disciplina de schema do SwiftData; padrões do Liquid Glass; shipping multi-plataforma; a matriz de plataformas; framework Vision; Symbol Effects; inferência Core ML; API de Writing Tools; Swift Testing; Privacy Manifest; Acessibilidade como plataforma; tipografia SF Pro; padrões espaciais do visionOS; framework Speech; migrações do SwiftData; focus engine do tvOS; internals do @Observable; sobre o que me recuso a escrever. O hub está na Apple Ecosystem Series. Para contexto mais amplo de iOS-com-agentes-de-IA, veja o guia de iOS Agent Development.

FAQ

Por que não usar simplesmente GeometryReader?

O GeometryReader sempre propõe o tamanho completo recebido ao seu conteúdo (ele não tem opinião sobre o que seu conteúdo quer). O resultado é que qualquer view dentro de um GeometryReader recebe infinity proposto para os eixos que o reader não restringe, e views como Text se dimensionam de forma gulosa. A composição luta contra si mesma: o reader passa adiante sem alteração, o conteúdo pede o tamanho máximo, o layout quebra. O Layout é a ferramenta certa porque permite que o desenvolvedor tome decisões explícitas por filho sobre o tamanho proposto.

Posso escrever um substituto personalizado para HStack?

Sim. Um Layout personalizado equivalente a HStack lê os tamanhos preferidos dos filhos, soma suas larguras, pega a altura máxima e os posiciona da esquerda para a direita. O HStack real faz mais (espaçamento, alinhamento, resolução de prioridade de layout), mas o formato básico é direto em Layout. O exercício é uma forma útil de internalizar como o protocolo funciona.

Como faço para suportar .layoutPriority(_:) no meu layout personalizado?

Leia-o através da propriedade dedicada priority: Double do proxy LayoutSubview: subview.priority. O SwiftUI expõe .layoutPriority(_:) diretamente no proxy em vez de através de uma LayoutValueKey pública. O valor padrão é 0. Use a prioridade ao distribuir espaço extra (dê preferencialmente a filhos de alta prioridade) ou ao truncar (trunque filhos de baixa prioridade primeiro).

Qual é a diferença entre proposal: .infinity e proposal: .zero?

.infinity propõe tamanho máximo em cada eixo (width: .infinity, height: .infinity). Filhos que respondem a propostas gulosas (como Color) ocupam todo o espaço disponível. .zero propõe tamanho mínimo (width: 0, height: 0). Filhos retornam seu tamanho mínimo (Text retorna o tamanho de seu maior token inquebrável). Os dois são endpoints úteis para medir o intervalo de dimensionamento dos filhos; muitos layouts usam .unspecified (ambos nil) para perguntar “qual é seu tamanho ideal?”.

O Layout funciona em watchOS, tvOS e visionOS?

Sim. O protocolo Layout está no núcleo cross-platform do SwiftUI. Layouts personalizados funcionam da mesma forma em iOS, iPadOS, macOS, watchOS, tvOS e visionOS. O post Apple Platform Matrix do cluster argumenta que a inclusão de plataformas é uma decisão de produto; o mecanismo Layout do SwiftUI é agnóstico em relação à plataforma para os casos em que múltiplas plataformas se aplicam.

Como o Layout interage com modelos @Observable?

Layout é uma struct que não armazena estado observável diretamente; ele não rastreia mudanças. Quando um modelo atualiza, o body da view pai é reavaliado, o que faz o Layout rodar novamente com quaisquer filhos que o body produzir. O Layout é reativo através do body em que vive, não através de hooks de observação próprios. O post @Observable internals do cluster cobre o lado de observação.

Referências


  1. Apple Developer Documentation: Layout. A referência do protocolo cobrindo os requisitos sizeThatFits e placeSubviews, além dos hooks opcionais makeCache, updateCache, spacing e de alinhamento explícito. 

  2. Apple Developer Documentation: sizeThatFits(proposal:subviews:cache:) e placeSubviews(in:proposal:subviews:cache:). Os dois métodos obrigatórios do protocolo Layout

  3. Apple Developer Documentation: ProposedViewSize. O tipo de dois CGFloats opcionais que carrega a proposta de tamanho do pai, com os valores convencionais .unspecified, .zero e .infinity

  4. Apple Developer Documentation: LayoutSubview. O tipo proxy representando uma view filha dentro dos métodos de Layout, com sizeThatFits(_:) para consultar tamanhos preferidos e place(at:anchor:proposal:) para posicionamento. 

  5. Apple Developer Documentation: LayoutValueKey e layoutValue(key:value:). O canal tipado para dados em nível de layout do filho para o pai, acessado via subscript em LayoutSubview

  6. Apple Developer: Composing custom layouts with SwiftUI. O guia da Apple cobrindo cache, alignment guides e quando recorrer ao Layout em vez de containers nativos. 

Artigos relacionados

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 min de leitura

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 min de leitura

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 min de leitura