美文网首页SwiftUI
用 SwiftUI 绘制树形图

用 SwiftUI 绘制树形图

作者: plantseeds | 来源:发表于2020-01-01 22:18 被阅读0次

    翻译自:Drawing Trees in SwiftUI

    对于一个新项目,我们需要用 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 systempreference 是一种在视图层级之间传值通信的机制。视图树中的任何子视图都可以定义它的 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 是节点的标识符,ValueAnchor<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,它的属性 fromto 是绝对坐标系中的点,将这两个点都添加到属性 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可以动画该插入操作(代码请参见文末链接):

    animatable.gif

    我们也使用了这中技术来绘制不同类型的图。对于即将到来的项目,我们希望可视化 SwiftUI 的视图层级的树形结构图。通过使用 Mirror 我们可以获取到视图 body 属性的类型,看起来像这样:

    VStack<TupleView<(ModifiedContent<ModifiedContent<ModifiedContent<Button<Text>,_PaddingLayout>,_BackgroundModifier<Color>>,_ClipEffect<RoundedRectangle>>,_ConditionalContent<Text, Text>)>>
    

    然后,我们将其解析为 Tree<String>,对其进行略微简化,并使用上方的 Diagram 对其可视化:

    2019-12-17-tree05-4947beaa.png

    使用 SwiftUI 内置的功能,如 形状、渐变和一些修改器,我们可以用极少的代码绘制以上树形图。而且,也非常容易实现它的交互操作:将每个节点包装到 Button 中,或者在节点内部添加其它控件。我们在演示文稿中一直在使用它,以生成静态图表并快速可视化事物。

    如果你想自己尝试一下,欢迎查看 本文树形图画 SwiftUI 的视图层级的树形结构图 的完整代码。

    相关文章

      网友评论

        本文标题:用 SwiftUI 绘制树形图

        本文链接:https://www.haomeiwen.com/subject/bmngoctx.html