Build a branching bracket diagram in a Mac app by calculating node centers once, placing cards with a custom SwiftUI Layout, and drawing connectors behind them with Canvas. A two-axis scroll view keeps the diagram readable when the window is smaller than its content.

This tutorial creates a synthetic seven-node diagram with neutral labels such as Node 1. Branches extend leftward from a root on the right. The concrete task is to display connected, consistently spaced cards in a resizable macOS window, with placement and drawing driven by the same geometry.

Choose a layout structure that keeps cards and lines aligned

Use a macOS deployment target of 13 or later for this implementation. Apple documents Layout as available starting in macOS 13. Its two required methods report the container’s size and place its subviews. That makes it suitable for arranging cards at calculated positions. Layout

Keep the node cards as ordinary SwiftUI views. Use a background Canvas only for connector strokes. Apple explains that individual elements drawn inside a canvas do not receive their own interactivity or accessibility, including views supplied as canvas symbols. Separate cards preserve a straightforward path to adding ordinary controls later. Canvas

The implementation has three parts: a tree describing the branches, a geometry calculation producing node centers and edges, and a view combining cards with connector drawing. Both rendering layers receive the same geometry value. Changing the tree therefore cannot update the cards while accidentally leaving an independently maintained connector array behind.

The sizing policy is deliberately fixed: cards remain the same size as the window changes, and the surrounding viewport scrolls. Apple documents that ScrollView supports horizontal scrolling, vertical scrolling, or both; it does not provide zooming. ScrollView

Calculate branch positions from leaf spacing

Start with the leaves, the nodes that have no children. Assign each leaf a successive vertical slot. Then place each parent halfway between its first and last child. Horizontal position depends on depth: the root occupies the rightmost column, and each additional depth moves one column left.

For this example, use cards measuring 132 by 44 points, a horizontal gap of 64 points, and a vertical gap of 28 points. These are illustrative design inputs. With three columns and four leaves, the resulting content measures 524 by 260 points before outer padding.

Quantity Calculation Result
Column step Card width + horizontal gap 196 points
Leaf step Card height + vertical gap 72 points
Content width 3 × 132 + 2 × 64 524 points
Content height 4 × 44 + 3 × 28 260 points

This gives the four leaf centers vertical coordinates of 22, 94, 166, and 238. Their immediate parents sit at 58 and 202, and the root sits at 130. These coordinates provide a useful diagnostic: a parent should be centered between its outer child centers, and every card should fit inside the reported content rectangle.

The algorithm below also accepts uneven branch depths. A shallow leaf stays in its depth column rather than moving automatically to the far-left column. That is an intentional rule of this example. Decide whether depth alignment or leaf alignment suits your diagram before changing the coordinate formula.

Build the complete macOS example

Use an Xcode macOS SwiftUI app target with a deployment target of macOS 13 or later. Replace its generated app entry file with the following three code blocks, in order, in one Swift file. Together they form the complete program. Remove the old app declaration if it is in another file: Apple requires exactly one app entry point. App

Define the tree and its geometry

Each node has an integer identifier and an array of children. The geometry builder visits children before calculating their parent’s center, collecting both node placements and edges. A repeated identifier triggers a precondition failure instead of producing an ambiguous view collection.

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

The model always contains a root, so even a tree without children produces one card with a nonzero content size. There is no separate empty-diagram case in this program. For an application that permits no root, handle that condition explicitly before constructing this geometry.

Place cards using a custom layout value

Pass each card’s center through a LayoutValueKey. Apple’s documented pattern is to set a value with layoutValue(key:value:), then read it from the subview proxy using the key. This associates the position with its card rather than relying on parallel array indexes. 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
            )
        }
    }
}

The layout returns the same finite size for every proposal because its content is inflexible. Apple explicitly demonstrates that an inflexible layout may ignore the proposal. SwiftUI can call the sizing method several times with different proposals during a layout pass, so this method simply returns geometry; it performs no model updates. sizeThatFits(proposal:subviews:cache:)

Placement adds bounds.minX and bounds.minY to each local center. The supplied bounds are expressed in the parent’s coordinate space, so assuming a zero origin would break placement when the container moves. Apple also requires sizing and placement calculations to agree about spacing and dimensions. placeSubviews(in:proposal:subviews:cache:)

Using .center makes the supplied point represent the middle of each card. Each proxy is placed exactly once, inside the placement method, as required by the subview placement API. The explicit card frames in the next block ensure that the displayed cards match the geometry’s dimensions. place(at:anchor:proposal:)

Draw connectors and present the window

Each connector starts at the child card’s right edge and ends at the parent card’s left edge. Two intermediate points create a right-angled route through the middle of the gap. Path.addLines(_:) creates connected straight segments from an array of points. 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 receives identifiable node values, matching Apple’s requirement for either identifiable elements or an explicit identifier argument. Keep identifiers attached to logical nodes if you later make the tree editable. ForEach

The canvas is attached directly to the layout’s background, so its local coordinates match the geometry used for cards. Outer padding is applied after the diagram is assembled and moves both layers together. Shared portions of sibling connectors overlap to form a bracket-shaped junction.

WindowGroup supplies the window’s view hierarchy and standard macOS window management behavior. The sample holds immutable synthetic geometry in its app structure; it does not install timers or lifecycle callbacks to reposition views. WindowGroup

Verify the diagram and diagnose common problems

The arithmetic for this sample was checked with a small deterministic Python calculation: seven centers, six edges, a 524-by-260-point content rectangle, and every card inside that rectangle. The SwiftUI program was inspected but could not be compiled in the research environment because no Swift compiler was available.

When you build the app in Xcode, check these observable results:

  1. Four cards appear in the leftmost column, two in the middle, and one on the right.
  2. Each connector touches the horizontal midpoint of the corresponding card edge.
  3. Each parent sits vertically between its outer children.
  4. Resizing the window preserves card dimensions and exposes scrolling when needed.
  5. Replacing the sample root with BranchNode(1) displays one card without connectors.

If cards and lines are offset, inspect modifier placement first. Padding or offsets applied only to the cards introduce a second coordinate adjustment that the connector geometry does not share. Keep diagram-wide spacing outside BranchDiagram, as shown.

If a card appears unexpectedly at the layout’s origin, check that its final view has the DiagramCenterKey value. The key’s default is .zero; a missing assignment therefore places the card center at the origin. If the program stops at the identifier precondition, give every node a distinct identifier before rendering.

Fixed card dimensions suit the short synthetic labels in this example. For longer text or larger fonts, introduce a measurement phase: ask subview proxies for their sizes, calculate column widths and subtree spacing from those measurements, and use those same results for connector endpoints. A size proposal alone does not force a subview to accept that size. place(at:anchor:proposal:)

This algorithm describes a tree: each displayed child belongs to one parent. It does not route arbitrary cross-links or represent a shared node under multiple parents. Those requirements need a different geometry model and an explicit routing policy. Likewise, animation would need connector geometry to move in step with card positions; this example provides static placement.

Keep one geometry calculation responsible for content size, card centers, and edge endpoints. That gives you a concrete place to adjust spacing, inspect coordinates, and extend the diagram while preserving agreement between SwiftUI layout and drawing.

Sources