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
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.
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:
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.
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.
- You have three
Text
elements that display some information about the state. -
GeometryReader
provides information about the size of the containingVStack
. - Inside the
GeometryReader
, you have aZStack
that contains a yellow background and aMapView
. -
MapView
is offset from the center ofSurfaceView
by a combination ofdragOffset
andportalPosition
. TheMapView
also has a basic animation that makes changes look pretty and silky-smooth.
Your view preview looks like this now: