Crie um diagrama de colchetes ramificados em um app para Mac calculando os centros dos nós uma única vez, posicionando cartões com um Layout personalizado do SwiftUI e desenhando as conexões atrás deles com Canvas. Uma visualização com rolagem nos dois eixos mantém o diagrama legível quando a janela é menor que o conteúdo.

Este tutorial cria um diagrama sintético de sete nós, com rótulos neutros como Node 1. Os ramos se estendem para a esquerda a partir de uma raiz à direita. A tarefa concreta é exibir cartões conectados e espaçados uniformemente em uma janela redimensionável do macOS, com o posicionamento e o desenho controlados pela mesma geometria.

Escolha uma estrutura de layout que mantenha cartões e linhas alinhados

Use uma versão de destino do macOS 13 ou posterior para esta implementação. A Apple documenta que Layout está disponível a partir do macOS 13. Seus dois métodos obrigatórios informam o tamanho do contêiner e posicionam suas subvisualizações. Isso o torna adequado para organizar cartões em posições calculadas. Layout

Mantenha os cartões dos nós como visualizações comuns do SwiftUI. Use um Canvas de fundo apenas para os traços das conexões. A Apple explica que os elementos individuais desenhados em um canvas não têm interatividade nem acessibilidade próprias, inclusive as visualizações fornecidas como símbolos do canvas. Separar os cartões mantém um caminho direto para adicionar controles comuns mais tarde. Canvas

A implementação tem três partes: uma árvore que descreve os ramos, um cálculo geométrico que produz os centros dos nós e as arestas, e uma visualização que combina os cartões com o desenho das conexões. As duas camadas de renderização recebem o mesmo valor geométrico. Assim, alterar a árvore não pode atualizar os cartões e deixar para trás, por acidente, um conjunto de conexões mantido separadamente.

A política de dimensionamento é deliberadamente fixa: os cartões mantêm o mesmo tamanho quando a janela muda, e a área de visualização ao redor permite rolagem. A Apple documenta que ScrollView aceita rolagem horizontal, vertical ou em ambos os eixos; ela não oferece zoom. ScrollView

Calcule as posições dos ramos a partir do espaçamento das folhas

Comece pelas folhas, os nós que não têm filhos. Atribua a cada folha uma posição vertical sucessiva. Em seguida, posicione cada pai no ponto médio entre o primeiro e o último filho. A posição horizontal depende da profundidade: a raiz ocupa a coluna mais à direita, e cada nível adicional se desloca uma coluna para a esquerda.

Para este exemplo, use cartões de 132 por 44 pontos, um intervalo horizontal de 64 pontos e um intervalo vertical de 28 pontos. Esses valores são parâmetros de design ilustrativos. Com três colunas e quatro folhas, o conteúdo resultante mede 524 por 260 pontos antes do preenchimento externo.

Quantidade Cálculo Resultado
Distância entre colunas Largura do cartão + intervalo horizontal 196 pontos
Distância entre folhas Altura do cartão + intervalo vertical 72 pontos
Largura do conteúdo 3 × 132 + 2 × 64 524 pontos
Altura do conteúdo 4 × 44 + 3 × 28 260 pontos

Assim, as quatro folhas ficam com coordenadas verticais dos centros em 22, 94, 166 e 238. Seus pais imediatos ficam em 58 e 202, e a raiz fica em 130. Essas coordenadas oferecem um diagnóstico útil: um pai deve estar centralizado entre os centros dos filhos mais externos, e cada cartão deve caber dentro do retângulo de conteúdo informado.

O algoritmo abaixo também aceita ramos com profundidades diferentes. Uma folha mais rasa permanece na sua coluna de profundidade, em vez de se mover automaticamente para a coluna mais à esquerda. Essa é uma regra intencional deste exemplo. Decida se o alinhamento por profundidade ou por folhas é mais adequado ao seu diagrama antes de alterar a fórmula das coordenadas.

