对于一个新项目,我们需要用 SwiftUI 来绘制树形图。在本文中,我们将一步一步向您展示如何使用 SwiftUI 的 preference
功能,以最少的代码绘制简洁可交互的树形图。
我们的树在当前节点和所有子节点上都有值:
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)
])
])
第一步,我们可以递归地绘制树的节点:对于每棵树,我们创建一个包含当前节点和子节点的 VStack
视图,并使用 HStack
视图来绘制其所有子节点。我们要求每个节点元素都是可识别的,以便和 ForEach
方法一起使用。另外,我们还需要一个函数,将节点值转换为视图,正好 Tree
的节点值和子节点值都是相同的泛型:
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
协议。与其让 非我们创建的 Int
类型 遵守 Identifiable
协议,不如把 Int
类型包装到一个遵守了 Identifiable
协议的对象中。当我们以后要修改 Tree
时,这将非常有用。因为可以识别出每个元素,所以我们可以精确地对任何元素做动画。下面是我们用到的极其简单的包装器类:
class Unique<A>: Identifiable {
let value: A
init(_ value: A) { self.value = value }
}
为了把我们的 Tree<Int>
转换为 Tree<Unique<Int>>
类型,我们为 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)
现在,我们可以创建图表视图,并渲染第一棵树:
struct ContentView: View {
@State var tree = uniqueTree
var body: some View {
DiagramSimple(tree: tree, node: { value in
Text("\(value.value)")
})
}
}
它看起来十分简单:
2019-12-17-tree01-7e3021e9.png为了给节点添加一些样式,我们创建一个 ViewModifier
,将每个元素视图包装到一个固定的大小中,添加一个带有黑色边框的白色圆圈作为背景,并在内容周围添加边距:
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())
})
}
}
这下看起来好多了:
2019-12-17-tree02-fe72fffa.png但是,我们仍然缺少节点之间的边缘,因此很难看到连接了哪些节点。要绘制这些线条,需要使用布局系统,收集所有节点的中心点,然后从每个节点的中心点到子节点的中心点画线。
为了收集所有中心点,我们使用 SwiftUI 的 preference system。preference
是一种在视图层级之间传值通信的机制。视图树中的任何子视图都可以定义它的 preference
,并且任何父视图都可以读取该 preference
。
首先,我们定义一个新的 PreferenceKey
来存储字典。PreferenceKey
协议有两个要求:1. 提供一个默认值,如果子树未定义 preference,则使用默认值;2.实现一个 reduce
方法,用于结合多个视图子树中的 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 })
}
}
在我们的实现中,默认值是一个空字典,reduce
方法将多个字典合并为一个字典。
有了 preference,我们可以使用 .anchorPreference
方法在视图树上传递锚点。使用我们刚创建的 CollectDict
作为一个 preference key,我们必须指定 Key
是节点的标识符,Value
是 Anchor<CGPoint>
(稍后会在另一个视图坐标系统中解析为 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)
})
}
}
}
}
现在我们使用 backgroundPreferenceValue
来读取当前树上所有节点的中心点。使用 GeometryReader
来将 Anchor<CGPoint>
解析为 CGPoint
,遍历所有子节点,然后从当前的树节点中心到子节点的中心画一条线:
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
是绝对坐标系中的点,将这两个点都添加到属性 animatableData
中,为了将这两个点做动画效果,animatableData
必须遵守 VectorArithmetic
协议(完整代码参见文末链接)。
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)
}
}
}
基于以上的所有机制,我们最终可以使用 Diagram
视图并且绘制带有边缘的树形图:
struct ContentView: View {
@State var tree = uniqueTree
var body: some View {
Diagram(tree: tree, node: { value in
Text("\(value.value)")
.modifier(RoundedCircleStyle())
})
}
}
2019-12-17-tree03-f5f77847.png
更有趣的是,我们的树还支持动画,因为我们将每个元素都包装在 Unique
对象中,所以我们可以在不同状态之间进行动画处理。例如:当我们插入一个新数字时,SwiftUI可以动画该插入操作(代码请参见文末链接):
我们也使用了这中技术来绘制不同类型的图。对于即将到来的项目,我们希望可视化 SwiftUI 的视图层级的树形结构图。通过使用 Mirror
我们可以获取到视图 body
属性的类型,看起来像这样:
VStack<TupleView<(ModifiedContent<ModifiedContent<ModifiedContent<Button<Text>,_PaddingLayout>,_BackgroundModifier<Color>>,_ClipEffect<RoundedRectangle>>,_ConditionalContent<Text, Text>)>>
然后,我们将其解析为 Tree<String>
,对其进行略微简化,并使用上方的 Diagram
对其可视化:
使用 SwiftUI 内置的功能,如 形状、渐变和一些修改器,我们可以用极少的代码绘制以上树形图。而且,也非常容易实现它的交互操作:将每个节点包装到 Button
中,或者在节点内部添加其它控件。我们在演示文稿中一直在使用它,以生成静态图表并快速可视化事物。
如果你想自己尝试一下,欢迎查看 本文树形图 和 画 SwiftUI 的视图层级的树形结构图 的完整代码。
网友评论