在 Mac App 中建立分支括號圖:只計算一次節點中心,使用自訂 SwiftUI Layout 放置卡片,並透過 Canvas 在卡片後方繪製連接線。雙軸捲動檢視可在視窗小於內容時,讓圖表仍清晰易讀。

本教學會建立一個由七個節點組成的合成圖表,使用 Node 1 等中性標籤。分支從右側的根節點向左延伸。具體目標是在可調整大小的 macOS 視窗中顯示彼此連接、間距一致的卡片,並由同一份幾何資料控制卡片位置與連接線繪製。

選擇能讓卡片與連接線對齊的版面結構

此實作的 macOS 部署目標為 13 或更新版本。Apple 文件指出,Layout 自 macOS 13 起提供。它的兩個必要方法會回報容器大小並放置子視圖,因此適合用來依計算位置排列卡片。Layout

節點卡片維持為一般 SwiftUI 檢視。只使用背景 Canvas 繪製連接線。Apple 說明,在畫布內繪製的個別元素不具備自己的互動或無障礙功能,包含作為畫布符號提供的檢視。將卡片分開,可讓日後新增一般控制項的方式更直接。Canvas

此實作分為三個部分:描述分支的樹狀結構、產生節點中心與邊的幾何計算,以及結合卡片和連接線繪製的檢視。兩個繪製層都會使用同一份幾何資料。因此,變更樹狀結構時,不會發生卡片更新了,卻意外遺漏了獨立維護的連接線陣列。

尺寸策略刻意採用固定值:卡片大小不隨視窗變化,周圍的檢視區則可捲動。Apple 文件指出,ScrollView 支援水平、垂直或雙向捲動,但不提供縮放功能。ScrollView

根據葉節點間距計算分支位置

從葉節點開始,也就是沒有子節點的節點。依序為每個葉節點指派一個垂直位置。接著將每個父節點放在其第一個和最後一個子節點的正中間。水平位置取決於深度:根節點位於最右側欄位,每增加一層深度就向左移一欄。

此範例使用尺寸為 132 × 44 點的卡片、64 點的水平間距,以及 28 點的垂直間距。這些是示意用的設計參數。若有三欄和四個葉節點,外側留白前的內容尺寸為 524 × 260 點。

項目 計算方式 結果
欄位間距 卡片寬度 + 水平間距 196 點
葉節點間距 卡片高度 + 垂直間距 72 點
內容寬度 3 × 132 + 2 × 64 524 點
內容高度 4 × 44 + 3 × 28 260 點

四個葉節點中心的垂直座標依序為 22、94、166 和 238。它們的直接父節點位於 58 和 202,根節點則位於 130。這些座標可用來檢查結果:父節點應位於最外側兩個子節點中心的正中間,且每張卡片都應落在回報的內容矩形範圍內。

以下演算法也接受深度不均的分支。較淺的葉節點會留在自己的深度欄位,不會自動移到最左側欄位。這是此範例刻意採用的規則。修改座標公式前,請先決定圖表適合依深度還是依葉節點對齊。

建立完整的 macOS 範例

建立一個 Xcode macOS SwiftUI App 目標,部署目標設為 macOS 13 或更新版本。依序將以下三個程式碼區塊放入同一個 Swift 檔案,取代產生的 App 進入點檔案。這些區塊合在一起就是完整程式。若舊的 App 宣告位於另一個檔案,請移除它:Apple 規定 App 進入點只能有一個。App

定義樹狀結構與幾何資料

每個節點都有一個整數識別碼和一個子節點陣列。幾何建構器會先走訪子節點,再計算父節點中心,同時收集節點位置和連接邊。若識別碼重複,程式會觸發前置條件失敗,而不是產生含糊不清的檢視集合。

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

此模型一律包含根節點,因此即使樹中沒有子節點,也會產生一張卡片和非零的內容尺寸。此程式沒有另外處理空圖表的情況。若應用程式允許沒有根節點,請在建構此幾何資料前明確處理該情況。

使用自訂版面值放置卡片

透過 LayoutValueKey 傳遞每張卡片的中心點。Apple 文件示範的模式是使用 layoutValue(key:value:) 設定值,再透過子視圖代理以該索引鍵讀取。這會將位置與卡片關聯,而不必依賴平行陣列的索引。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
            )
        }
    }
}

