How to Build a Mind Map App in SwiftUI
A mind map app lets users capture, connect, and visually organise ideas on an infinite canvas — dragging nodes, drawing branches, and re-arranging hierarchies with gestures. It's aimed at students, writers, and knowledge workers who want a native, offline-first tool that feels fast on iPhone and iPad.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Solid Swift/SwiftUI knowledge, including
Canvasand gesture composition - Familiarity with SwiftData — persistent graph structures need careful relationship modelling
- Understanding of CGAffineTransform and coordinate-space conversions for the infinite canvas
- StoreKit 2 knowledge for subscription implementation
Architecture overview
The app uses a SwiftData model layer (MindNode and MindEdge) persisted in a local store, surfaced through a single @Query-driven CanvasViewModel that owns transform state, selection, and layout computation. The MindMapCanvas view renders entirely inside Canvas for performance, with overlay View elements only for editable text fields. A LayoutEngine struct runs a radial tree pass whenever the graph changes, publishing updated positions back into SwiftData.
MindMapApp/
├── Models/
│ ├── MindNode.swift # @Model: id, label, color, position, children
│ └── MindEdge.swift # @Model: source, target relationship
├── ViewModels/
│ └── CanvasViewModel.swift # transform, selection, undo stack
├── Views/
│ ├── MindMapCanvas.swift # Canvas + gesture layer
│ ├── NodeOverlay.swift # TextField on top of canvas
│ └── ToolbarView.swift # add, delete, color picker
├── Engine/
│ └── LayoutEngine.swift # radial tree auto-layout
└── PrivacyInfo.xcprivacy
Step-by-step
1. Data model with SwiftData
Define MindNode and MindEdge as SwiftData @Model classes with a parent–child relationship so the entire graph persists automatically.
import SwiftData
import SwiftUI
@Model
final class MindNode {
var id: UUID
var label: String
var x: Double
var y: Double
var colorHex: String
@Relationship(deleteRule: .cascade) var children: [MindNode]
@Relationship(inverse: \MindNode.children) var parent: MindNode?
init(label: String, x: Double = 0, y: Double = 0, colorHex: String = "#4F8EF7") {
self.id = UUID()
self.label = label
self.x = x
self.y = y
self.colorHex = colorHex
self.children = []
}
var position: CGPoint {
get { CGPoint(x: x, y: y) }
set { x = newValue.x; y = newValue.y }
}
}
2. Core canvas view
Use SwiftUI's Canvas inside a ZStack with a MagnifyGesture and DragGesture to pan and zoom the infinite workspace.
struct MindMapCanvas: View {
@Bindable var vm: CanvasViewModel
let nodes: [MindNode]
var body: some View {
Canvas { ctx, size in
let t = vm.transform
for node in nodes {
// Draw edges first
for child in node.children {
var path = Path()
path.move(to: t.apply(node.position))
path.addLine(to: t.apply(child.position))
ctx.stroke(path, with: .color(.secondary), lineWidth: 2)
}
// Draw node bubble
let rect = CGRect(origin: t.apply(node.position), size: .zero)
.insetBy(dx: -40, dy: -20)
ctx.fill(Path(ellipseIn: rect), with: .color(Color(hex: node.colorHex)))
}
}
.gesture(canvasDrag.simultaneously(with: pinchGesture))
.onTapGesture(count: 2) { vm.addNode(at: $0) }
}
var canvasDrag: some Gesture {
DragGesture().onChanged { vm.translate(by: $0.translation) }
.onEnded { _ in vm.commitTranslation() }
}
var pinchGesture: some Gesture {
MagnifyGesture().onChanged { vm.scale(by: $0.magnification) }
}
}
3. Visual idea organisation (core feature)
Implement radial auto-layout in LayoutEngine so child nodes spread evenly around their parent, with an animated position update applied back to SwiftData.
struct LayoutEngine {
static let radius: Double = 160
/// Recursively positions children in a radial arc around `node`.
static func layout(_ node: MindNode, angle: Double = 0, depth: Int = 0) {
guard !node.children.isEmpty else { return }
let count = node.children.count
let spread = depth == 0 ? 2 * .pi : .pi * 0.8
let step = spread / Double(max(count - 1, 1))
let startAngle = angle - spread / 2
for (i, child) in node.children.enumerated() {
let theta = startAngle + step * Double(i)
let r = radius * Double(depth + 1)
child.position = CGPoint(
x: node.x + r * cos(theta),
y: node.y + r * sin(theta)
)
layout(child, angle: theta, depth: depth + 1)
}
}
}
// In CanvasViewModel:
func applyAutoLayout() {
guard let root else { return }
withAnimation(.spring(duration: 0.4)) {
LayoutEngine.layout(root)
}
try? modelContext.save()
}
4. Privacy Manifest (PrivacyInfo.xcprivacy)
Apple requires a Privacy Manifest declaring all "required reason" API usage — for a mind map app that's NSPrivacyAccessedAPICategoryUserDefaults (storing settings) and file timestamp APIs.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key><false/>
<key>NSPrivacyCollectedDataTypes</key><array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>C617.1</string></array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- SwiftData circular relationships: A parent→children→parent back-reference will trigger infinite encode loops unless you mark one direction with
@Relationship(inverse:)and avoid fetching both sides eagerly in the same query. - Canvas coordinate drift on zoom: Always apply the same
CGAffineTransformto both drawing and gesture hit-testing, or taps will miss nodes after the user zooms. Store a single source-of-truth transform in your view model. - Performance with large graphs:
Canvasredraws the entire scene every frame. Cache resolvedColorvalues and skip off-screen nodes using a bounding-box cull against the current viewport rect. - App Store review: missing export compliance: If you add iCloud sync later, reviewers will ask for an encryption declaration. Add
ITSAppUsesNonExemptEncryption = NOto Info.plist now to avoid a rejection when you update. - Subscription sandbox testing: StoreKit 2 sandbox renewals expire in minutes, not months. Always test cancellation and grace-period flows in the sandbox before submission — reviewers test this path and will reject a broken paywall.
Adding monetization: Subscription
Use StoreKit 2's Product.products(for:) to fetch your auto-renewable subscription product (e.g. com.yourapp.mindmap.pro.monthly), then call product.purchase() and listen on Transaction.updates for real-time entitlement changes. Gate features like unlimited nodes, iCloud sync, and export formats behind an @AppStorage-backed entitlement flag that you set after a verified Transaction. Use StoreKit.AppStore.sync() on launch to restore purchases for users who reinstalled. Configure the subscription group, price, and free trial duration in App Store Connect under Monetization → Subscriptions before submitting for review.
Shipping this faster with Soarias
Soarias scaffolds the SwiftData model layer, wires up the ModelContainer in the app entry point, generates a correct PrivacyInfo.xcprivacy based on the APIs your code actually calls, configures fastlane match for code signing, and drives the App Store Connect submission — including the subscription group metadata and required screenshot sets — without you touching Xcode's organiser.
For an advanced app like this, the most time-consuming manual steps are code-signing setup, Privacy Manifest auditing, and the first ASC submission cycle. Soarias typically cuts that overhead from a full day down to under an hour, letting you spend the 2–4 week estimate on actual canvas and layout logic rather than toolchain friction.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. You can build and run the app on a simulator with a free account, but TestFlight distribution and App Store submission require the $99/year Apple Developer Program membership.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), upload via Xcode Organiser or xcrun altool, then complete the App Store Connect listing — screenshots, privacy labels, and subscription details — before clicking Submit for Review. Soarias can automate the entire upload and metadata step.
Can I support iPad split-screen and Apple Pencil?
Yes. SwiftUI's Canvas works well in multi-window scenes on iPadOS. For Pencil, adopt PencilKit or handle UITouch.type == .pencil via a UIViewRepresentable wrapper to distinguish stylus strokes from finger pans — a high-value differentiator for note-taking users.
Last reviewed: 2026-05-12 by the Soarias team.