Creating a Mind-Map UI in SwiftUI

In this tutorial, you’ll learn how to create an animated spatial UI in SwiftUI with support for pan and zoom interactions. By Warren Burton.

4.6 (17) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Making a Map View

In this section, you’ll combine your NodeView and EdgeView to show a visual description of your Mesh.

Creating the Nodes’ Layer

For your first task, you’ll build the layer that draws the nodes. You could smoosh the nodes and the edges into one view, but building the layer is tidier and gives better modularity and data isolation.

In the Project navigator, select the View Stack folder. Then, create a new SwiftUI View file. Name the file NodeMapView.swift.

Locate NodeMapView, and add these two properties to it:

@ObservedObject var selection: SelectionHandler
@Binding var nodes: [Node]

Using @Binding on nodes tells SwiftUI that another object will own the node collection and pass it to NodeMapView.

Next, replace the template implementation of body with this:

ZStack {
  ForEach(nodes, id: \.visualID) { node in
    NodeView(node: node, selection: self.selection)
      .offset(x: node.position.x, y: node.position.y)
      .onTapGesture {
        self.selection.selectNode(node)
      }
  }
}

Examine the body of NodeMapView. You’re creating a ZStack of nodes and applying an offset to each node to position the node on the surface. Each node also gains an action to perform when you tap it.

Finally, locate NodeMapView_Previews and add these properties to it:

static let node1 = Node(position: CGPoint(x: -100, y: -30), text: "hello")
static let node2 = Node(position: CGPoint(x: 100, y: 30), text: "world")
@State static var nodes = [node1, node2]

And replace the implementation of previews with this:

let selection = SelectionHandler()
return NodeMapView(selection: selection, nodes: $nodes)

Notice how you use the $nodes syntax to pass a type of Binding. Placing the mock node array outside previews as a @State allows you to create this binding.

Refresh the canvas and you’ll see two nodes side by side. Place the canvas into Live Preview mode by pressing the Play button. The selection logic is now interactive, and touching either node will display a red border.

node layer preview

Creating the Edges’ Layer

Now, you’ll create a layer to display all the edges.

In the Project navigator, select the View Stack folder. Then, create a new SwiftUI View file. Name the file EdgeMapView.swift.

Add this property to EdgeMapView:

@Binding var edges: [EdgeProxy]

Replace the body implementation with this:

ZStack {
  ForEach(edges) { edge in
    EdgeView(edge: edge)
      .stroke(Color.black, lineWidth: 3.0)
  }
}

Notice that each edge in the array has a black stroke.

Add these properties to EdgeMapView_Previews:

static let proxy1 = EdgeProxy(
  id: EdgeID(),
  start: .zero,
  end: CGPoint(x: -100, y: 30))
static let proxy2 = EdgeProxy(
  id: EdgeID(),
  start: .zero,
  end: CGPoint(x: 100, y: 30))

@State static var edges = [proxy1, proxy2]

Replace previews‘ implementation with this line:

EdgeMapView(edges: $edges)

Again, you create a @State property to pass the mock data to the preview of EdgeMapView. Your preview will display the two edges:

Edge map view

OK, get excited because you’re almost there! Now, you’ll combine the two layers to form the finished view.

Creating the MapView

You’re going to place one layer on top of the other to create the finished view.

In the project navigator, select View Stack and create a new SwiftUI View file. Name the file MapView.swift.

Add these two properties to MapView:

@ObservedObject var selection: SelectionHandler
@ObservedObject var mesh: Mesh

Again, you have a reference to a SelectionHandler here. For the first time, you bring an instance of Mesh into the view system.

Replace the body implementation with this:

ZStack {
  Rectangle().fill(Color.orange)
  EdgeMapView(edges: $mesh.links)
  NodeMapView(selection: selection, nodes: $mesh.nodes)
}

Finally putting all the different views together. You start with an orange rectangle, stack the edges on top of it and, finally, stack the nodes. The orange rectangle helps you see what’s happening to your view.

Notice how you bind only the relevant parts of mesh to EdgeMapView and NodeMapView using the $ notation.

Locate MapView_Previews, and replace the code in previews with this implementation:

let mesh = Mesh()
let child1 = Node(position: CGPoint(x: 100, y: 200), text: "child 1")
let child2 = Node(position: CGPoint(x: -100, y: 200), text: "child 2")
[child1, child2].forEach {
  mesh.addNode($0)
  mesh.connect(mesh.rootNode(), to: $0)
}
mesh.connect(child1, to: child2)
let selection = SelectionHandler()
return MapView(selection: selection, mesh: mesh)

You create two nodes and add them to a Mesh. Then, you create edges between nodes. Click Resume in the preview pane, and your canvas should now display three nodes with links between them.

In RazeMind‘s specific case, a Mesh always has a root node.

Three nodes, a root and two children, with links between them

That’s it. Your core map view is complete. Now, you’ll start to add some drag interactions.

Dragging Nodes

In this section, you’ll add the drag gestures so you can move your NodeView around the screen. You’ll also add the ability to pan the MapView.

In the project navigator, select View Stack. Then create a new SwiftUI View file and name it SurfaceView.swift.

Inside SurfaceView, add these properties:

@ObservedObject var mesh: Mesh
@ObservedObject var selection: SelectionHandler

//dragging
@State var portalPosition: CGPoint = .zero
@State var dragOffset: CGSize = .zero
@State var isDragging: Bool = false
@State var isDraggingMesh: Bool = false

//zooming
@State var zoomScale: CGFloat = 1.0
@State var initialZoomScale: CGFloat?
@State var initialPortalPosition: CGPoint?

Locate SurfaceView_Previews and replace the implementation of previews with this:

let mesh = Mesh.sampleMesh()
let selection = SelectionHandler()
return SurfaceView(mesh: mesh, selection: selection)

The @State variables that you added to SurfaceView keep track of the drag and magnification gestures you’re about to create.

Note that this section only deals with dragging. In the next section, you’ll tackle zooming the MapView. But before you set up the dragging actions using a DragGesture, you need to add a little infrastructure.

Getting Ready to Drag

In SurfaceView_Previews, you instantiate a pre-made mesh and assign that mesh to SurfaceView.

Replace the body implementation inside SurfaceView with this code:

VStack {
  // 1
  Text("drag offset = w:\(dragOffset.width), h:\(dragOffset.height)")
  Text("portal offset = x:\(portalPosition.x), y:\(portalPosition.y)")
  Text("zoom = \(zoomScale)")
  //<-- insert TextField here
  // 2
  GeometryReader { geometry in
    // 3
    ZStack {
      Rectangle().fill(Color.yellow)
      MapView(selection: self.selection, mesh: self.mesh)
          //<-- insert scale here later
        // 4
        .offset(
          x: self.portalPosition.x + self.dragOffset.width,
          y: self.portalPosition.y + self.dragOffset.height)
        .animation(.easeIn)
    }
    //<-- add drag gesture later
  }
}

Here, you've created a VStack of four views.

  1. You have three Text elements that display some information about the state.
  2. GeometryReader provides information about the size of the containing VStack.
  3. Inside the GeometryReader, you have a ZStack that contains a yellow background and a MapView.
  4. MapView is offset from the center of SurfaceView by a combination of dragOffset and portalPosition. The MapView also has a basic animation that makes changes look pretty and silky-smooth.

Your view preview looks like this now:

Four nodes connected with lines against an orange background