ノードの中心座標を一度だけ計算し、カスタムSwiftUI Layoutでカードを配置し、Canvasで背面にコネクタを描画して、Macアプリに分岐するブラケット図を作成します。縦横両方向にスクロールできるスクロールビューを使うと、ウィンドウがコンテンツより小さくても図を読みやすく保てます。

このチュートリアルでは、Node 1のような中立的なラベルを付けた、7ノードのサンプル図を作成します。右側のルートから左向きに枝が伸びます。具体的な目標は、配置と描画で同じジオメトリを使い、サイズ変更可能なmacOSウィンドウに、間隔をそろえて接続されたカードを表示することです。

カードと線の位置がずれないレイアウト構造を選ぶ

この実装では、macOS 13以降をデプロイメントターゲットに設定します。Appleのドキュメントによると、LayoutはmacOS 13から利用できます。必須の2つのメソッドで、コンテナのサイズを報告し、そのサブビューを配置します。そのため、計算した位置にカードを並べるのに適しています。Layout

ノードカードは通常のSwiftUIビューとして保ちます。コネクタの線だけを背景のCanvasで描画します。Appleは、キャンバス内に描画される個々の要素には、キャンバスのシンボルとして渡されたビューも含め、独自のインタラクティブ機能やアクセシビリティがないと説明しています。カードを分けておけば、後から通常のコントロールを追加しやすくなります。Canvas

実装は3つの部分で構成されます。枝を記述するツリー、ノードの中心座標と辺を生成するジオメトリ計算、そしてカードとコネクタの描画を組み合わせるビューです。描画を担う両方のレイヤーに、同じジオメトリ値を渡します。これにより、ツリーを変更したときにカードだけ更新され、別管理のコネクタ配列が取り残されることはありません。

サイズの方針は意図的に固定しています。ウィンドウのサイズが変わってもカードの大きさは変わらず、周囲のビューポートをスクロールします。Appleのドキュメントによると、ScrollViewは水平方向、垂直方向、または両方向のスクロールに対応していますが、ズーム機能はありません。ScrollView

葉の間隔から枝の位置を計算する

まず、子を持たないノードである葉から配置します。各葉に、順番に縦方向のスロットを割り当てます。次に、各親の中心を最初の子と最後の子の中間に置きます。横方向の位置は深さで決まります。ルートは最も右の列に置き、深さが1つ増えるごとに列を1つ左へ移動します。

この例では、カードのサイズを132×44ポイント、横の間隔を64ポイント、縦の間隔を28ポイントとします。これらは説明用の設計値です。3列と4つの葉を使うと、外側のパディングを加える前のコンテンツサイズは524×260ポイントになります。

項目 計算 結果
列の間隔 カード幅 + 横の間隔 196ポイント
葉の間隔 カード高さ + 縦の間隔 72ポイント
コンテンツ幅 3 × 132 + 2 × 64 524ポイント
コンテンツ高さ 4 × 44 + 3 × 28 260ポイント

これにより、4つの葉の中心の縦座標は22、94、166、238になります。その直上にある親の座標は58と202、ルートの座標は130です。これらは便利な確認用の値です。親は外側の子の中心の間に配置され、すべてのカードが報告されたコンテンツ領域内に収まる必要があります。

以下のアルゴリズムは、枝ごとに深さが異なる場合にも対応します。浅い葉は自動的に最左列へ移動せず、自分の深さに対応する列にとどまります。これはこの例で意図したルールです。座標計算式を変更する前に、深さをそろえる方法と葉をそろえる方法のどちらが図に適しているかを決めてください。

macOSのサンプル全体を作成する

XcodeのmacOS SwiftUIアプリターゲットを使い、デプロイメントターゲットをmacOS 13以降に設定します。生成されたアプリのエントリーファイルを、次の3つのコードブロックで順番に置き換え、1つのSwiftファイルにまとめます。これらを合わせると、プログラム全体になります。アプリ宣言が別ファイルにある場合は、古い宣言を削除してください。Appleの要件では、アプリのエントリーポイントは1つだけです。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)
    }
}

モデルには常にルートが含まれるため、子のないツリーでもコンテンツサイズがゼロ以外のカードが1つ表示されます。このプログラムには、図が空の場合の処理はありません。ルートを持たない状態を許容するアプリでは、このジオメトリを構築する前に、その状態を明示的に処理してください。

カスタムレイアウト値でカードを配置する

各カードの中心を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は1回のレイアウト処理中に、異なる提案サイズでサイズ計算メソッドを何度も呼び出すことがあります。そのため、このメソッドはジオメトリを返すだけで、モデルを更新しません。sizeThatFits(proposal:subviews:cache:)

配置では、各ローカル中心座標にbounds.minXとbounds.minYを加算します。渡される境界は親の座標空間で表されるため、原点がゼロだと仮定すると、コンテナが移動したときに配置が崩れます。Appleは、サイズ計算と配置計算で、間隔と寸法の扱いを一致させることも求めています。placeSubviews(in:proposal:subviews:cache:)

.centerを使うと、渡す点をカードの中央として扱えます。サブビュー配置APIの要件に従い、各プロキシは配置メソッド内で一度だけ配置します。次のブロックで明示的に指定するカードのフレームによって、表示されるカードの寸法をジオメトリに一致させます。place(at:anchor:proposal:)

コネクタを描画してウィンドウを表示する

各コネクタは子カードの右端から始まり、親カードの左端で終わります。中間に2つの点を置くことで、間隔の中央を通る直角の経路を作ります。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標準のウィンドウ管理動作を提供します。このサンプルでは、不変のサンプル用ジオメトリをアプリ構造体に保持しています。ビューの位置を調整するためのタイマーやライフサイクルコールバックは設定していません。WindowGroup

図を確認し、よくある問題を診断する

このサンプルの計算は、小さな決定論的Python計算で検証しました。結果は、中心座標が7つ、辺が6本、コンテンツ領域が524×260ポイントで、すべてのカードが領域内に収まるというものです。調査環境にSwiftコンパイラがなかったため、SwiftUIプログラムは確認しましたがコンパイルできていません。

Xcodeでアプリをビルドしたら、次の結果を確認してください。

  1. 最左列にカードが4つ、中央列に2つ、右列に1つ表示される。
  2. 各コネクタが、対応するカードの辺の横方向の中央に接続する。
  3. 各親が、外側にある子の間に縦方向に配置される。
  4. ウィンドウをリサイズしてもカードの寸法は変わらず、必要に応じてスクロールできる。
  5. サンプルのルートをBranchNode(1)に置き換えると、コネクタのないカードが1つ表示される。

カードと線の位置がずれている場合は、まずモディファイアの適用位置を確認してください。カードだけにパディングやオフセットを適用すると、コネクタのジオメトリと共有されない座標調整がもう1つ加わります。図全体の間隔は、例のようにBranchDiagramの外側に置いてください。

カードがレイアウトの原点に予期せず表示される場合は、最終的なビューにDiagramCenterKeyの値が設定されているか確認してください。このキーの既定値は.zeroです。値を設定し忘れると、カードの中心は原点に配置されます。識別子の事前条件でプログラムが停止する場合は、描画前にすべてのノードに異なる識別子を付けてください。

固定サイズのカードは、この例の短いサンプルラベルに適しています。長いテキストや大きなフォントを使う場合は、計測段階を追加してください。サブビュープロキシにサイズを問い合わせ、その測定値から列幅とサブツリー間隔を計算し、コネクタの端点にも同じ値を使います。サイズ提案だけでは、サブビューがそのサイズを受け入れるとは限りません。place(at:anchor:proposal:)

このアルゴリズムが扱うのはツリーです。表示される各子は1つの親に属します。任意の交差リンクの経路設定や、複数の親の下で共有されるノードの表現には対応していません。そうした要件には、異なるジオメトリモデルと、明示的な経路設定ルールが必要です。同様に、アニメーションを加える場合は、カードの位置に合わせてコネクタのジオメトリも動かす必要があります。この例の配置は静的です。

コンテンツサイズ、カードの中心座標、辺の端点を決めるジオメトリ計算は1つにまとめます。そうすることで、間隔を調整し、座標を確認し、SwiftUIのレイアウトと描画の整合性を保ちながら図を拡張できる、具体的な場所ができます。

参考資料