Crea un diagramma a parentesi ramificate in un’app per Mac calcolando una sola volta i centri dei nodi, disponendo le schede con un Layout SwiftUI personalizzato e disegnando i collegamenti dietro di esse con Canvas. Una vista scorrevole sui due assi mantiene leggibile il diagramma quando la finestra è più piccola del suo contenuto.

Questo tutorial crea un diagramma sintetico con sette nodi ed etichette neutre come Node 1. I rami si estendono verso sinistra a partire da una radice sulla destra. L’obiettivo concreto è mostrare schede collegate e distanziate in modo uniforme in una finestra ridimensionabile di macOS, con posizionamento e disegno basati sulla stessa geometria.

Scegli una struttura di layout che mantenga allineate le schede e le linee

Per questa implementazione, usa una versione minima di macOS 13 o successiva. Apple documenta che Layout è disponibile a partire da macOS 13. I suoi due metodi obbligatori restituiscono le dimensioni del contenitore e posizionano le sue viste secondarie. È quindi adatto a disporre le schede in posizioni calcolate. Layout

Mantieni le schede dei nodi come normali viste SwiftUI. Usa un Canvas di sfondo solo per i tratti dei collegamenti. Apple spiega che i singoli elementi disegnati all’interno di un canvas non hanno interattività o accessibilità proprie, comprese le viste fornite come simboli del canvas. Separare le schede semplifica l’aggiunta di normali controlli in seguito. Canvas

L’implementazione ha tre parti: un albero che descrive i rami, un calcolo geometrico che produce i centri dei nodi e i lati, e una vista che combina le schede con il disegno dei collegamenti. Entrambi i livelli di rendering ricevono lo stesso valore geometrico. In questo modo, modificare l’albero non può aggiornare le schede lasciando per errore invariato un array di collegamenti gestito separatamente.

La politica di dimensionamento è volutamente fissa: le schede mantengono le stesse dimensioni quando cambia la finestra, mentre l’area circostante scorre. Apple documenta che ScrollView supporta lo scorrimento orizzontale, verticale o in entrambe le direzioni; non offre lo zoom. ScrollView

Calcola le posizioni dei rami a partire dalla spaziatura delle foglie

Parti dalle foglie, cioè dai nodi senza figli. Assegna a ogni foglia uno slot verticale successivo. Poi posiziona ogni genitore a metà strada tra il primo e l’ultimo figlio. La posizione orizzontale dipende dalla profondità: la radice occupa la colonna più a destra e ogni livello di profondità aggiuntivo sposta una colonna verso sinistra.

Per questo esempio, usa schede da 132 per 44 punti, una spaziatura orizzontale di 64 punti e una verticale di 28 punti. Sono parametri di progettazione a scopo illustrativo. Con tre colonne e quattro foglie, il contenuto risultante misura 524 per 260 punti, prima della spaziatura esterna.

Quantità Calcolo Risultato
Passo tra le colonne Larghezza della scheda + spaziatura orizzontale 196 punti
Passo tra le foglie Altezza della scheda + spaziatura verticale 72 punti
Larghezza del contenuto 3 × 132 + 2 × 64 524 punti
Altezza del contenuto 4 × 44 + 3 × 28 260 punti

I centri delle quattro foglie hanno così coordinate verticali pari a 22, 94, 166 e 238. I rispettivi genitori immediati si trovano a 58 e 202, mentre la radice si trova a 130. Queste coordinate offrono un utile controllo: un genitore dovrebbe essere centrato tra i centri dei figli più esterni e ogni scheda dovrebbe rientrare nel rettangolo del contenuto dichiarato.

L’algoritmo seguente gestisce anche rami di profondità disuguale. Una foglia meno profonda rimane nella propria colonna di profondità, invece di spostarsi automaticamente nella colonna più a sinistra. È una regola intenzionale di questo esempio. Prima di modificare la formula delle coordinate, decidi se il tuo diagramma si adatta meglio all’allineamento per profondità o per foglie.

Crea l’esempio macOS completo

Usa un target di app macOS SwiftUI in Xcode con una versione minima di macOS 13 o successiva. Sostituisci il file di ingresso dell’app generato con i tre blocchi di codice seguenti, nell’ordine indicato e in un unico file Swift. Insieme formano il programma completo. Rimuovi la vecchia dichiarazione dell’app se si trova in un altro file: Apple richiede un solo punto di ingresso per l’app. App

Definisci l’albero e la sua geometria

Ogni nodo ha un identificatore intero e un array di figli. Il generatore di geometria visita i figli prima di calcolare il centro del genitore, raccogliendo sia le posizioni dei nodi sia i lati. Un identificatore ripetuto attiva un errore di precondizione invece di produrre una raccolta di viste ambigua.

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)
    }
}

Il modello contiene sempre una radice, quindi anche un albero senza figli produce una scheda con dimensioni del contenuto diverse da zero. In questo programma non esiste un caso separato per un diagramma vuoto. Se l’applicazione consente l’assenza di una radice, gestisci esplicitamente tale condizione prima di costruire questa geometria.

Posiziona le schede con un valore di layout personalizzato

Passa il centro di ogni scheda tramite un LayoutValueKey. Lo schema documentato da Apple consiste nell’impostare un valore con layoutValue(key:value:), quindi leggerlo dalla proxy della vista secondaria usando la chiave. In questo modo la posizione è associata alla scheda, senza dipendere da indici paralleli degli array. 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
            )
        }
    }
}

