This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We start building a visual node editor as a playground to experiment with focus state, selection and more.

00:06 Today we want to start building a small macOS app that draws a graph with nodes and edges between them. We should be able to edit the graph by moving nodes around, and have different anchor points for the edges. The goal is to explore what's involved when managing lots of interactive UI elements, including gestures, focus, selection, and hopefully also accessibility.

00:45 The inspiration for this comes from node-based editors, like React Flow and Quartz Composer. We're not trying to implement their functionality — just the UI concept.

Nodes and Edges

01:05 Let's define some data structures. We'll start with a Graph that contains nodes and edges:

struct Graph {
    var nodes: [Node]
    var edges: [Edge]
}

01:27 The `Node` struct is `Identifiable`, and we use `UUID` for the identifier. Besides a `title` property, it also has a `frame` property, because the graph won't be laid out automatically — we'll store the all coordinates in our model to let the nodes support drag-and-drop:

```swift
struct Node: Identifiable {
    var id: UUID = UUID()
    var title: String
    var frame: CGRect
}

01:53 Next is the Edge type. We also make it Identifiable, and it connects two node IDs. Later, we'll add anchor points for more precision, but we're keep it simple for now:

struct Edge: Identifiable {
    var id: UUID = UUID()
    var source: Node.ID
    var destination: Node.ID
    // todo: anchor points
}

Drawing Nodes

02:29 Let's create a GraphView that renders nodes. In a ZStack, we loop over the graph's nodes, rendering a NodeView for each:

struct GraphView: View {
    var graph: Graph

    var body: some View {
        ZStack {
            ForEach(graph.nodes) { node in
                NodeView(node: node)
            }
        }
    }
}

03:04 Here's the outline for NodeView. The question is whether the view should handle its own positioning, or whether we'll let the parent view do this. We go for the latter, and we make NodeView handle the sizing and styling:

struct NodeView: View {
    var node: Node

    var body: some View {
        Rectangle()
            .stroke(lineWidth: 2)
            .frame(width: node.frame.width, height: node.frame.height)
    }
}

03:38 We create an example graph with nodes and one edge:

var exampleGraph: Graph {
    var result = Graph(nodes: [
        .init(title: "My First Node", frame: .init(origin: .zero, size: .init(width: 100, height: 40))),
        .init(title: "Another Node", frame: .init(origin: .init(x: 200, y: 80), size: .init(width: 120, height: 80))),
    ], edges: [])
    result.edges.append(
        .init(
            source: result.nodes[0].id,
            destination: result.nodes[1].id
        )
    )
    return result
}

struct ContentView: View {
    @State var graph = exampleGraph

    var body: some View {
        GraphView(graph: graph)
    }
}

05:11 Running this, the nodes appear centered on the screen. That makes sense since we haven't positioned them — only sized them. To fix this, we'll give the ZStack a top-leading alignment and we offset each node using its frame:

struct GraphView: View {
    var graph: Graph

    var body: some View {
        ZStack(alignment: .topLeading) {
            Color.clear
            ForEach(graph.nodes) { node in
                NodeView(node: node)
                    .offset(x: node.frame.minX, y: node.frame.minY)
            }
        }
    }
}

07:03 Now the nodes are positioned, but they're close to the edges, so we add padding around the graph in ContentView:

struct ContentView: View {
    @State var graph = exampleGraph
    
    var body: some View {
        GraphView(graph: graph)
            .padding()
    }
}

07:16 To make the nodes' titles visible, we overlay each NodeView with a Text:

struct NodeView: View {
    var node: Node

    var body: some View {
        Rectangle()
            .stroke(lineWidth: 2)
            .frame(width: node.frame.width, height: node.frame.height)
            .overlay { Text(node.title) }
    }
}

Drawing Edges

07:40 To draw the edges, we'll loop through them before drawing the nodes, so the edges appear behind the nodes. Since our data model includes node positions and sizes, we don't need to use geometry readers and preferences.

08:28 Each Edge references a source and destination node. We can create a helper method on Graph that resolves an Edge into concrete coordinates:

struct Graph {
    var nodes: [Node]
    var edges: [Edge]

    func resolve(_ edge: Edge) -> ResolvedEdge {
        
    }
}

09:39 The ResolvedEdge struct contains two points:

struct ResolvedEdge {
    var from: CGPoint
    var to: CGPoint
}

10:07 Let's also define a helper to find a node by its ID:

struct Graph {
    var nodes: [Node]
    var edges: [Edge]

    func node(with id: Node.ID) -> Node {
        nodes.first { $0.id == id }!
    }

    func resolve(_ edge: Edge) -> ResolvedEdge {
        
    }
}

10:50 Now we can locate the edge's nodes in the resolve method:

struct Graph {
    var nodes: [Node]
    var edges: [Edge]

    func node(with id: Node.ID) -> Node {
        nodes.first { $0.id == id }!
    }

    func resolve(_ edge: Edge) -> ResolvedEdge {
        let s = node(with: edge.source)
        let d = node(with: edge.destination)

    }
}

11:10 We need a small extension on CGRect to pick a point using a UnitPoint. We've previously stored this as a code snippet because we use it so much:

extension CGRect {
    subscript(_ point: UnitPoint) -> CGPoint {
        return .init(x: minX + point.x * width, y: minY + point.y * height)
    }
}

11:42 Using the above helper, we can find the starting node's trailing point and the destination node's leading point, and return the resolved edge:

struct Graph {
    var nodes: [Node]
    var edges: [Edge]

    func node(with id: Node.ID) -> Node {
        nodes.first { $0.id == id }!
    }

    func resolve(_ edge: Edge) -> ResolvedEdge {
        let s = node(with: edge.source)
        let d = node(with: edge.destination)
        let sPoint = s.frame[.trailing]
        let dPoint = d.frame[.leading]
        return ResolvedEdge(from: sPoint, to: dPoint)
    }
}

12:25 We write an EdgeView that takes a resolved edge and draws a line between the two points, and then we use it to render each edge in GraphView:

struct Edgeview: View {
    var edge: ResolvedEdge

    var body: some View {
        Path { p in
            p.move(to: edge.from)
            p.addLine(to: edge.to)
        }
        .stroke(Color.accentColor, lineWidth: 2)
    }
}

struct GraphView: View {
    var graph: Graph

    var body: some View {
        ZStack(alignment: .topLeading) {
            Color.clear
            ForEach(graph.edges) { edge in
                let resolved = graph.resolve(edge)
                Edgeview(edge: resolved)
            }
            ForEach(graph.nodes) { node in
                NodeView(node: node)
                    .offset(x: node.frame.minX, y: node.frame.minY)
            }
        }
    }
}

13:29 When we run this, we see a line between the nodes:

Curved Edges

13:46 Instead of a straight line, we want to draw a curve for each edge. We calculate two control points by taking the horizontal distance between edge.from and edge.to, and then multiplying that by a factor 0.5. The first control point moves to the right from the source, and the second moves to the left from the destination, creating a smooth arc between the nodes:

struct Edgeview: View {
    var edge: ResolvedEdge

    var body: some View {
        Path { p in
            p.move(to: edge.from)
            var cp1 = edge.from
            let diffX = edge.to.x - edge.from.x
            cp1.x += diffX * 0.5
            var cp2 = edge.to
            cp2.x -= diffX * 0.5
            p.addCurve(to: edge.to, control1: cp1, control2: cp2)
        }
        .stroke(Color.accentColor, lineWidth: 2)
    }
}

15:28 It doesn't look so good if we move the second node over to the left. The edge should always go out to the right from the source node, which we can achieve by making the diffX absolute:

struct Edgeview: View {
    var edge: ResolvedEdge

    var body: some View {
        Path { p in
            p.move(to: edge.from)
            var cp1 = edge.from
            let diffX = abs(edge.to.x - edge.from.x)
            cp1.x += diffX * 0.5
            var cp2 = edge.to
            cp2.x -= diffX * 0.5
            p.addCurve(to: edge.to, control1: cp1, control2: cp2)
        }
        .stroke(Color.accentColor, lineWidth: 2)
    }
}

16:04 By filling the nodes, the edges can go behind them without interfering with the contents of the node views:

struct NodeView: View {
    var node: Node

    var body: some View {
        Rectangle()
            .fill(.background)
            .stroke(Color.primary, lineWidth: 2)
            .frame(width: node.frame.width, height: node.frame.height)
            .overlay { Text(node.title) }
    }
}

16:37 If the trailing edge of the start node and the leading edge of the destination node are at the same X position, we end up with a straight line down. To prevent this from happening and make the curve shape more consistent, we can define a minimum offset for the control points:

struct Edgeview: View {
    var edge: ResolvedEdge
    let minOffset: CGFloat = 50
    
    var body: some View {
        Path { p in
            p.move(to: edge.from)
            var cp1 = edge.from
            let diffX = max(minOffset, abs(edge.to.x - edge.from.x))
            cp1.x += diffX * 0.5
            var cp2 = edge.to
            cp2.x -= diffX * 0.5
            p.addCurve(to: edge.to, control1: cp1, control2: cp2)
        }
        .stroke(Color.accentColor, lineWidth: 2)
    }
}

Dragging Nodes

17:54 With nodes and edges in place, we want to add some interactivity by moving the nodes around with a drag gesture. Since we'll be dragging more things, we can create a reusable DragModifier that takes a binding to a CGPoint:

struct DragModifier: ViewModifier {
    @Binding var location: CGPoint

    func body(content: Content) -> some View {
        content
            .gesture(DragGesture().onChanged({ value in

            }).onEnded({ value in

            }))
    }
}

19:58 The tricky part about dragging is that we want the changes to be reflected in the data model immediately — otherwise, the edges won't update while we move a node. To do this, we need to track the original position at the start of the drag gesture, and apply the gesture's current translation relative to that point. We introduce a startPoint state property to store the node's initial position when the gesture begins, and then we update the binding by adding the current translation to that starting point. When the gesture ends, we reset startPoint to nil, so that it's ready for the next drag interaction:

struct DragModifier: ViewModifier {
    @Binding var location: CGPoint
    @State private var startPoint: CGPoint?

    func body(content: Content) -> some View {
        content
            .gesture(DragGesture().onChanged({ value in
                if startPoint == nil {
                    startPoint = value.location
                }
                location = startPoint! + value.translation
            }).onEnded({ value in
                startPoint = nil
            }))
    }
}

21:15 We also add a snippet to support the + operator with CGPoint and CGSize:

protocol Vector2 {
    var x: CGFloat { get set }
    var y: CGFloat { get set }
    init(x: CGFloat, y: CGFloat)
}

extension Vector2 {
    static func * (lhs: Self, rhs: Self) -> Self {
        Self(x: lhs.x * rhs.x, y: lhs.y * rhs.y)
    }

    static func * (lhs: Self, rhs: CGFloat) -> Self {
        Self(x: lhs.x * rhs, y: lhs.y * rhs)
    }

    static func + (lhs: Self, rhs: some Vector2) -> Self {
        Self(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
    }

    static func - (lhs: Self, rhs: Self) -> Self {
        Self(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
    }
}

extension CGPoint: Vector2 {}
extension CGSize: Vector2 {
    var x: CGFloat {
        get { width }
        set { width = newValue }
    }
    var y: CGFloat {
        get { height }
        set { height = newValue}
    }

    init(x: CGFloat, y: CGFloat) {
        self = CGSize(width: x, height: y)
    }
}

22:30 Before we can test the dragging behavior, we need to actually apply the DragModifier to our NodeView. That means switching to ForEach($graph.nodes) to get a binding to each node, and then passing $node.frame.origin as a binding into the drag modifier. To make this work, the graph property itself needs to be a binding as well:

struct GraphView: View {
    @Binding var graph: Graph

    var body: some View {
        ZStack(alignment: .topLeading) {
            Color.clear
            ForEach(graph.edges) { edge in
                let resolved = graph.resolve(edge)
                Edgeview(edge: resolved)
            }
            ForEach($graph.nodes) { $node in
                NodeView(node: node)
                    .modifier(DragModifier(location: $node.frame.origin))
                    .offset(x: node.frame.minX, y: node.frame.minY)
            }
        }
    }
}

23:50 In ContentView, we now pass a binding into GraphView:

struct ContentView: View {
    @State var graph = exampleGraph
    
    var body: some View {
        GraphView(graph: $graph)
            .padding()
    }
}

24:03 The drag now works, although the node jumps around quite a bit while it's being dragged. Let's see if it improves by swapping the order of the offset and the drag modifier:

// ...
ForEach($graph.nodes) { $node in
    NodeView(node: node)
        .modifier(DragModifier(location: $node.frame.origin))
        .offset(x: node.frame.minX, y: node.frame.minY)
}
// ...

24:35 There's still a slight offset due to how we initialize the drag. We fix this by capturing the original location instead of the gesture's location:

struct DragModifier: ViewModifier {
    @Binding var location: CGPoint
    @State private var startPoint: CGPoint?

    func body(content: Content) -> some View {
        content
            .gesture(DragGesture().onChanged({ value in
                if startPoint == nil {
                    startPoint = location
                }
                location = startPoint! + value.translation
            }).onEnded({ value in
                startPoint = nil
            }))
    }
}

25:40 Finally, let's add a third node and connect it with another edge to the first node:

var exampleGraph: Graph {
    var result = Graph(nodes: [
        .init(title: "My First Node", frame: .init(origin: .zero, size: .init(width: 100, height: 40))),
        .init(title: "Another Node", frame: .init(origin: .init(x: 100, y: 80), size: .init(width: 120, height: 80))),
        .init(title: "Third Node", frame: .init(origin: .init(x: 100, y: 160), size: .init(width: 120, height: 80))),
    ], edges: [])
    result.edges.append(contentsOf: [
        .init(
            source: result.nodes[0].id,
            destination: result.nodes[1].id
        ),
        .init(
            source: result.nodes[0].id,
            destination: result.nodes[2].id
        ),
    ])
    return result
}

26:40 This is a great start. Next, we'll look at edge sockets and anchor points — so edges can connect at any point on a node's edge, not just the trailing and leading points. We'll also explore adding resize handles to the node view.

Resources

  • Sample Code

    Written in Swift 6.0

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

193 Episodes · 67h20min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes