在 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 時,請確認以下可觀察的結果:
- 最左欄有四張卡片,中間欄有兩張,右欄有一張。
- 每條連接線都會接觸對應卡片邊緣的水平中點。
- 每個父節點都位於最外側兩個子節點之間的垂直位置。
- 調整視窗大小時,卡片尺寸維持不變,必要時可捲動檢視。
- 將範例根節點替換成
BranchNode(1)後,會顯示一張沒有連接線的卡片。
若卡片和連接線位置偏移,請先檢查修飾子的套用位置。若只對卡片套用內距或偏移,就會引入連接線幾何資料未共用的第二種座標調整。請將整個圖表的間距設定在 BranchDiagram 外,就像範例所示。
若卡片意外出現在版面原點,請確認最終檢視有設定 DiagramCenterKey 值。該索引鍵的預設值是 .zero;缺少設定時,卡片中心就會位於原點。若程式在識別碼前置條件處停止,請在繪製前為每個節點指定不同的識別碼。
固定卡片尺寸適合此範例的簡短合成標籤。若文字較長或字體較大,請加入測量階段:向子視圖代理索取尺寸,根據測量結果計算欄寬和子樹間距,並使用同一組結果設定連接線端點。僅提供尺寸提案,並不會強制子視圖接受該尺寸。place(at:anchor:proposal:)
此演算法描述的是樹:每個顯示的子節點都隸屬於一個父節點。它不會為任意交叉連結安排路徑,也不會將同一節點表示為多個父節點的子節點。這些需求需要不同的幾何模型和明確的路徑規則。同樣地,動畫需要讓連接線幾何與卡片位置同步移動;此範例採用靜態位置。
請讓同一個幾何計算負責內容尺寸、卡片中心和連接線端點。如此一來,你就有明確的位置可以調整間距、檢查座標和擴充圖表,同時維持 SwiftUI 版面配置與繪圖的一致性。
來源
- Layout — Apple
- Canvas — Apple
- ScrollView — Apple
- App — Apple
- LayoutValueKey — Apple
- sizeThatFits(proposal:subviews:cache:) — Apple
- placeSubviews(in:proposal:subviews:cache:) — Apple
- place(at:anchor:proposal:) — Apple
- addLines(_:) — Apple
- ForEach — Apple
- WindowGroup — Apple