Blog

Drawing Trees in SwiftUI

For a new project, we need to draw tree diagrams in SwiftUI. In this post, we'll walk you through our attempts, and show how we use SwiftUI’s preference system to draw clean and interactive diagrams with minimal code.

Our trees have values at the nodes, and any number of children:

								struct Tree<A> {
    var value: A
    var children: [Tree<A>] = []
    init(_ value: A, children: [Tree<A>] = []) {
        self.value = value
        self.children = children
    }
}

							

For example, here's a simple binary tree that's Int-based:

								let binaryTree = Tree<Int>(50, children: [
    Tree(17, children: [
        Tree(12),
        Tree(23)
    ]),
    Tree(72, children: [
        Tree(54),
        Tree(72)
    ])
])

							

As a first step, we can draw the nodes of the tree recursively: for each tree, we create a VStack containing the value and its children. The children themselves are drawn using an HStack. We require that each element is identifiable so that we can use them with a ForEach. Since Tree is generic over the node values, we also need to provide a function that turns a node value into a view:

								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)
                })
            }
        }
    }
}

							

We are almost ready to draw our tree. There is one problem we still have to solve: the integers in our example binary tree do not conform to the Identifiable protocol. Rather than conforming a type we don't own (Int) to a protocol we don't own (Identifiable), we will wrap each integer in the tree in a new object that is identifiable. This will be useful when we want to modify our tree later on; by being able to uniquely identify elements we can have great animations. Here's the extremely simple wrapper class we're going to use:

								class Unique<A>: Identifiable {
    let value: A
    init(_ value: A) { self.value = value }
}

							

To transform our tree of type Tree<Int> to a tree of type Tree<Unique<Int>>, we add map to our tree type and use it to wrap each integer within a Unique object:

								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)

							

Now we're able to create the diagram view and render a first tree:

								struct ContentView: View {
    @State var tree = uniqueTree
    var body: some View {
        DiagramSimple(tree: tree, node: { value in
            Text("\(value.value)")
        })
    }
}

							

Our tree looks pretty minimalistic:

To add some styling to the nodes, we create a view modifier that wraps each element view in a frame, adds a white circle with a black stroke as background, and some padding around everything:

								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)
    }
}

							

To use this new view modifier we have to change our 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())
        })
    }
}

							

This is starting to look much better:

However, we're still missing the edges between nodes, so it's hard to see which nodes are connected. To draw these, we need to hook into the layout system. First, we need to collect the center point of each node, and then draw lines from each node's center to its children's centers.

To collect all the center points, we use SwiftUI's preference system. Preferences are the mechanism used to communicate values up the view tree, from children to their ancestors. Any child in the view tree can define a preference, and any ancestor can read that preference.

As a first step, we'll define a new PreferenceKey that stores a dictionary. The PreferenceKey protocol has two requirements: a default value, in case a subtree doesn't define a preference, and a reduce method, that is used to combine the preference values from multiple view subtrees.

								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 })
    }
}

							

In our implementation the default value is an empty dictionary, and the reduce method merges multiple dictionaries into one.

With this preference key in place, we can now use .anchorPreference in the diagram view to pass an anchor up the view tree. To use our generic CollectDict as preference key, we have to specify the generic parameters of CollectDict: Key is the node's identifier, and Value is Anchor<CGPoint> (think of an Anchor<CGPoint> as a way to specify a CGPoint that can be resolved in the coordinate system of another view later on):

								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)
                })
            }
        }
    }
}

							

Now we can use backgroundPreferenceValue to read out all the node centers for our current tree. To resolve the anchors into actual CGPoints, we have to use a GeometryReader. We loop over all the children, then draw a line from the center of the current tree's root node to the child node's center:

								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 is a custom Shape that has absolute from and to coordinates. We also add both points to the animatableData, so that SwiftUI knows how to animate lines (to be able to use CGPoint as animatble data, we have to conform it to the VectorArithmetic protocol. This conformance is ommitted here for brevity):

								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)
        }
    }
}

							

Given all of the machinary above, we finally can use the Diagram view and draw a nice tree with edges:

								struct ContentView: View {
    @State var tree = uniqueTree
    var body: some View {
        Diagram(tree: tree, node: { value in
            Text("\(value.value)")
                .modifier(RoundedCircleStyle())
        })
    }
}

							

What's more, our tree supports animations as well. Because we wrapped each element in a Unique object, we can animate between different states. For example, when we insert a new number into the tree, SwiftUI can animate that insertion for us:

We have also used this technique to draw different kinds of diagrams. For an upcoming project, we wanted to visualize the structure of SwiftUI's view tree. By using Mirror we can access the type of a view's body property, which can look like this (for a simple view):

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

							

We then parse that into a Tree<String>, simplify it slightly, and visualize it using the Diagram view above:

Using SwiftUI's built-in features like shapes, gradients, and some padding we were able to draw the above tree with minimal code. It's also really easy to make the trees interactive: you can wrap each node in a Button, or add other controls inside the nodes. We've been using this in presentations, to generate static diagrams and to just quickly visualize things.

If you'd like to experiment for yourself, you're welcome to try out the full code for the binary tree, and drawing a tree hierarchy of SwiftUI's view hierarchy.

We add new Swift Talk episodes to our SwiftUI Collection every week. Our latest public episode recreates the iOS Stopwatch app, starting with custom buttons. At over 9 hours, and 24 episodes, we're learning a lot!

To learn with us, become a subscriber.


  • See the Swift Talk Collection

Stay up-to-date with our newsletter or follow us on Twitter .

Back to the Blog

Recent Posts