Crie o exemplo completo para macOS

Use um destino de app SwiftUI para macOS no Xcode, com versão de destino macOS 13 ou posterior. Substitua o arquivo de entrada do app gerado pelos três blocos de código a seguir, nesta ordem, em um único arquivo Swift. Juntos, eles formam o programa completo. Remova a declaração antiga do app se ela estiver em outro arquivo: a Apple exige exatamente um ponto de entrada do app. App

Defina a árvore e sua geometria

Cada nó tem um identificador inteiro e uma matriz de filhos. O construtor da geometria percorre os filhos antes de calcular o centro do pai, reunindo as posições dos nós e as arestas. Um identificador repetido causa uma falha de precondição, em vez de produzir uma coleção ambígua de visualizações.

import SwiftUI

struct BranchNode {
    let id: Int
    let children: [BranchNode]

    init(_ id: Int, _ children: [BranchNode] = []) {
        self.id = id
        self.children = children
    }

    var depth: Int {
        (children.map { $0.depth + 1 }.max()) ?? 0
    }
}

struct DiagramNode: Identifiable {
    let id: Int
    let center: CGPoint
}

struct DiagramEdge {
    let child: CGPoint
    let parent: CGPoint
}

struct DiagramGeometry {
    static let cardSize = CGSize(width: 132, height: 44)
    static let horizontalGap: CGFloat = 64
    static let verticalGap: CGFloat = 28

    let nodes: [DiagramNode]
    let edges: [DiagramEdge]
    let size: CGSize

    static func make(root: BranchNode) -> DiagramGeometry {
        let maximumDepth = root.depth
        var nextLeaf = 0
        var nodes: [DiagramNode] = []
        var edges: [DiagramEdge] = []
        var identifiers = Set<Int>()

        func visit(_ node: BranchNode, depth: Int) -> CGPoint {
            precondition(
                identifiers.insert(node.id).inserted,
                "Every node must have a unique identifier."
            )

            let x = cardSize.width / 2
                + CGFloat(maximumDepth - depth)
                * (cardSize.width + horizontalGap)
            let childCenters = node.children.map {
                visit($0, depth: depth + 1)
            }

            let y: CGFloat
            if let first = childCenters.first,
               let last = childCenters.last {
                y = (first.y + last.y) / 2
            } else {
                y = cardSize.height / 2
                    + CGFloat(nextLeaf)
                    * (cardSize.height + verticalGap)
                nextLeaf += 1
            }

            let center = CGPoint(x: x, y: y)
            nodes.append(DiagramNode(id: node.id, center: center))
            for child in childCenters {
                edges.append(DiagramEdge(child: child, parent: center))
            }
            return center
        }

        _ = visit(root, depth: 0)
        let size = CGSize(
            width: CGFloat(maximumDepth + 1) * cardSize.width
                + CGFloat(maximumDepth) * horizontalGap,
            height: CGFloat(nextLeaf) * cardSize.height
                + CGFloat(max(0, nextLeaf - 1)) * verticalGap
        )
        return DiagramGeometry(nodes: nodes, edges: edges, size: size)
    }
}

O modelo sempre contém uma raiz, então até uma árvore sem filhos produz um cartão com tamanho de conteúdo diferente de zero. Este programa não tem um caso separado para diagrama vazio. Se um app puder não ter raiz, trate essa condição explicitamente antes de construir esta geometria.

Posicione cartões usando um valor de layout personalizado

Passe o centro de cada cartão por meio de um LayoutValueKey. O padrão documentado pela Apple consiste em definir um valor com layoutValue(key:value:) e, em seguida, lê-lo na representação da subvisualização usando a chave. Isso associa a posição ao cartão, em vez de depender de índices de matrizes paralelas. LayoutValueKey

struct DiagramCenterKey: LayoutValueKey {
    static let defaultValue: CGPoint = .zero
}

