Skapa ett förgrenat bracketdiagram i en Mac-app genom att beräkna nodernas mittpunkter en gång, placera kort med en anpassad SwiftUI-Layout och rita förbindelser bakom dem med Canvas. En rullningsvy i två riktningar håller diagrammet läsbart när fönstret är mindre än innehållet.

Den här handledningen skapar ett syntetiskt diagram med sju noder och neutrala etiketter som Node 1. Grenarna sträcker sig åt vänster från en rot till höger. Uppgiften är att visa sammanlänkade kort med jämna mellanrum i ett storleksändringsbart macOS-fönster, där placering och ritning styrs av samma geometri.

Välj en layoutstruktur som håller kort och linjer i linje

Använd ett distributionsmål för macOS 13 eller senare för den här implementationen. Apple dokumenterar att Layout är tillgängligt från och med macOS 13. Dess två obligatoriska metoder rapporterar containerns storlek och placerar dess undervyer. Det gör typen lämplig för att ordna kort på beräknade positioner. Layout

Behåll nodkorten som vanliga SwiftUI-vyer. Använd en Canvas endast som bakgrund för förbindelsestreck. Apple förklarar att enskilda element som ritas inuti en canvas inte får egen interaktivitet eller tillgänglighet, inte heller vyer som tillhandahålls som canvassymboler. Separata kort ger en tydlig väg till att senare lägga till vanliga kontroller. Canvas

Implementationen har tre delar: ett träd som beskriver grenarna, en geometriberäkning som tar fram nodernas mittpunkter och kanter samt en vy som kombinerar korten med förbindelserna. Båda renderingslagren får samma geometrivärde. Därför kan en ändring av trädet inte uppdatera korten och av misstag lämna kvar en separat, självständigt underhållen lista med förbindelser.

Storleksprincipen är avsiktligt fast: korten behåller samma storlek när fönstret ändras, och den omgivande visningsytan rullas. Apple dokumenterar att ScrollView stöder horisontell rullning, vertikal rullning eller båda; den stöder inte zoomning. ScrollView

Beräkna grenarnas positioner utifrån bladens mellanrum

Börja med bladen, alltså noderna som saknar barn. Tilldela varje blad en vertikal plats i följd. Placera sedan varje förälder mitt emellan sitt första och sista barn. Den horisontella positionen beror på djupet: roten ligger i kolumnen längst till höger, och varje ytterligare djup flyttar en kolumn åt vänster.

I det här exemplet används kort som mäter 132 × 44 punkter, ett horisontellt mellanrum på 64 punkter och ett vertikalt mellanrum på 28 punkter. Det är illustrativa designvärden. Med tre kolumner och fyra blad blir innehållet 524 × 260 punkter före den yttre utfyllnaden.

Storhet Beräkning Resultat
Kolumnsteg Kortbredd + horisontellt mellanrum 196 punkter
Bladsteg Korthöjd + vertikalt mellanrum 72 punkter
Innehållsbredd 3 × 132 + 2 × 64 524 punkter
Innehållshöjd 4 × 44 + 3 × 28 260 punkter

Då får de fyra bladen mittpunkter med de vertikala koordinaterna 22, 94, 166 och 238. Deras närmaste föräldrar hamnar på 58 respektive 202, och roten på 130. Koordinaterna ger en användbar kontroll: en förälder ska ligga mitt emellan sina yttersta barns mittpunkter, och varje kort ska rymmas inom den rapporterade innehållsrektangeln.

Algoritmen nedan hanterar också grenar med olika djup. Ett grunt blad stannar i sin djupkolumn i stället för att automatiskt flyttas till kolumnen längst till vänster. Det är en avsiktlig regel i det här exemplet. Bestäm om djupjustering eller bladjustering passar ditt diagram innan du ändrar koordinatformeln.

Skapa det kompletta macOS-exemplet

Använd ett Xcode-appmål för macOS med SwiftUI och ett distributionsmål för macOS 13 eller senare. Ersätt den genererade appens startfil med följande tre kodblock, i ordning, i en enda Swift-fil. Tillsammans utgör de hela programmet. Ta bort den gamla appdeklarationen om den finns i en annan fil: Apple kräver exakt en appstartpunkt. App

Definiera trädet och dess geometri

Varje nod har en heltalsidentifierare och en array med barn. Geometribyggaren går igenom barnen innan den beräknar förälderns mittpunkt och samlar in både nodernas placeringar och kanterna. Om en identifierare upprepas utlöses ett precondition-fel i stället för att skapa en tvetydig vygrupp.

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

Modellen innehåller alltid en rot, så även ett träd utan barn ger ett kort med en innehållsstorlek som inte är noll. Programmet har inget separat fall för ett tomt diagram. Om en app tillåter att rot saknas ska du hantera det uttryckligen innan du skapar geometrin.

Placera kort med ett anpassat layoutvärde

Skicka varje korts mittpunkt via en LayoutValueKey. Apples dokumenterade mönster är att ange ett värde med layoutValue(key:value:) och sedan läsa det från undervyns proxy med nyckeln. Då kopplas positionen till kortet i stället för att förlita sig på parallella arrayindex. 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
            )
        }
    }
}

