對于一個新項目,我們需要用 SwiftUI 來繪制樹形圖。在本文中,我們將一步一步向您展示如何使用 SwiftUI 的 preference 功能,以最少的代碼繪制簡潔可交互的樹形圖。
我們的樹在當(dāng)前節(jié)點和所有子節(jié)點上都有值:
struct Tree<A> {
var value: A
var children: [Tree<A>] = []
init(_ value: A, children: [Tree<A>] = []) {
self.value = value
self.children = children
}
}
例如,這是一個 Int類型 的簡單二叉樹:
let binaryTree = Tree<Int>(50, children: [
Tree(17, children: [
Tree(12),
Tree(23)
]),
Tree(72, children: [
Tree(54),
Tree(72)
])
])
第一步,我們可以遞歸地繪制樹的節(jié)點:對于每棵樹,我們創(chuàng)建一個包含當(dāng)前節(jié)點和子節(jié)點的 VStack 視圖,并使用 HStack 視圖來繪制其所有子節(jié)點。我們要求每個節(jié)點元素都是可識別的,以便和 ForEach 方法一起使用。另外,我們還需要一個函數(shù),將節(jié)點值轉(zhuǎn)換為視圖,正好 Tree 的節(jié)點值和子節(jié)點值都是相同的泛型:
struct DiagramSimple<A: Identifiable, V: View>: View {
let tree: Tree<A>
let node: (A) -> V
var body: some View {
return VStack(alignment: .center) {
node(tree.value)
HStack(alignment: .bottom, spacing: 10) {
ForEach(tree.children, id: \.value.id, content: { child in
DiagramSimple(tree: child, node: self.node)
})
}
}
}
}
在繪制樹形圖之前,還有一個問題待解決:binaryTree 中的 Int 類型并不遵守 Identifiable 協(xié)議。與其讓 非我們創(chuàng)建的 Int 類型 遵守 Identifiable 協(xié)議,不如把 Int 類型包裝到一個遵守了 Identifiable 協(xié)議的對象中。當(dāng)我們以后要修改 Tree 時,這將非常有用。因為可以識別出每個元素,所以我們可以精確地對任何元素做動畫。下面是我們用到的極其簡單的包裝器類:
class Unique<A>: Identifiable {
let value: A
init(_ value: A) { self.value = value }
}
為了把我們的 Tree<Int> 轉(zhuǎn)換為 Tree<Unique<Int>> 類型,我們?yōu)?Tree 添加一個 map 方法,用它來將 Int 包裝到 Unique 對象中:
extension Tree {
func map<B>(_ transform: (A) -> B) -> Tree<B> {
Tree<B>(transform(value), children: children.map { $0.map(transform) })
}
}
let uniqueTree: Tree<Unique<Int>> = binaryTree.map(Unique.init)
現(xiàn)在,我們可以創(chuàng)建圖表視圖,并渲染第一棵樹:
struct ContentView: View {
@State var tree = uniqueTree
var body: some View {
DiagramSimple(tree: tree, node: { value in
Text("\(value.value)")
})
}
}
它看起來十分簡單:

為了給節(jié)點添加一些樣式,我們創(chuàng)建一個 ViewModifier,將每個元素視圖包裝到一個固定的大小中,添加一個帶有黑色邊框的白色圓圈作為背景,并在內(nèi)容周圍添加邊距:
struct RoundedCircleStyle: ViewModifier {
func body(content: Content) -> some View {
content
.frame(width: 50, height: 50)
.background(Circle().stroke())
.background(Circle().fill(Color.white))
.padding(10)
}
}
使用這個 ViewModifier 來改變我們的 ContentView:
struct ContentView: View {
@State var tree: Tree<Unique<Int>> = binaryTree.map(Unique.init)
var body: some View {
DiagramSimple(tree: tree, node: { value in
Text("\(value.value)")
.modifier(RoundedCircleStyle())
})
}
}
這下看起來好多了:

