用 SwiftUI 繪制樹形圖

翻譯自:Drawing Trees in SwiftUI

對于一個新項目,我們需要用 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)")
        })
    }
}

它看起來十分簡單:

2019-12-17-tree01-7e3021e9.png

為了給節(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())
        })
    }
}

這下看起來好多了:

2019-12-17-tree02-fe72fffa.png

但是,我們?nèi)匀蝗鄙俟?jié)點之間的邊緣,因此很難看到連接了哪些節(jié)點。要繪制這些線條,需要使用布局系統(tǒng),收集所有節(jié)點的中心點,然后從每個節(jié)點的中心點到子節(jié)點的中心點畫線。

為了收集所有中心點,我們使用 SwiftUI 的 preference systempreference 是一種在視圖層級之間傳值通信的機(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)識符,ValueAnchor<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,它的屬性 fromto 是絕對坐標(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())
        })
    }
}
2019-12-17-tree03-f5f77847.png

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

animatable.gif

我們也使用了這中技術(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 對其可視化:

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

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

如果你想自己嘗試一下,歡迎查看 本文樹形圖畫 SwiftUI 的視圖層級的樹形結(jié)構(gòu)圖 的完整代碼。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容