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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Creating a Mind-Map UI in SwiftUI
30 mins
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.
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:
- 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.
- Node.swift: A node describes one object in the mesh, the position of the node and the text contained by the node.
- Edge.swift: An edge describes a connection between two nodes and includes references to them.
- 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
.
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.
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)
}
- You pass the node you want to display.
-
@ObservedObject
tells you thatselection
is passed toNodeView
by reference, as it has a requirement ofAnyObject
. - 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.
Open the SwiftUI canvas using Adjust Editor Options ▸ Canvas at the top-right of the editor or by pressing Option-Command-Return.
In the preview frame, you’ll see the two possible versions of NodeView
.
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
:
- You initialize the shape with an instance of
EdgeProxy
, notEdge
, because anEdge
doesn’t know anything about theNode
instances it references. TheMesh
rebuilds the list ofEdgeProxy
objects when the model changes. - You split the two end
CGPoints
into fourCGFloat
properties. This becomes important later in the tutorial, when you add animation. - The drawing in
path(in:)
is a simple straight line fromstart
toend
. The call to the helperalignCenterInParent(_:)
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.
You’re now ready to start creating your mesh view.