但是,我們?nèi)匀蝗鄙俟?jié)點之間的邊緣,因此很難看到連接了哪些節(jié)點。要繪制這些線條,需要使用布局系統(tǒng),收集所有節(jié)點的中心點,然后從每個節(jié)點的中心點到子節(jié)點的中心點畫線。
為了收集所有中心點,我們使用 SwiftUI 的 preference system。preference 是一種在視圖層級之間傳值通信的機(jī)制。視圖樹中的任何子視圖都可以定義它的 preference,并且任何父視圖都可以讀取該 preference。
首先,我們定義一個新的 PreferenceKey 來存儲字典。PreferenceKey 協(xié)議有兩個要求:1. 提供一個默認(rèn)值,如果子樹未定義 preference,則使用默認(rèn)值;2.實現(xiàn)一個 reduce 方法,用于結(jié)合多個視圖子樹中的 preference 值,收集其中心點。
struct CollectDict<Key: Hashable, Value>: PreferenceKey {
static var defaultValue: [Key:Value] { [:] }
static func reduce(value: inout [Key:Value], nextValue: () -> [Key:Value]) {
value.merge(nextValue(), uniquingKeysWith: { $1 })
}
}
在我們的實現(xiàn)中,默認(rèn)值是一個空字典,reduce 方法將多個字典合并為一個字典。
有了 preference,我們可以使用 .anchorPreference 方法在視圖樹上傳遞錨點。使用我們剛創(chuàng)建的 CollectDict 作為一個 preference key,我們必須指定 Key 是節(jié)點的標(biāo)識符,Value 是 Anchor<CGPoint>(稍后會在另一個視圖坐標(biāo)系統(tǒng)中解析為 CGPoint):
struct Diagram<A: Identifiable, V: View>: View {
let tree: Tree<A>
let node: (A) -> V
typealias Key = CollectDict<A.ID, Anchor<CGPoint>>
var body: some View {
return VStack(alignment: .center) {
node(tree.value)
.anchorPreference(key: Key.self, value: .center, transform: {
[self.tree.value.id: $0]
})
HStack(alignment: .bottom, spacing: 10) {
ForEach(tree.children, id: \.value.id, content: { child in
Diagram(tree: child, node: self.node)
})
}
}
}
}
現(xiàn)在我們使用 backgroundPreferenceValue 來讀取當(dāng)前樹上所有節(jié)點的中心點。使用 GeometryReader 來將 Anchor<CGPoint> 解析為 CGPoint,遍歷所有子節(jié)點,然后從當(dāng)前的樹節(jié)點中心到子節(jié)點的中心畫一條線:
struct Diagram<A: Identifiable, V: View>: View {
// ...
var body: some View {
VStack(alignment: .center) {
// ...
}.backgroundPreferenceValue(Key.self, { (centers: [A.ID: Anchor<CGPoint>]) in
GeometryReader { proxy in
ForEach(self.tree.children, id: \.value.id, content: { child in
Line(
from: proxy[centers[self.tree.value.id]!],
to: proxy[centers[child.value.id]!]
).stroke()
})
}
})
}
}
Line 是一個自定義的 Shape,它的屬性 from 和 to 是絕對坐標(biāo)系中的點,將這兩個點都添加到屬性 animatableData 中,為了將這兩個點做動畫效果,animatableData 必須遵守 VectorArithmetic 協(xié)議(完整代碼參見文末鏈接)。
struct Line: Shape {
var from: CGPoint
var to: CGPoint
var animatableData: AnimatablePair<CGPoint, CGPoint> {
get { AnimatablePair(from, to) }
set {
from = newValue.first
to = newValue.second
}
}
func path(in rect: CGRect) -> Path {
Path { p in
p.move(to: self.from)
p.addLine(to: self.to)
}
}
}
基于以上的所有機(jī)制,我們最終可以使用 Diagram 視圖并且繪制帶有邊緣的樹形圖:
struct ContentView: View {
@State var tree = uniqueTree
var body: some View {
Diagram(tree: tree, node: { value in
Text("\(value.value)")
.modifier(RoundedCircleStyle())
})
}
}

更有趣的是,我們的樹還支持動畫,因為我們將每個元素都包裝在 Unique 對象中,所以我們可以在不同狀態(tài)之間進(jìn)行動畫處理。例如:當(dāng)我們插入一個新數(shù)字時,SwiftUI可以動畫該插入操作(代碼請參見文末鏈接):

我們也使用了這中技術(shù)來繪制不同類型的圖。對于即將到來的項目,我們希望可視化 SwiftUI 的視圖層級的樹形結(jié)構(gòu)圖。通過使用 Mirror 我們可以獲取到視圖 body 屬性的類型,看起來像這樣:
VStack<TupleView<(ModifiedContent<ModifiedContent<ModifiedContent<Button<Text>,_PaddingLayout>,_BackgroundModifier<Color>>,_ClipEffect<RoundedRectangle>>,_ConditionalContent<Text, Text>)>>
然后,我們將其解析為 Tree<String>,對其進(jìn)行略微簡化,并使用上方的 Diagram 對其可視化:

使用 SwiftUI 內(nèi)置的功能,如 形狀、漸變和一些修改器,我們可以用極少的代碼繪制以上樹形圖。而且,也非常容易實現(xiàn)它的交互操作:將每個節(jié)點包裝到 Button 中,或者在節(jié)點內(nèi)部添加其它控件。我們在演示文稿中一直在使用它,以生成靜態(tài)圖表并快速可視化事物。
如果你想自己嘗試一下,歡迎查看 本文樹形圖 和 畫 SwiftUI 的視圖層級的樹形結(jié)構(gòu)圖 的完整代碼。