由於內容不可伸縮,無論收到什麼提案,版面都會回傳相同的有限尺寸。Apple 明確示範過,不可伸縮的版面可以忽略提案。SwiftUI 在一次版面配置期間,可能會使用不同提案多次呼叫尺寸計算方法,因此此方法只回傳幾何資料,不會更新模型。sizeThatFits(proposal:subviews:cache:)

放置時,會將 bounds.minX 和 bounds.minY 加到每個區域中心點。提供的邊界以父檢視的座標空間表示,因此假設原點為零會在容器移動時導致位置錯誤。Apple 也要求尺寸計算與放置計算對間距和尺寸的處理保持一致。placeSubviews(in:proposal:subviews:cache:)

使用 .center 可讓提供的點代表每張卡片的中心。每個代理都會在放置方法內依子視圖放置 API 的要求放置一次。下一個區塊中的明確卡片框架,會確保顯示的卡片尺寸與幾何資料一致。place(at:anchor:proposal:)

繪製連接線並顯示視窗

每條連接線都從子卡片的右邊緣延伸到父卡片的左邊緣。兩個中間點會在間隙中央形成直角路徑。Path.addLines(_:) 會依點陣列建立連續的直線線段。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 接收可識別的節點值,符合 Apple 對元素必須可識別,或提供明確識別碼引數的要求。若日後要讓樹狀結構可編輯,請將識別碼維持在邏輯節點上。ForEach

畫布直接附加在版面的背景上,因此其區域座標與卡片使用的幾何資料一致。組合完成圖表後才套用外側留白,讓兩個繪製層一起移動。相鄰分支的部分連接線會重疊,形成括號形狀的交會點。

WindowGroup 提供視窗的檢視階層和標準 macOS 視窗管理行為。此範例在 App 結構中保存不可變的合成幾何資料,不會安裝計時器或生命週期回呼來重新定位檢視。WindowGroup

驗證圖表並診斷常見問題

此範例的算術已使用小型、確定性的 Python 計算檢查:七個中心、六條邊、524 × 260 點的內容矩形,以及所有卡片都在矩形範圍內。研究環境中沒有 Swift 編譯器,因此只能檢視 SwiftUI 程式,無法編譯。

在 Xcode 中建置 App 時,請確認以下可觀察的結果:

  1. 最左欄有四張卡片,中間欄有兩張,右欄有一張。
  2. 每條連接線都會接觸對應卡片邊緣的水平中點。
  3. 每個父節點都位於最外側兩個子節點之間的垂直位置。
  4. 調整視窗大小時,卡片尺寸維持不變,必要時可捲動檢視。
  5. 將範例根節點替換成 BranchNode(1) 後,會顯示一張沒有連接線的卡片。

若卡片和連接線位置偏移,請先檢查修飾子的套用位置。若只對卡片套用內距或偏移,就會引入連接線幾何資料未共用的第二種座標調整。請將整個圖表的間距設定在 BranchDiagram 外,就像範例所示。

若卡片意外出現在版面原點,請確認最終檢視有設定 DiagramCenterKey 值。該索引鍵的預設值是 .zero;缺少設定時,卡片中心就會位於原點。若程式在識別碼前置條件處停止,請在繪製前為每個節點指定不同的識別碼。

固定卡片尺寸適合此範例的簡短合成標籤。若文字較長或字體較大,請加入測量階段:向子視圖代理索取尺寸,根據測量結果計算欄寬和子樹間距,並使用同一組結果設定連接線端點。僅提供尺寸提案,並不會強制子視圖接受該尺寸。place(at:anchor:proposal:)

此演算法描述的是樹:每個顯示的子節點都隸屬於一個父節點。它不會為任意交叉連結安排路徑,也不會將同一節點表示為多個父節點的子節點。這些需求需要不同的幾何模型和明確的路徑規則。同樣地,動畫需要讓連接線幾何與卡片位置同步移動;此範例採用靜態位置。

請讓同一個幾何計算負責內容尺寸、卡片中心和連接線端點。如此一來,你就有明確的位置可以調整間距、檢查座標和擴充圖表,同時維持 SwiftUI 版面配置與繪圖的一致性。

來源