Layouten returnerar samma ändliga storlek för alla förslag eftersom innehållet inte kan ändra storlek. Apple visar uttryckligen att en oflexibel layout kan ignorera förslaget. SwiftUI kan anropa storleksmetoden flera gånger med olika förslag under ett layoutpass, så metoden returnerar bara geometrin och utför inga modelluppdateringar. sizeThatFits(proposal:subviews:cache:)

Placeringen lägger till bounds.minX och bounds.minY till varje lokal mittpunkt. De angivna gränserna uttrycks i förälderns koordinatsystem, så om man förutsätter en origo vid noll blir placeringen fel när containern flyttas. Apple kräver också att beräkningarna för storlek och placering stämmer överens när det gäller mellanrum och dimensioner. placeSubviews(in:proposal:subviews:cache:)

Med .center representerar den angivna punkten mitten av varje kort. Varje proxy placeras exakt en gång, inuti placeringsmetoden, vilket subvyernas placerings-API kräver. De uttryckliga ramarna för korten i nästa block säkerställer att de visade korten har samma dimensioner som geometrin. place(at:anchor:proposal:)

Rita förbindelser och visa fönstret

Varje förbindelse börjar vid barnkortets högra kant och slutar vid föräldrakortets vänstra kant. Två mellanliggande punkter skapar en rätvinklig väg genom mitten av mellanrummet. Path.addLines(_:) skapar sammanhängande raka segment från en array med punkter. 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 får identifierbara nodvärden, i enlighet med Apples krav på antingen identifierbara element eller ett uttryckligt identifierarargument. Behåll identifierarna kopplade till de logiska noderna om du senare gör trädet redigerbart. ForEach

Canvasen läggs direkt som bakgrund till layouten, så dess lokala koordinater stämmer med geometrin som används för korten. Den yttre utfyllnaden tillämpas när diagrammet har satts samman och flyttar båda lagren tillsammans. Överlappande delar av syskonens förbindelser bildar en konsolformad förgrening.

WindowGroup tillhandahåller fönstrets vyhierarki och standardbeteendet för fönsterhantering i macOS. Exemplet lagrar oföränderlig syntetisk geometri i appstrukturen; det använder inga timers eller livscykelåteranrop för att flytta vyer. WindowGroup

Kontrollera diagrammet och felsök vanliga problem

Aritmetiken för exemplet kontrollerades med en liten deterministisk Python-beräkning: sju mittpunkter, sex kanter, en innehållsrektangel på 524 × 260 punkter och alla kort innanför rektangeln. SwiftUI-programmet granskades, men kunde inte kompileras i undersökningsmiljön eftersom ingen Swift-kompilator fanns tillgänglig.

När du bygger appen i Xcode ska du kontrollera följande synliga resultat:

  1. Fyra kort visas i kolumnen längst till vänster, två i mitten och ett till höger.
  2. Varje förbindelse möter mittpunkten på motsvarande kortkant.
  3. Varje förälder ligger vertikalt mellan sina yttersta barn.
  4. När fönstret ändrar storlek behåller korten sina dimensioner, och rullning blir tillgänglig vid behov.
  5. Om du ersätter exempelroten med BranchNode(1) visas ett kort utan förbindelser.

Om kort och linjer är förskjutna ska du först kontrollera var modifierare tillämpas. Utfyllnad eller förskjutningar som bara tillämpas på korten skapar en extra koordinatjustering som förbindelsernas geometri inte delar. Placera diagramövergripande avstånd utanför BranchDiagram, som i exemplet.

Om ett kort oväntat hamnar vid layoutens origo ska du kontrollera att dess slutliga vy har värdet DiagramCenterKey. Nyckelns standardvärde är .zero; om värdet inte anges placeras därför kortets mittpunkt vid origo. Om programmet stannar vid identifierarens precondition ska du ge varje nod en unik identifierare innan rendering.

Fasta kortdimensioner passar de korta syntetiska etiketterna i det här exemplet. För längre text eller större teckenstorlekar bör du införa ett mätsteg: be undervyernas proxyer om deras storlekar, beräkna kolumnbredder och mellanrum mellan delträd utifrån mätningarna och använd samma resultat för förbindelsernas ändpunkter. Ett storleksförslag tvingar inte i sig en undervy att anta den storleken. place(at:anchor:proposal:)

Algoritmen beskriver ett träd: varje visat barn tillhör en förälder. Den hanterar inte godtyckliga tvärlänkar eller en delad nod under flera föräldrar. Sådana krav behöver en annan geometrimodell och en uttrycklig routningspolicy. På samma sätt kräver animering att förbindelsernas geometri rör sig i takt med kortens positioner; det här exemplet använder statisk placering.

Låt en enda geometriberäkning ansvara för innehållsstorlek, kortens mittpunkter och kantändpunkter. Då får du en konkret plats att justera mellanrum, granska koordinater och utöka diagrammet på, samtidigt som SwiftUI-layouten och ritningen fortsätter att stämma överens.

Källor