In this second set of lecture notes on the graph drawing program I am going to complete the drawing app. The final version of the app will be designed to run on an iPad, so this will also give us an opportunity to look at some design strategies that are best suited for tablets.
Here is what the finished app will look like:

The app will add these new capabilities:
Our first improvement is to make it easy to store our graphs in a database. We will set up a SwiftData database for this purpose.
The first change we need to make to enable this feature is to turn our Vertex and Edge classes from the last example into SwiftData model classes. We will also need to introduce a new Graph class. Here is the code for our updated classes.
import Foundation
import SwiftUI
import SwiftData
@Model
class Vertex {
@Attribute(.unique) var id: String = UUID().uuidString
var centerX : Float
var centerY : Float
let radius : Float = 5.0
var offsetX : Float = 0.0
var offsetY : Float = 0.0
init(centerX : Float,centerY: Float) {
self.centerX = centerX
self.centerY = centerY
}
func getCenter() -> CGPoint {
return CGPoint(x:CGFloat(centerX + offsetX),y:CGFloat(centerY + offsetY))
}
func drawIn(_ context: GraphicsContext) {
let circle = Path(ellipseIn: CGRect(x: CGFloat(centerX + offsetX - radius), y: CGFloat(centerY + offsetY - radius), width: CGFloat(2 * radius), height: CGFloat(2 * radius)))
context.fill(circle,with: .color(.red))
}
func containsPoint(_ pt : CGPoint) -> Bool {
let deltaX = (centerX - Float(pt.x))
let deltaY = (centerY - Float(pt.y))
let distSquared = deltaX*deltaX + deltaY*deltaY
if distSquared < radius*radius {
return true
} else {
return false
}
}
func setOffset(_ offset : CGSize) {
self.offsetX = Float(offset.width)
self.offsetY = Float(offset.height)
}
func endDrag() {
centerX += offsetX
centerY += offsetY
offsetX = 0.0
offsetY = 0.0
}
}
@Model
class Edge {
@Attribute(.unique) var id: String = UUID().uuidString
var origin : Vertex
var dest : Vertex
init(origin: Vertex, dest: Vertex) {
self.origin = origin
self.dest = dest
}
func drawIn(_ context: GraphicsContext) {
let edge = Path() { path in
path.move(to: origin.getCenter())
path.addLine(to: dest.getCenter())
}
context.stroke(edge,with:.color(.green),lineWidth:2)
}
}
@Model
class Graph {
@Attribute(.unique) var id: String = UUID().uuidString
var name : String
var created : Date
var vertices : [Vertex]
var edges : [Edge]
var dragging : Vertex?
var started : Bool = false
init(name: String,created: Date) {
self.name = name
self.created = created
vertices = []
edges = []
}
func makeVertex(_ center : CGPoint) {
let v = Vertex(centerX: Float(center.x), centerY: Float(center.y))
vertices.append(v)
}
func startDrag(_ pt : CGPoint) {
started = true
for v in vertices {
if v.containsPoint(pt) {
dragging = v
}
}
}
func continueDrag(_ offset : CGSize) {
if let s = dragging {
s.setOffset(offset)
}
}
func endDrag() {
if let s = dragging {
s.endDrag()
}
dragging = nil
started = false
}
func endDraw(_ pt : CGPoint) {
for v in vertices {
if v.containsPoint(pt) && (v !== dragging) {
if let start = dragging {
let newEdge = Edge(origin:start,dest:v)
edges.append(newEdge)
}
break
}
}
started = false
dragging = nil
}
}
Here are some things to note about this new code:
drawIn() methods in the Vertex and Edge classes you will see how I handle that.To take full advantage of the iPad platform we are going to be working with a new top level component that is well-suited to the platform. The top level component in our app is going to be a NavigationSplitView. Here are the key features of that component:
Here is the code for our top level view:
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var graphs: [Graph]
@State var selected : Graph?
@State var showInput = false
@State var graphName = ""
var body: some View {
NavigationSplitView {
VStack {
List {
ForEach(graphs) { graph in
Text(graph.name)
.onTapGesture {
selected = graph
}
}
}
Button("Add New Graph") {
showInput = true
}.alert("New Graph", isPresented: $showInput, actions: {
TextField("Graph Name", text: $graphName)
Button("Create", action: {addItem()})
Button("Cancel", role: .cancel, action: {})
}, message: {
Text("Enter a name for the new graph.")
})
}
} detail: {
if let g = selected {
GraphDetailView(graph:g)
} else
{
Text("Select an item")
}
}
}
func addItem() {
let newGraph = Graph(name:graphName,created:Date())
modelContext.insert(newGraph)
}
}
The NavigationSplitView has both a body section and a detail section. The list view goes in the body and the detail view goes in the detail section. Our navigation strategy here is a simple one. I simply added a state variable to the view that keeps track of which Graph the user has selected. In the list view I simply added a tap gesture recognizer to each Graph in the list that will mark that Graph as the selected Graph when the user taps on it. The logic in the detail section simply displays a GraphDetailView for the currently selected Graph.
In addition to the list of Graphs, the list view also includes a button that the user can click to create a new Graph. I have attached an alert to that button that will prompt the user to enter a name for the new Graph and then add that Graph to the database. Adding a new Graph to the database also automatically updates the list of all Graphs in the view.
The GraphDetailView contains code that I ported over from the ContentView in the previous version. The only important change I needed to make to the code was to replace the view model class from the previous version with a Graph object. The rest of the logic for this component is largely unchanged from the previous version.
One small change that I added to take advantage of the iPad platform was a frame modifier that I added to the Canvas component in this version:
.frame(maxWidth:1000,maxHeight: 1000)
I intentionally made the Canvas component very large. Since the Canvas is going to appear in the detail section of the NavigationSplitView. If we ask for a frame on the Canvas that is too big to fit in the detail section SwiftUI will automatically scale the Canvas to make it fit into the detail pane nicely.