Il layout restituisce la stessa dimensione finita per ogni proposta, perché il suo contenuto è rigido. Apple mostra esplicitamente che un layout rigido può ignorare la proposta. Durante un passaggio di layout, SwiftUI può chiamare il metodo di dimensionamento più volte con proposte diverse, quindi questo metodo si limita a restituire la geometria: non modifica il modello. sizeThatFits(proposal:subviews:cache:)

Il posizionamento aggiunge bounds.minX e bounds.minY al centro locale di ogni vista. I limiti forniti sono espressi nello spazio di coordinate del genitore, quindi presumere che l’origine sia zero causerebbe errori di posizionamento quando il contenitore si sposta. Apple richiede inoltre che i calcoli di dimensionamento e posizionamento concordino su spaziatura e dimensioni. placeSubviews(in:proposal:subviews:cache:)

Usando .center, il punto fornito rappresenta il centro di ogni scheda. Ogni proxy viene posizionata esattamente una volta, all’interno del metodo di posizionamento, come richiesto dall’API per il posizionamento delle viste secondarie. Le dimensioni esplicite delle schede nel blocco successivo garantiscono che le schede visualizzate corrispondano alle dimensioni della geometria. place(at:anchor:proposal:)

Disegna i collegamenti e mostra la finestra

Ogni collegamento parte dal bordo destro della scheda figlia e termina sul bordo sinistro della scheda genitore. Due punti intermedi creano un percorso ad angolo retto attraverso il centro dello spazio. Path.addLines(_:) crea segmenti rettilinei collegati a partire da un array di punti. 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 riceve valori di nodo identificabili, in linea con il requisito di Apple di usare elementi identificabili oppure un argomento identificatore esplicito. Se in seguito rendi modificabile l’albero, mantieni gli identificatori associati ai nodi logici. ForEach

Il canvas è collegato direttamente allo sfondo del layout, quindi le sue coordinate locali corrispondono alla geometria usata per le schede. La spaziatura esterna viene applicata dopo l’assemblaggio del diagramma e sposta insieme entrambi i livelli. Le parti condivise dei collegamenti tra fratelli si sovrappongono formando un incrocio a parentesi.

WindowGroup fornisce la gerarchia di viste della finestra e il comportamento standard di gestione delle finestre di macOS. L’esempio mantiene la geometria sintetica immutabile nella struttura dell’app; non installa timer né callback del ciclo di vita per riposizionare le viste. WindowGroup

Verifica il diagramma e individua i problemi comuni

I calcoli aritmetici per questo esempio sono stati verificati con un piccolo calcolo deterministico in Python: sette centri, sei lati, un rettangolo del contenuto di 524 per 260 punti e tutte le schede all’interno del rettangolo. Il programma SwiftUI è stato esaminato, ma non è stato possibile compilarlo nell’ambiente di ricerca perché non era disponibile un compilatore Swift.

Quando crei l’app in Xcode, verifica i seguenti risultati osservabili:

  1. Quattro schede compaiono nella colonna più a sinistra, due in quella centrale e una a destra.
  2. Ogni collegamento tocca il punto medio orizzontale del bordo della scheda corrispondente.
  3. Ogni genitore è posizionato verticalmente tra i figli più esterni.
  4. Ridimensionando la finestra, le dimensioni delle schede restano invariate e, quando serve, compare lo scorrimento.
  5. Sostituendo la radice di esempio con BranchNode(1), viene mostrata una scheda senza collegamenti.

Se le schede e le linee risultano disallineate, controlla prima la posizione dei modificatori. Spaziatura o offset applicati solo alle schede introducono una seconda modifica delle coordinate, che la geometria dei collegamenti non condivide. Mantieni all’esterno di BranchDiagram la spaziatura che interessa l’intero diagramma, come mostrato.

Se una scheda compare inaspettatamente all’origine del layout, controlla che la sua vista finale abbia il valore DiagramCenterKey. Il valore predefinito della chiave è .zero; se manca un’assegnazione, il centro della scheda viene quindi posizionato all’origine. Se il programma si interrompe per la precondizione sugli identificatori, assegna a ogni nodo un identificatore distinto prima del rendering.

Le dimensioni fisse delle schede sono adatte alle brevi etichette sintetiche di questo esempio. Per testi più lunghi o caratteri più grandi, aggiungi una fase di misurazione: chiedi le dimensioni alle proxy delle viste secondarie, calcola la larghezza delle colonne e la spaziatura dei sottoalberi in base a tali misure e usa gli stessi risultati per gli estremi dei collegamenti. Una proposta di dimensioni, da sola, non obbliga una vista secondaria ad accettarle. place(at:anchor:proposal:)

Questo algoritmo descrive un albero: ogni figlio visualizzato appartiene a un solo genitore. Non instrada collegamenti incrociati arbitrari e non rappresenta un nodo condiviso tra più genitori. Questi requisiti richiedono un modello geometrico diverso e una politica di instradamento esplicita. Anche l’animazione richiederebbe che la geometria dei collegamenti si muovesse insieme alle posizioni delle schede; questo esempio prevede un posizionamento statico.

Affida a un unico calcolo geometrico le dimensioni del contenuto, i centri delle schede e gli estremi dei lati. Avrai così un punto concreto da cui modificare la spaziatura, ispezionare le coordinate ed estendere il diagramma, mantenendo coerenti il layout SwiftUI e il disegno.

Fonti