Maak een vertakkend bracketdiagram in een Mac-app door de middelpunten van knooppunten één keer te berekenen, kaarten te plaatsen met een aangepaste SwiftUI-Layout en verbindingslijnen erachter te tekenen met Canvas. Met een schuifweergave over twee assen blijft het diagram leesbaar wanneer het venster kleiner is dan de inhoud.

Deze tutorial maakt een synthetisch diagram met zeven knooppunten en neutrale labels zoals Node 1. De vertakkingen lopen vanaf een hoofdknooppunt rechts naar links. De concrete taak is om verbonden kaarten met gelijkmatige tussenruimten weer te geven in een aanpasbaar macOS-venster, waarbij zowel de plaatsing als het tekenen op dezelfde geometrie is gebaseerd.

Kies een lay-outstructuur die kaarten en lijnen uitgelijnd houdt

Gebruik voor deze implementatie een macOS-deploymenttarget van 13 of hoger. Volgens Apple is Layout beschikbaar vanaf macOS 13. De twee vereiste methoden geven de grootte van de container door en plaatsen de subweergaven. Daardoor is dit geschikt voor het rangschikken van kaarten op berekende posities. Layout

Houd de knooppuntkaarten als gewone SwiftUI-weergaven. Gebruik een Canvas alleen als achtergrond voor de verbindingslijnen. Apple legt uit dat afzonderlijke elementen die binnen een canvas worden getekend geen eigen interactiviteit of toegankelijkheid hebben, ook geen weergaven die als canvassymbolen zijn aangeleverd. Afzonderlijke kaarten bieden later een eenvoudige manier om gewone bedieningselementen toe te voegen. Canvas

De implementatie bestaat uit drie onderdelen: een boom die de vertakkingen beschrijft, een geometrieberekening die middelpunten van knooppunten en verbindingen oplevert, en een weergave die kaarten combineert met het tekenen van verbindingslijnen. Beide weergavelagen ontvangen dezelfde geometriewaarde. Daardoor kan de boom niet worden gewijzigd zonder dat de kaarten worden bijgewerkt terwijl een afzonderlijk bijgehouden lijst met verbindingslijnen achterblijft.

Het formaatbeleid is bewust vast: de kaarten behouden dezelfde grootte wanneer het venster verandert, en de omringende weergave kan scrollen. Volgens Apple ondersteunt ScrollView horizontaal, verticaal of in beide richtingen scrollen; inzoomen wordt niet ondersteund. ScrollView

Bereken vertakkingsposities op basis van de afstand tussen bladeren

Begin bij de bladeren, de knooppunten zonder kinderen. Wijs elk blad een opeenvolgende verticale positie toe. Plaats elke ouder vervolgens halverwege het eerste en laatste kind. De horizontale positie hangt af van de diepte: het hoofdknooppunt staat in de meest rechtse kolom en elke extra diepte verschuift één kolom naar links.

Gebruik voor dit voorbeeld kaarten van 132 bij 44 punten, een horizontale tussenruimte van 64 punten en een verticale tussenruimte van 28 punten. Dit zijn illustratieve ontwerpwaarden. Met drie kolommen en vier bladeren is de resulterende inhoud 524 bij 260 punten, exclusief buitenste opvulling.

Grootheid Berekening Resultaat
Kolomstap Kaartbreedte + horizontale tussenruimte 196 punten
Bladstap Kaarthoogte + verticale tussenruimte 72 punten
Inhoudsbreedte 3 × 132 + 2 × 64 524 punten
Inhoudshoogte 4 × 44 + 3 × 28 260 punten

Dit geeft de vier middelpunten van de bladeren verticale coördinaten van 22, 94, 166 en 238. Hun directe ouders staan op 58 en 202, en het hoofdknooppunt staat op 130. Deze coördinaten zijn nuttig als controle: een ouder hoort gecentreerd te staan tussen de buitenste middelpunten van zijn kinderen, en elke kaart moet binnen de opgegeven inhoudsrechthoek passen.

