Interrompa um loop geométrico no SwiftUI removendo o caminho que leva de um tamanho medido de volta ao quadro que o produziu. Para uma janela do Mac com rótulos que compartilham a mesma largura, calcule os tamanhos dentro de um contêiner Layout em vez de armazenar medições no estado.

Este passo a passo cria um pequeno painel de rótulos dentro da janela do seu próprio app. Os rótulos usam texto local sintético, compartilham a mesma largura e quebram linha quando a janela fica mais estreita. O exemplo completo tem como alvo o macOS 13 ou posterior; as seções seguintes explicam como aplicar a mesma disciplina de dimensionamento quando a interface inclui visualizações de texto do AppKit.

Como uma medição geométrica se transforma em um loop de layout?

O layout do SwiftUI envolve propostas e respostas: um elemento pai propõe um tamanho, e seus filhos informam os tamanhos que escolhem. Um contêiner personalizado participa dessa negociação por meio de ProposedViewSize. Portanto, é normal medir uma visualização repetidamente; medições repetidas, por si só, não indicam que há um problema. Consulte a documentação de ProposedViewSize da Apple.

A dependência problemática surge quando uma medição altera uma entrada do layout que está sendo medido. Por exemplo, um rótulo informa sua largura com preenchimento, uma ação armazena essa largura e um quadro ao redor usa o valor armazenado mais preenchimento adicional. O quadro resultante altera a próxima medição. A Apple descreve explicitamente esse loop de realimentação entre geometry reader e frame e recomenda usar o sistema de layout para esse tipo de organização. Consulte Compose custom layouts with SwiftUI.

Um cálculo simples mostra por que uma verificação de igualdade pode não ajudar. Suponha que uma implementação ilustrativa meça a largura do quadro anterior mais 16 pontos de preenchimento e, em seguida, atribua o resultado como a próxima largura do quadro. Começar com 120 pontos produz 136, 152 e 168 pontos. Cada resultado difere do anterior, então uma condição que impeça atribuir um valor inalterado nunca interrompe o crescimento. Esses números ilustram a dependência; não são medições de um app em execução.

Antes de escolher uma solução, trace a dependência no seu próprio código:

  1. Localize o geometry reader, o manipulador de preferências ou a ação de alteração geométrica.
  2. Identifique cada valor de estado que recebe as medições.
  3. Encontre os frames, os preenchimentos, as escolhas de fonte ou as visualizações condicionais que dependem desses valores.
  4. Verifique se essas escolhas afetam a visualização medida ou seus elementos ancestrais.
  5. Substitua temporariamente o valor medido por uma constante. Se as alterações repetidas pararem, examine primeiro esse caminho de dependência.

Mudar a forma como uma medição é transmitida não necessariamente elimina o ciclo. Uma preferência pode levar o mesmo tamanho de volta ao mesmo ancestral. Adiar a atribuição do estado pode transferir a realimentação para uma atualização posterior. A pergunta útil é se a próxima medição ainda depende do resultado anterior.

Qual mecanismo de dimensionamento você deve usar?

Escolha o mecanismo conforme a decisão que precisa tomar. Comece com os contêineres de layout integrados; a Apple descreve implementações personalizadas de Layout como o caminho para comportamentos que exigem mais controle. Consulte a documentação de Layout.

Resultado necessário Abordagem compatível Condição principal
Organizar rótulos comuns verticalmente Uma pilha integrada Permita que os filhos participem do dimensionamento normal.
Escolher uma apresentação ampla ou compacta ViewThatFits Forneça alternativas na ordem de preferência.
Dar a vários rótulos uma largura comum baseada no conteúdo Um Layout personalizado Meça e posicione os filhos dentro dos métodos de layout.
Reagir à geometria de uma visualização existente onGeometryChange Observe apenas o valor necessário para a ação.
Dimensionar uma visualização AppKit incorporada NSViewRepresentable.sizeThatFits Retorne um tamanho preferencial para a proposta.

ViewThatFits seleciona o primeiro filho cujo tamanho ideal cabe nos eixos com restrições. É útil quando o requisito real é escolher entre apresentações, como uma disposição horizontal e uma alternativa vertical. Isso pode eliminar uma variável de estado separada para a largura medida. Consulte a documentação de ViewThatFits da Apple.

Para o painel de rótulos com largura compartilhada abaixo, a regra de dimensionamento é explícita: use a maior largura ideal entre os rótulos, limitada por uma largura finita proposta. Em seguida, meça a altura de cada rótulo nessa largura escolhida. Assim, uma janela mais estreita pode aumentar a altura do painel por causa da quebra de linha, sem enviar medições geométricas pelo estado do app.

Crie um painel de rótulos com largura compartilhada e sem estado geométrico

