本篇为大家带来SwiftUI中Preference的第二个实战教程,最后的实效效果如下图:
其实,用SwiftUI实现上图的二叉树还算简单,节点与节点之间的连线,需要用到Preference的知识。
定义数据结构
二叉树上的节点由两部分组成:
- 值
- 子节点
struct Tree<A>: Identifiable {
let id = UUID().uuidString
var value: A
var children: [Tree<A>] = []
init(_ value: A, children: [Tree<A>] = []) {
self.value = value
self.children = children
}
}
我们用value表示该节点的值,它是一个范型,因此该值可以显示任何类型的数据,用children表示该节点的子节点,之所以让Tree实现Identifiable,目的是后续的代码需要遍历子节点,会用到ForEach。
绘制节点
由于每个节点可能存在n多个子节点,并且子节点在父节点的下方,我们很自然的考虑使用VStack包装一个HStack,示意图如下:
struct DiagramSample<A, V: View>: View {
let tree: Tree<A>
let node: (A) -> V
var body: some View {
VStack(spacing: 10) {
node(tree.value)
HStack(alignment: .bottom, spacing: 10) {
ForEach(tree.children) { child in
DiagramSample(tree: child, node: self.node)
}
}
}
}
}
上边的代码很好理解,采用递归的方法显示各个节点。为了查看效果,我们举个🌰,我们先把节点中value的类型设为Int:Tree<Int>:
struct DiagramSampleExample: View {
let binarytree = Tree<Int>(10, children: [
Tree<Int>(20, children: [
Tree(21),
Tree(22)
]),
Tree<Int>(30, children: [
Tree(31),
Tree(32)
])
])
var body: some View {
DiagramSample(tree: binarytree, node: { value in
Text("\(value)")
.modifier(RoundedCircleStyle())
})
}
}
我们再把value的类型设置String:Tree(String):
struct DiagramSampleExample1: View {
let binarytree = Tree<String>("爷爷", children: [
Tree<String>("大爷", children: [
Tree("大侄子"),
Tree("二侄子")
]),
Tree<String>("爸爸", children: [
Tree("大儿子"),
Tree("小儿子")
])
])
var body: some View {
DiagramSample(tree: binarytree, node: { value in
Text("\(value)")
.modifier(RoundedCircleStyle())
})
}
}
完整实现
上边的内容只是一个小小的演示,接下来,我们讲解一下绘制二叉树的具体步骤,大家发现没有,绘制节点并没有用到Preference相关的知识,只有绘制节点与节点之间的连线的时候,才用到了这个技术。
基本思路如下:
- 我们需要知道每个节点的位置信息
- 把每个字节点的center同父节点连线
在本例中,我们使用AnchorPreference来获取节点的center,因此我们先定义一个PreferenceKey:
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 })
}
}
接下来是绘制图形,代码如下:
struct Diagram<A, V: View>: View {
let tree: Tree<A>
let node: (A) -> V
typealias Key = CollectDict<String, Anchor<CGPoint>>
var body: some View {
VStack(spacing: 10) {
node(tree.value)
.anchorPreference(key: Key.self, value: .center, transform: {
[self.tree.id: $0]
})
HStack(alignment: .bottom, spacing: 10) {
ForEach(tree.children) { child in
Diagram(tree: child, node: self.node)
}
}
}
.backgroundPreferenceValue(Key.self) { (centers: [String: Anchor<CGPoint>]) in
GeometryReader { proxy in
ForEach(self.tree.children) { child in
Line(from: proxy[centers[self.tree.id]!],
to: proxy[centers[child.id]!])
.stroke()
}
}
}
}
}
我们通过.anchorPreference
为节点绑定信息,这个信息是一个字典,key为tree的id,value为center。在.backgroundPreferenceValue
中为节点连线。我们在看看Line这个结构体的定义:
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 {
var path = Path()
path.move(to: from)
path.addLine(to: to)
return path
}
}
代码非常简单,就是绘制两点之间的path,我们实现了animatableData,目的是在插入数据的时候,Line的过渡效果是圆滑的,而不是瞬间改变。由于from和to的类型是CGPoint,它并没有实现VectorArithmetic协议,因此,我们需要手动实现该协议:
extension CGPoint: VectorArithmetic {
public static func - (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
}
public mutating func scale(by rhs: Double) {
x *= CGFloat(rhs)
y *= CGFloat(rhs)
}
public var magnitudeSquared: Double {
0
}
public static func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}
}
完成了上边的代码后,主要功能已经实现了,接下来,我们把这些代码组合起来:
struct DiagramExample: View {
@State private var binarytree = Tree<Int>(130, children: [
Tree<Int>(20, children: [
Tree(21),
Tree(22)
]),
Tree<Int>(30, children: [
Tree(31),
Tree(32)
])
])
var body: some View {
VStack {
Diagram(tree: binarytree, node: { value in
Text("\(value)")
.modifier(RoundedCircleStyle())
})
Button("随机插入") {
withAnimation {
self.binarytree.insert(Int.random(in: 0...100))
}
}
}
}
}
当点击随机插入的按钮后,我们为Tree随机插入一个0~100的整数,我们打算把这个二叉树做成二叉查找树,当前根节点的左边全部比根节点小,当前根节点的右边全部比根节点大。
extension Tree where A == Int {
mutating func insert(_ number: Int) {
if number < value {
if children.count > 0 {
children[0].insert(number)
} else {
children.append(Tree(number))
}
} else {
if children.count == 2 {
children[1].insert(number)
} else if children.count == 1, children[0].value > number {
children[0].insert(number)
} else {
children.append(Tree(number))
}
}
}
}
总结
Preference的用处还有很多,在接下来的一个实战中,使用该技术可以为ScrollView添加下拉刷新功能,敬请期待。
参考:https://www.objc.io/blog/2019/12/16/drawing-trees/
SwiftUI集合:FuckingSwiftUI