struct BranchLayout: Layout {
    let contentSize: CGSize

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        contentSize
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let cardProposal = ProposedViewSize(
            width: DiagramGeometry.cardSize.width,
            height: DiagramGeometry.cardSize.height
        )
        for subview in subviews {
            let center = subview[DiagramCenterKey.self]
            subview.place(
                at: CGPoint(
                    x: bounds.minX + center.x,
                    y: bounds.minY + center.y
                ),
                anchor: .center,
                proposal: cardProposal
            )
        }
    }
}

O layout retorna o mesmo tamanho finito para qualquer proposta, pois seu conteúdo é inflexível. A Apple demonstra explicitamente que um layout inflexível pode ignorar a proposta. O SwiftUI pode chamar o método de dimensionamento várias vezes com propostas diferentes durante uma passagem de layout; por isso, esse método simplesmente retorna a geometria, sem atualizar o modelo. sizeThatFits(proposal:subviews:cache:)

O posicionamento soma bounds.minX e bounds.minY a cada centro local. Os limites fornecidos são expressos no sistema de coordenadas do pai, então presumir uma origem em zero prejudicaria o posicionamento quando o contêiner se movesse. A Apple também exige que os cálculos de dimensionamento e posicionamento concordem quanto ao espaçamento e às dimensões. placeSubviews(in:proposal:subviews:cache:)

Usar .center faz com que o ponto fornecido represente o meio de cada cartão. Cada representação é posicionada exatamente uma vez, dentro do método de posicionamento, conforme exigido pela API de posicionamento de subvisualizações. Os quadros explícitos dos cartões no próximo bloco garantem que os cartões exibidos correspondam às dimensões da geometria. place(at:anchor:proposal:)

Desenhe as conexões e apresente a janela

Cada conexão começa na borda direita do cartão do filho e termina na borda esquerda do cartão do pai. Dois pontos intermediários criam um trajeto em ângulo reto pelo meio do intervalo. Path.addLines(_:) cria segmentos retos conectados a partir de uma matriz de pontos. addLines(_:)

struct BranchDiagram: View {
    let geometry: DiagramGeometry

    var body: some View {
        BranchLayout(contentSize: geometry.size) {
            ForEach(geometry.nodes) { node in
                Text("Node \(node.id)")
                    .font(.body)
                    .frame(
                        width: DiagramGeometry.cardSize.width,
                        height: DiagramGeometry.cardSize.height
                    )
                    .background(
                        RoundedRectangle(cornerRadius: 8)
                            .fill(Color.gray.opacity(0.12))
                    )
                    .overlay {
                        RoundedRectangle(cornerRadius: 8)
                            .stroke(Color.secondary, lineWidth: 1)
                    }
                    .layoutValue(
                        key: DiagramCenterKey.self,
                        value: node.center
                    )
            }
        }
        .background {
            Canvas { context, _ in
                var path = Path()
                for edge in geometry.edges {
                    let start = CGPoint(
                        x: edge.child.x
                            + DiagramGeometry.cardSize.width / 2,
                        y: edge.child.y
                    )
                    let end = CGPoint(
                        x: edge.parent.x
                            - DiagramGeometry.cardSize.width / 2,
                        y: edge.parent.y
                    )
                    let middleX = (start.x + end.x) / 2
                    path.addLines([
                        start,
                        CGPoint(x: middleX, y: start.y),
                        CGPoint(x: middleX, y: end.y),
                        end
                    ])
                }
                context.stroke(
                    path,
                    with: .color(.secondary),
                    lineWidth: 1.5
                )
            }
        }
    }
}

@main
struct BranchDiagramApp: App {
    let geometry = DiagramGeometry.make(
        root: BranchNode(1, [
            BranchNode(2, [BranchNode(4), BranchNode(5)]),
            BranchNode(3, [BranchNode(6), BranchNode(7)])
        ])
    )

