How to Build a Gantt Chart App in SwiftUI
A Gantt Chart app lets users plan projects visually — draggable task bars, dependency arrows, and a scrollable timeline rendered natively on iOS. It's aimed at freelancers, project leads, and small teams who want a lightweight alternative to desktop tools.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Solid Swift/SwiftUI knowledge, including Canvas and GeometryReader
- Familiarity with SwiftData (@Model, ModelContainer, ModelContext)
- Understanding of date math with Calendar and DateComponents — core to timeline positioning
Architecture overview
The app uses SwiftData for persistence of projects, tasks, and dependency edges. A TimelineViewModel translates date ranges into pixel offsets for the Canvas renderer. The main view pairs a sticky left column (task names) with a horizontally scrollable Canvas for bars. Drag gestures update task dates directly on the model, and StoreKit 2 gates advanced features behind a subscription paywall.
GanttApp/
├── Models/
│ ├── Project.swift # @Model: name, startDate, tasks
│ ├── GanttTask.swift # @Model: title, start, end, progress, dependencies
│ └── Dependency.swift # @Model: from, to task IDs
├── ViewModels/
│ └── TimelineViewModel.swift # date→x offset, zoom, visible range
├── Views/
│ ├── GanttRootView.swift # NavigationSplitView
│ ├── TimelineView.swift # ScrollView + Canvas
│ ├── TaskRowView.swift # left-column labels
│ └── SubscriptionGateView.swift
└── PrivacyInfo.xcprivacy
Step-by-step
1. Data model with SwiftData
Define your persistence layer so tasks, projects, and dependencies survive app restarts and drive the timeline renderer via reactive queries.
import SwiftData
import Foundation
@Model
final class Project {
var name: String
var startDate: Date
@Relationship(deleteRule: .cascade) var tasks: [GanttTask]
init(name: String, startDate: Date = .now) {
self.name = name
self.startDate = startDate
self.tasks = []
}
}
@Model
final class GanttTask {
var title: String
var startDate: Date
var endDate: Date
var progress: Double // 0.0–1.0
var dependsOnIDs: [PersistentIdentifier]
init(title: String, start: Date, end: Date) {
self.title = title
self.startDate = start
self.endDate = end
self.progress = 0
self.dependsOnIDs = []
}
var durationDays: Int {
Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 1
}
}
2. Core UI — scrollable timeline grid
Build the two-panel layout: a fixed task-name column on the left and a horizontally scrollable date grid on the right, sharing a synchronized vertical scroll position.
struct TimelineRootView: View {
@State private var vm = TimelineViewModel()
@Query var tasks: [GanttTask]
var body: some View {
HStack(spacing: 0) {
// Fixed left column
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 0) {
ForEach(tasks) { task in
TaskLabelRow(task: task)
.frame(height: 44)
}
}
}
.frame(width: 160)
.background(Color(.systemGroupedBackground))
Divider()
// Scrollable canvas area
ScrollView([.horizontal, .vertical], showsIndicators: true) {
GanttCanvasView(tasks: tasks, vm: vm)
.frame(
width: vm.totalWidth,
height: CGFloat(tasks.count) * 44
)
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
ZoomControl(vm: vm)
}
}
}
}
3. Project timeline visualization with Canvas
Use SwiftUI's Canvas to draw Gantt bars, progress fills, today-line, and dependency arrows in a single GPU-accelerated pass — critical for smooth scrolling with many tasks.
struct GanttCanvasView: View {
let tasks: [GanttTask]
let vm: TimelineViewModel
var body: some View {
Canvas { ctx, size in
// Date column header lines
for col in vm.visibleDayColumns {
let x = vm.x(for: col.date)
ctx.stroke(Path { p in
p.move(to: CGPoint(x: x, y: 0))
p.addLine(to: CGPoint(x: x, y: size.height))
}, with: .color(.gray.opacity(0.15)))
}
// Gantt bars
for (idx, task) in tasks.enumerated() {
let y = CGFloat(idx) * 44 + 10
let x0 = vm.x(for: task.startDate)
let x1 = vm.x(for: task.endDate)
let barRect = CGRect(x: x0, y: y, width: x1 - x0, height: 24)
// Background bar
ctx.fill(RoundedRectangle(cornerRadius: 5).path(in: barRect),
with: .color(.blue.opacity(0.25)))
// Progress fill
var fillRect = barRect
fillRect.size.width = barRect.width * task.progress
ctx.fill(RoundedRectangle(cornerRadius: 5).path(in: fillRect),
with: .color(.blue))
}
// Today line
let todayX = vm.x(for: .now)
ctx.stroke(Path { p in
p.move(to: CGPoint(x: todayX, y: 0))
p.addLine(to: CGPoint(x: todayX, y: size.height))
}, with: .color(.red.opacity(0.6)), lineWidth: 1.5)
}
.gesture(
DragGesture()
.onChanged { vm.handlePan($0) }
)
}
}
4. Privacy Manifest setup
Add a PrivacyInfo.xcprivacy to declare any Required Reason API usage (e.g. NSFileSystemFreeSize, UserDefaults) — missing manifests cause automatic rejection since Xcode 15.
<?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>NSPrivacyTrackingDomains</key>
<array/>
<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>
</array>
</dict>
</plist>
Common pitfalls
- Canvas redraws on every state change. Don't store display-only derived values in @Model — compute them in TimelineViewModel and pass them into Canvas as value types so SwiftUI can diff correctly.
- SwiftData relationship cycles. Dependency graphs can create retain loops. Model dependencies as a flat array of PersistentIdentifiers rather than @Relationship references to avoid cascade delete surprises.
- Horizontal + vertical scroll synchronization. iOS doesn't offer a built-in linked-scroll API. Use a shared ScrollViewProxy approach carefully — misuse causes jitter. Test on a real device, not only Simulator.
- App Store review: missing StoreKit entitlement. If your subscription paywall is wired but the
com.apple.developer.in-app-paymentsentitlement is absent from the provisioning profile, the build will be rejected without a clear error message. Verify in App Store Connect before submission. - Date timezone edge cases. Positioning bars using
timeIntervalSinceReferenceDatewithout normalizing to midnight in the user's locale creates off-by-one-day rendering bugs. Always derive day boundaries fromCalendar.current.startOfDay(for:).
Adding monetization: Subscription
Use StoreKit 2's Product.products(for:) to fetch your monthly and annual subscription SKUs defined in App Store Connect, then gate advanced features — dependency linking, export to PDF, unlimited projects — behind a Transaction.currentEntitlement(for:) check. Present your paywall using a SubscriptionStoreView (iOS 17+), which handles introductory offers and family sharing automatically. Always verify receipts server-side for renewals and handle StoreKit.Transaction.updates in a background task so entitlement state stays accurate across sessions and device restores.
Shipping this faster with Soarias
Soarias scaffolds the entire SwiftData model layer, wires up a StoreKit 2 subscription paywall, and generates a correctly formed PrivacyInfo.xcprivacy from a single prompt — the three steps that typically consume a full weekend on an advanced project like this. It also sets up fastlane lanes for screenshot capture across six device sizes and handles the App Store Connect metadata submission, including age ratings and review notes.
For an advanced app of this complexity, most developers spend 1–2 weeks on boilerplate, CI wiring, and submission paperwork before touching the core Canvas renderer. Soarias compresses that overhead to a few hours, so you can direct the full 2–4 week budget toward the actual differentiator: the interactive Gantt timeline itself.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A free account lets you sideload onto your own device, but distributing on TestFlight or the App Store — and activating StoreKit subscriptions — requires 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 the Organizer or xcrun altool, then complete the App Store Connect listing — screenshots, description, privacy nutrition labels, and subscription pricing. Apple's review typically takes 1–3 business days for a first submission.
Can I export the Gantt chart to PDF or share it?
Yes — use ImageRenderer (iOS 16+) to rasterize your Canvas view into a UIImage, then wrap it in a PDFDocument via PDFKit. Gate this behind your subscription paywall as a premium export feature to add meaningful upgrade incentive.
Last reviewed: 2026-05-12 by the Soarias team.