Crie um projeto de app para macOS no Xcode usando SwiftUI, defina o destino de implantação como macOS 13 ou posterior e substitua as declarações geradas do app e da visualização de conteúdo por este exemplo completo em um único arquivo. Mantenha apenas uma declaração de app @main no destino. O programa foi conferido com base nas APIs documentadas; não foi compilado nem executado aqui porque o ambiente disponível é Linux.

import SwiftUI

private struct EqualWidthColumn: Layout {
    private let gap: CGFloat = 8

    private func columnWidth(
        proposal: ProposedViewSize,
        subviews: Subviews
    ) -> CGFloat {
        let idealWidth = subviews.reduce(CGFloat.zero) { result, view in
            max(result, view.sizeThatFits(.unspecified).width)
        }

        guard let offeredWidth = proposal.width,
              offeredWidth.isFinite else {
            return idealWidth
        }

        return min(idealWidth, max(0, offeredWidth))
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        guard !subviews.isEmpty else { return .zero }

        let width = columnWidth(proposal: proposal, subviews: subviews)
        let childProposal = ProposedViewSize(width: width, height: nil)
        let contentHeight = subviews.reduce(CGFloat.zero) { result, view in
            result + view.sizeThatFits(childProposal).height
        }
        let totalGap = gap * CGFloat(subviews.count - 1)

        return CGSize(width: width, height: contentHeight + totalGap)
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let childProposal = ProposedViewSize(
            width: bounds.width,
            height: nil
        )
        var y = bounds.minY

        for view in subviews {
            let height = view.sizeThatFits(childProposal).height
            view.place(
                at: CGPoint(x: bounds.minX, y: y),
                anchor: .topLeading,
                proposal: childProposal
            )
            y += height + gap
        }
    }
}

private struct ContentView: View {
    private let labels = [
        "Amber tile",
        "Blue tile with a longer local label",
        "Green tile"
    ]

    var body: some View {
        ScrollView(.vertical) {
            EqualWidthColumn {
                ForEach(labels, id: \.self) { label in
                    Text(label)
                        .padding(10)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(
                            RoundedRectangle(cornerRadius: 8)
                                .fill(Color.blue.opacity(0.12))
                        )
                }
            }
            .padding(20)
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .frame(minWidth: 220, minHeight: 180)
    }
}

@main
struct GeometryLoopDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Os dois métodos de layout exigidos dividem o trabalho: sizeThatFits informa o tamanho do contêiner, enquanto placeSubviews posiciona seus filhos. Os proxies dos filhos permitem medir e posicionar sem acessar diretamente as visualizações subjacentes. Ambos os métodos cumprem as responsabilidades documentadas. Consulte a documentação de Layout da Apple.

Os detalhes importantes da implementação são a proposta de largura e seu reaproveitamento. Cada filho é medido com uma largura definida e uma altura não especificada, permitindo que o texto determine a altura necessária. O posicionamento usa a largura real dos limites do contêiner e a mesma política de altura. Começar em bounds.minX e bounds.minY também evita presumir que o posicionamento começa na origem das coordenadas.

O contêiner trata uma coleção vazia retornando zero. Com uma largura não especificada, escolhe sua largura ideal; com uma largura finita, limita essa largura ideal. Uma proposta de largura infinita também resulta na largura ideal finita desses rótulos. Essas são regras deliberadas para este painel dimensionado pelo conteúdo. A Apple documenta que elementos pais podem sondar layouts com propostas zero, infinitas, não especificadas e outras durante uma única passagem de layout. Consulte sizeThatFits(proposal:subviews:cache:).

O exemplo não tem estado de tamanho medido, callback geométrico nem atribuição de uma largura renderizada de volta a um frame. Sua visualização de rolagem vertical oferece espaço para o conteúdo que fica mais alto ao quebrar linhas. O layout pressupõe filhos como os rótulos fornecidos, que conseguem responder adequadamente a uma largura finita. Um filho com largura fixa que ultrapasse o espaço disponível requer uma decisão de apresentação separada, como rolagem horizontal ou uma visualização alternativa.

Quando ainda é adequado observar a geometria?

A observação geométrica continua sendo útil quando a medição controla uma resposta limitada. A API onGeometryChange da Apple separa uma transformação de uma ação: a transformação extrai um valor comparável, e a ação é executada quando esse valor transformado muda. A Apple recomenda evitar atualizações amplas no app porque a geometria pode mudar com frequência. Consulte onGeometryChange(for:of:action:).

Por exemplo, uma sobreposição poderia indicar se uma região contenedora estável ultrapassa uma largura escolhida. Observe essa região contenedora e retorne um Booleano na transformação. Evite que a sobreposição contribua para o tamanho da região. Esse projeto segue o modelo documentado de transformação e ação, removendo a dependência circular de dimensionamento.

Reduzir um retângulo a um Booleano limita as notificações, mas isso, por si só, não comprova estabilidade. Se mudar para a apresentação compacta altera a região medida o suficiente para voltar à apresentação anterior, o Booleano pode alternar indefinidamente. Meça uma região cuja largura seja determinada pelo layout da janela ao redor ou use ViewThatFits para decidir a apresentação dentro do layout.

A disponibilidade também depende da sobrecarga. A ação que recebe apenas o novo valor é compatível com macOS 13 ou posterior quando compilada com um SDK que a inclui; a sobrecarga que recebe os valores antigo e novo requer macOS 15 ou posterior. O exemplo completo evita essa distinção usando apenas Layout. Consulte a documentação de alteração geométrica da Apple e a sobrecarga com os valores antigo e novo antes de copiar uma assinatura de callback para um destino de implantação mais antigo.

Como as medições do AppKit e do TextKit devem entrar no layout do SwiftUI?

Quando o conteúdo é uma visualização AppKit, use o limite de dimensionamento do representable. NSViewRepresentable.sizeThatFits(_:nsView:context:) está disponível no macOS 13 ou posterior, retorna um tamanho preferencial para uma proposta e pode ser chamado várias vezes com propostas diferentes em uma passagem. Retornar nil solicita o algoritmo de dimensionamento padrão. Trate-o como uma consulta de dimensionamento, não como um lugar para publicar dimensões medidas no estado do SwiftUI. Consulte o método de dimensionamento do representable.

O SwiftUI controla frame e bounds da visualização AppKit representada. A Apple alerta explicitamente para não definir essas propriedades de layout por conta própria, pois isso entra em conflito com o SwiftUI e gera comportamento indefinido. Configure o conteúdo no método de atualização do representable e informe o dimensionamento pelo mecanismo compatível. Consulte NSViewRepresentable.

No caso do texto, primeiro determine qual mecanismo de layout a visualização usa. O TextKit 2 fornece NSTextLayoutManager.usageBoundsForTextContainer, disponível no macOS 12 ou posterior, como uma medição de uso do contêiner de texto. A Apple documenta que ele pode ser observado por KVO. Essa observação pode dar suporte a uma solicitação de redimensionamento, mas não elimina a necessidade de manter consistentes as dependências entre largura e altura. Consulte usageBoundsForTextContainer.

Em uma implementação existente de TextKit 1, NSLayoutManager.usedRect(for:) informa a área ocupada pelos glifos já dispostos; ele não gera glifos nem executa o layout. Quando a medição exige um layout concluído, chame ensureLayout(for:) para o contêiner relevante antes de ler o retângulo utilizado. Ambas as APIs estão disponíveis desde o macOS 10.7. Consulte usedRect(for:) e ensureLayout(for:).

Não acesse layoutManager legado de uma visualização de texto TextKit 2 apenas para obter uma medição. A Apple explica que o acesso ao gerenciador de layout legado pode mudar a visualização de texto para o modo de compatibilidade com TextKit 1. Verifique textLayoutManager primeiro e mantenha o código de medição legado no ramo alternativo apropriado. Consulte O que há de novo no TextKit e nas visualizações de texto.

Para uma visualização de texto com quebra de linha, use a largura proposta como entrada do layout de texto e derive a altura preferencial dessa configuração. Considere explicitamente os recuos da visualização de texto e o preenchimento do contêiner; um retângulo em coordenadas do contêiner não corresponde automaticamente ao tamanho externo da visualização. Mantenha o trabalho de UI do AppKit dentro do isolamento de ator. O capítulo sobre concorrência do Swift explica a execução no ator principal; mover o trabalho para uma tarefa não altera a dependência circular na fórmula de dimensionamento.

Como verificar se o layout agora se estabiliza?

Execute o exemplo no Xcode e redimensione a janela lentamente em ambas as direções. O comportamento esperado é uma largura compartilhada entre os rótulos, quebra de linha em larguras menores e rolagem vertical quando o conteúdo ultrapassa a altura visível. Em seguida, repita com a estrutura real da sua visualização depois de remover a dependência do estado medido.

Use estas verificações para distinguir o trabalho esperado do layout da realimentação contínua:

  • Depois de redimensionar, deixe a janela parada. Verifique se as medições e atribuições de estado continuam mudando sem alterações no conteúdo.
  • Teste um rótulo sintético mais longo e uma fonte maior. Confirme que o painel pode crescer verticalmente sem cortar o texto.
  • Teste uma coleção vazia de rótulos. O layout deve retornar zero antes de aplicar o preenchimento ao redor.
  • Inspecione as propostas de medição e de posicionamento. Um filho com quebra de linha deve receber suposições de largura consistentes.
  • Se adicionar cache mais tarde, inclua as entradas de dimensionamento nas regras de validade; não reutilize alturas calculadas para outra largura.

Várias chamadas de dimensionamento durante uma passagem são um comportamento documentado. O objetivo prático é obter um resultado estável quando o conteúdo e as restrições não mudam. Mantenha as medições dentro da negociação do layout quando elas determinarem a organização e reserve a observação geométrica para respostas que não recriem as dimensões observadas.

Fontes