Het onderstaande algoritme ondersteunt ook vertakkingen met ongelijke dieptes. Een ondiep blad blijft in zijn dieptekolom in plaats van automatisch naar de meest linkse kolom te verplaatsen. Dat is een bewuste keuze in dit voorbeeld. Bepaal voordat je de coördinatenformule wijzigt of uitlijning op diepte of op bladeren beter bij je diagram past.

Bouw het volledige macOS-voorbeeld

Gebruik een macOS SwiftUI-appdoel in Xcode met een deploymenttarget van macOS 13 of hoger. Vervang het gegenereerde app-entrybestand door de volgende drie codeblokken, in deze volgorde, in één Swift-bestand. Samen vormen ze het volledige programma. Verwijder de oude app-declaratie als die in een ander bestand staat: Apple vereist precies één app-entrypoint. App

Definieer de boom en de geometrie

Elk knooppunt heeft een geheel getal als identificatie en een array met kinderen. De geometriebouwer bezoekt de kinderen voordat hij het middelpunt van hun ouder berekent, en verzamelt zowel de plaatsingen van knooppunten als de verbindingen. Een herhaalde identificatie veroorzaakt een preconditionfout in plaats van een ambigue verzameling weergaven op te leveren.

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

Het model bevat altijd een hoofdknooppunt, zodat zelfs een boom zonder kinderen één kaart met een niet-nul inhoudsgrootte oplevert. Dit programma heeft geen afzonderlijk geval voor een leeg diagram. Als je toepassing geen hoofdknooppunt toestaat, handel die situatie dan expliciet af voordat je deze geometrie opbouwt.

Plaats kaarten met een aangepaste lay-outwaarde

Geef het middelpunt van elke kaart door via een LayoutValueKey. Het gedocumenteerde patroon van Apple is om een waarde in te stellen met layoutValue(key:value:) en die vervolgens via de sleutel uit de proxy van de subweergave te lezen. Zo wordt de positie aan de kaart gekoppeld in plaats van te vertrouwen op parallelle array-indexen. 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
            )
        }
    }
}

De lay-out retourneert voor elk voorstel dezelfde eindige grootte omdat de inhoud niet flexibel is. Apple laat expliciet zien dat een niet-flexibele lay-out het voorstel mag negeren. Tijdens een lay-outpass kan SwiftUI de methode voor het bepalen van de grootte meerdere keren aanroepen met verschillende voorstellen. Daarom retourneert deze methode alleen de geometrie en voert ze geen modelupdates uit. sizeThatFits(proposal:subviews:cache:)

Bij het plaatsen worden bounds.minX en bounds.minY bij elk lokaal middelpunt opgeteld. De opgegeven grenzen zijn uitgedrukt in het coördinatenstelsel van de ouder, dus plaatsing op basis van een veronderstelde oorsprong van nul gaat mis wanneer de container verschuift. Apple vereist ook dat berekeningen voor grootte en plaatsing overeenkomen wat betreft tussenruimten en afmetingen. placeSubviews(in:proposal:subviews:cache:)

Met .center stelt het opgegeven punt het midden van elke kaart voor. Elke proxy wordt precies één keer geplaatst, binnen de plaatsingsmethode, zoals vereist door de API voor het plaatsen van subweergaven. De expliciete kaders van de kaarten in het volgende blok zorgen ervoor dat de weergegeven kaarten overeenkomen met de afmetingen in de geometrie. place(at:anchor:proposal:)

Teken verbindingslijnen en toon het venster

Elke verbinding begint aan de rechterrand van de kaart van het kind en eindigt aan de linkerrand van de kaart van de ouder. Twee tussenpunten maken een route met rechte hoeken door het midden van de tussenruimte. Path.addLines(_:) maakt verbonden rechte lijnsegmenten op basis van een array met punten. 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 krijgt identificeerbare knooppuntwaarden, overeenkomstig de eis van Apple om identificeerbare elementen te gebruiken of een expliciete identifierparameter op te geven. Behoud de identificaties van de logische knooppunten als je de boom later bewerkbaar maakt. ForEach