    var body: some Scene {
        WindowGroup {
            ScrollView([.horizontal, .vertical]) {
                BranchDiagram(geometry: geometry)
                    .padding(24)
            }
            .frame(minWidth: 360, minHeight: 240)
        }
    }
}

ForEach recebe valores de nós identificáveis, conforme o requisito da Apple de usar elementos identificáveis ou fornecer um argumento de identificador explícito. Se futuramente você tornar a árvore editável, mantenha os identificadores associados aos nós lógicos. ForEach

O canvas é anexado diretamente ao fundo do layout, de modo que suas coordenadas locais correspondam à geometria usada para os cartões. O preenchimento externo é aplicado depois que o diagrama é montado e desloca as duas camadas juntas. Trechos compartilhados das conexões entre irmãos se sobrepõem para formar uma junção em forma de colchete.

WindowGroup fornece a hierarquia de visualizações da janela e o comportamento padrão de gerenciamento de janelas do macOS. O exemplo armazena a geometria sintética imutável na estrutura do app; não instala temporizadores nem retornos de chamada de ciclo de vida para reposicionar as visualizações. WindowGroup

Verifique o diagrama e diagnostique problemas comuns

A aritmética deste exemplo foi verificada com um pequeno cálculo determinístico em Python: sete centros, seis arestas, um retângulo de conteúdo de 524 por 260 pontos e todos os cartões dentro desse retângulo. O programa SwiftUI foi inspecionado, mas não pôde ser compilado no ambiente de pesquisa porque não havia um compilador Swift disponível.

Ao compilar o app no Xcode, verifique estes resultados observáveis:

  1. Quatro cartões aparecem na coluna mais à esquerda, dois na coluna do meio e um à direita.
  2. Cada conexão toca o ponto médio horizontal da borda correspondente do cartão.
  3. Cada pai fica verticalmente entre seus filhos mais externos.
  4. Ao redimensionar a janela, as dimensões dos cartões são preservadas e a rolagem aparece quando necessário.
  5. Substituir a raiz de exemplo por BranchNode(1) exibe um cartão sem conexões.

Se os cartões e as linhas estiverem desalinhados, verifique primeiro onde os modificadores são aplicados. Preenchimentos ou deslocamentos aplicados somente aos cartões introduzem um segundo ajuste de coordenadas que a geometria das conexões não acompanha. Mantenha o espaçamento geral do diagrama fora de BranchDiagram, como mostrado.

Se um cartão aparecer inesperadamente na origem do layout, verifique se sua visualização final tem o valor DiagramCenterKey. O valor padrão da chave é .zero; portanto, se a atribuição estiver faltando, o centro do cartão será posicionado na origem. Se o programa parar na precondição do identificador, atribua um identificador distinto a cada nó antes da renderização.

As dimensões fixas dos cartões são adequadas aos rótulos sintéticos curtos deste exemplo. Para textos mais longos ou fontes maiores, introduza uma etapa de medição: solicite as dimensões às representações das subvisualizações, calcule as larguras das colunas e o espaçamento das subárvores com base nessas medidas e use esses mesmos resultados para as extremidades das conexões. Uma proposta de tamanho, por si só, não obriga uma subvisualização a aceitar esse tamanho. place(at:anchor:proposal:)

Este algoritmo descreve uma árvore: cada filho exibido pertence a um único pai. Ele não encaminha conexões cruzadas arbitrárias nem representa um nó compartilhado por vários pais. Esses requisitos exigem outro modelo geométrico e uma política explícita de encaminhamento. Da mesma forma, animações exigiriam que a geometria das conexões se movesse junto com as posições dos cartões; este exemplo oferece posicionamento estático.

Mantenha um único cálculo geométrico responsável pelo tamanho do conteúdo, pelos centros dos cartões e pelas extremidades das conexões. Assim, você terá um ponto concreto para ajustar o espaçamento, inspecionar as coordenadas e ampliar o diagrama, mantendo a concordância entre o layout e o desenho do SwiftUI.

Fontes