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 (18) · 2 Reviews

Download materials
Save for later
Share

SwiftUI is the perfect addition to an iOS or Mac developer’s toolbox, and it will only improve as the SDK matures. SwiftUI’s solid native support is ideal for vertical lists and items arranged in a rectangular grid.

But what about UIs that aren’t so square?

Think about apps like Sketch or OmniGraffle. These allow you to arrange items at arbitrary points on the screen and draw connections between them.

In this tutorial, you’ll learn how to create this type of mind-map spatial UI using SwiftUI. You’ll create a simple mind-mapping app that allows you to place text boxes on the screen, move them around and create connections between them.

Make sure you have Xcode 11.3 or higher installed before you continue.

Note: This tutorial assumes you have a basic understanding of SwiftUI syntax and intermediate-level Swift skills. If this is your first trip into SwiftUI, check out our basic SwiftUI tutorial first.

Getting Started

Download the tutorial materials by clicking the Download Materials button at the top or bottom of the tutorial. To keep you focused on the SwiftUI elements of this project, you’ll start with some existing model code that describes the graph. You’ll learn more about graph theory in the next section.

Open the RazeMind project in the starter folder. In the Project navigator, locate and expand the folder called Model. You’ll see four Swift files that provide a data source for the graph that you’ll render:

  1. Mesh.swift: The mesh is the top-level container for the model. A mesh has a set of nodes and a set of edges. There’s some logic associated with manipulating the mesh’s data. You’ll use that logic later in the tutorial.
  2. Node.swift: A node describes one object in the mesh, the position of the node and the text contained by the node.
  3. Edge.swift: An edge describes a connection between two nodes and includes references to them.
  4. SelectionHandler.swift: This helper acts as a persistent memory of the selection state of the view. There is some logic associated with selection and editing of nodes that you’ll use later.

Feel free to browse the starter project code. In a nutshell, the starter code provides managed access to some sets of objects. You don’t need to understand it all right now.

Understanding Graph Theory

Graphs are mathematical structures that model pair-wise relationships between nodes in the graph. A connection between two nodes is an edge.

Graphs are either directed or undirected. A directed graph symbolizes orientation between the two end nodes A and B of an edge, e.g A -> B != B -> A. An undirected graph doesn’t give any significance to the orientation of the end points, so A -> B == B -> A.

graph types

A graph is a web of connections. A node can reference anything you choose.

In the sample project, your node is a container for a single string, but you can think as big as you want.

Imagine you’re an architect planning a building. You’ll take components from a palette and generate a bill of materials with that information.

Graph of your building materials

Designing Your UI

For this tutorial, you’ll build an infinite 2D surface. You’ll be able to pan the surface and zoom in and out to see more or less content.

When you create your own app, you need to decide how you want your interface to operate. You can do almost anything, but remember to consider common-use patterns and accessibility. If your interface is too strange or complex, your users will find it hard to work with.

Here’s the set of rules you’ll implement:

  • Change the position of a node by dragging it.
  • Select a node by tapping it.
  • Pan and zoom on the screen because it acts like an infinite surface.
  • Pan the surface by dragging the surface.
  • Use a pinch gesture to zoom in and out.

Now, it’s time to implement these features. You’ll start by building some simple views.

Building the View Primitives

You want to display two things on the surface: nodes and edges. The first thing to do is to create SwiftUI views for these two types.

Creating a Node View

Start by creating a new file. In the Project navigator, select the View Stack folder and then add a new file by pressing Command-N. Select iOS ▸ Swift UI View and click Next. Name the file NodeView.swift and check that you’ve selected the target RazeMind. Finally, click Create.

Inside NodeView, add these variables:

static let width = CGFloat(100)
// 1
@State var node: Node
//2
@ObservedObject var selection: SelectionHandler
//3
var isSelected: Bool {
  return selection.isNodeSelected(node)
}
  1. You pass the node you want to display.
  2. @ObservedObject tells you that selection is passed to NodeView by reference, as it has a requirement of AnyObject.
  3. The computed property isSelected keeps things tidy inside the body of the view.

Now, find the NodeView_Previews implementation and replace the body of the previews property with:

let selection1 = SelectionHandler()
let node1 = Node(text: "hello world")
let selection2 = SelectionHandler()
let node2 = Node(text: "I'm selected, look at me")
selection2.selectNode(node2)

return VStack {
  NodeView(node: node1, selection: selection1)
  NodeView(node: node2, selection: selection2)
}

Here, you instantiate two nodes using two different instances of SelectionHandler. This provides you with a preview of how the view looks when you select it.

Go back to NodeView and replace the body property with the following implementation:

Ellipse()
  .fill(Color.green)
  .overlay(Ellipse()
    .stroke(isSelected ? Color.red : Color.black, lineWidth: isSelected ? 5 : 3))
  .overlay(Text(node.text)
    .multilineTextAlignment(.center)
    .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)))
  .frame(width: NodeView.width, height: NodeView.width, alignment: .center)

This results in a green ellipse with a border and text inside. Nothing fancy, but it’s fine to start working with.

Select simulator iPhone 11 Pro in the target selector. This choice controls how the SwiftUI canvas displays your preview.

Selecting the correct display mode

Open the SwiftUI canvas using Adjust Editor Options ▸ Canvas at the top-right of the editor or by pressing Option-Command-Return.

How to open Canvas

In the preview frame, you’ll see the two possible versions of NodeView.

NodeView preview

Creating an Edge Shape

Now, you’re ready for your next task, which is to create the view of an edge. An edge is a line that connects two nodes.

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

Xcode has created a Template view called EdgeView, but you want EdgeView to be a Shape. So, replace the declaration for the type:

struct EdgeView: View {

With:

struct EdgeView: Shape {

Delete the template’s body. Now, you have a struct with no code inside.

To define the shape, add this code inside EdgeView.

var startx: CGFloat = 0
var starty: CGFloat = 0
var endx: CGFloat = 0
var endy: CGFloat = 0

// 1
init(edge: EdgeProxy) {
  // 2
  startx = edge.start.x
  starty = edge.start.y
  endx = edge.end.x
  endy = edge.end.y
}

// 3
func path(in rect: CGRect) -> Path {
  var linkPath = Path()
  linkPath.move(to: CGPoint(x: startx, y: starty)
    .alignCenterInParent(rect.size))
  linkPath.addLine(to: CGPoint(x: endx, y:endy)
    .alignCenterInParent(rect.size))
  return linkPath
}

Looking at the code for EdgeView:

  1. You initialize the shape with an instance of EdgeProxy, not Edge, because an Edge doesn’t know anything about the Node instances it references. The Mesh rebuilds the list of EdgeProxy objects when the model changes.
  2. You split the two end CGPoints into four CGFloat properties. This becomes important later in the tutorial, when you add animation.
  3. The drawing in path(in:) is a simple straight line from start to end. The call to the helper alignCenterInParent(_:) shifts the origin of the line from the top leading edge to the center of the view rectangle.

Locate EdgeView_Previews below EdgeView, and replace the default implementation of previews with this code.

let edge1 = EdgeProxy(
  id: UUID(),
  start: CGPoint(x: -100, y: -100),
  end: CGPoint(x: 100, y: 100))
let edge2 = EdgeProxy(
  id: UUID(),
  start: CGPoint(x: 100, y: -100),
  end: CGPoint(x: -100, y: 100))
return ZStack {
  EdgeView(edge: edge1).stroke(lineWidth: 4)
  EdgeView(edge: edge2).stroke(Color.blue, lineWidth: 2)
}

Refresh the preview. You’ll see an X centered in the simulator window.

edge view preview

You’re now ready to start creating your mesh view.