Het canvas is rechtstreeks aan de achtergrond van de lay-out gekoppeld, zodat de lokale coördinaten overeenkomen met de geometrie die voor de kaarten wordt gebruikt. Buitenste opvulling wordt pas toegepast nadat het diagram is samengesteld en verplaatst beide lagen samen. Gedeelde delen van verbindingen tussen broers en zussen overlappen om een vertakkingspunt in de vorm van een haakje te vormen.

WindowGroup levert de weergavehiërarchie van het venster en het standaardvensterbeheer in macOS. Het voorbeeld bevat onveranderlijke synthetische geometrie in de appstructuur; het installeert geen timers of callbacks voor de levenscyclus om weergaven te verplaatsen. WindowGroup

Controleer het diagram en spoor veelvoorkomende problemen op

De berekeningen voor dit voorbeeld zijn gecontroleerd met een kleine deterministische Python-berekening: zeven middelpunten, zes verbindingen, een inhoudsrechthoek van 524 bij 260 punten en alle kaarten binnen die rechthoek. Het SwiftUI-programma is nagekeken, maar kon in de onderzoeksomgeving niet worden gecompileerd omdat er geen Swift-compiler beschikbaar was.

Controleer bij het bouwen van de app in Xcode de volgende waarneembare resultaten:

  1. Vier kaarten verschijnen in de meest linkse kolom, twee in het midden en één rechts.
  2. Elke verbindingslijn raakt het horizontale midden van de corresponderende kaartrand.
  3. Elke ouder staat verticaal tussen de buitenste kinderen.
  4. Bij het aanpassen van het venster behouden de kaarten hun afmetingen en verschijnt zo nodig een schuifbalk.
  5. Als je het voorbeeldhoofdknooppunt vervangt door BranchNode(1), verschijnt één kaart zonder verbindingslijnen.

Als kaarten en lijnen verschoven zijn, controleer dan eerst de plaatsing van de modifiers. Opvulling of verschuiving die alleen op de kaarten wordt toegepast, introduceert een tweede coördinaataanpassing die niet wordt gedeeld met de geometrie van de verbindingslijnen. Houd de diagramvullende tussenruimte buiten BranchDiagram, zoals hier wordt getoond.

Als een kaart onverwacht bij de oorsprong van de lay-out verschijnt, controleer dan of de uiteindelijke weergave de waarde DiagramCenterKey heeft. De standaardsleutel is .zero; een ontbrekende toewijzing plaatst het middelpunt van de kaart dus bij de oorsprong. Als het programma stopt bij de identifier-precondition, geef dan elk knooppunt een unieke identificatie voordat je het weergeeft.

Vaste kaartafmetingen zijn geschikt voor de korte synthetische labels in dit voorbeeld. Introduceer voor langere tekst of grotere lettertypen een meetfase: vraag de proxy's van de subweergaven om hun afmetingen, bereken kolombreedtes en de ruimte tussen subbomen op basis van die metingen, en gebruik dezelfde resultaten voor de eindpunten van de verbindingslijnen. Een groottevoorstel dwingt een subweergave niet om die grootte te accepteren. place(at:anchor:proposal:)

Dit algoritme beschrijft een boom: elk weergegeven kind hoort bij één ouder. Het routeert geen willekeurige kruisverbindingen en geeft geen gedeeld knooppunt weer onder meerdere ouders. Voor die vereisten zijn een ander geometrisch model en een expliciet routeringsbeleid nodig. Ook moet bij animatie de geometrie van de verbindingslijnen synchroon bewegen met de posities van de kaarten; dit voorbeeld gebruikt statische plaatsing.

Laat één geometrieberekening verantwoordelijk zijn voor de inhoudsgrootte, de middelpunten van kaarten en de eindpunten van verbindingen. Zo heb je een concrete plek om de tussenruimten aan te passen, coördinaten te inspecteren en het diagram uit te breiden, terwijl de SwiftUI-lay-out en het tekenen op elkaar blijven aansluiten.

Bronnen