用 SwiftUI 绘制树形图

翻译自: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 的视图层级的树形结构图 的完整代码。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,014评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,796评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,484评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,830评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,946评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,114评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,182评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,927评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,369评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,678评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,832评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,533评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,166评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,885评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,128评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,659评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,738评论 2 351