How to Build a Kanban Board App in SwiftUI
A Kanban Board app lets users organize tasks across visual columns — To Do, In Progress, Done — using drag-and-drop cards. It's ideal for indie developers targeting productivity-focused iOS users who want a lightweight, local-first project tracker.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- Familiarity with SwiftData
@ModelandModelContainer— this app relies heavily on persistent, ordered relationships - Understanding of SwiftUI's
.draggable/.dropDestinationAPI introduced in iOS 16, refined in iOS 17
Architecture overview
The app uses SwiftData as the single source of truth, with three @Model types: Board, KanbanColumn, and Card. Views are driven entirely by @Query and @Bindable — no separate view-model layer needed at this complexity. Drag-and-drop state is managed transiently in @State on each column view; on drop confirmation the SwiftData context is mutated and persists automatically. StoreKit 2 handles the one-time unlock check at app launch.
KanbanApp/ ├── Models/ │ ├── Board.swift # @Model: title, columns │ ├── KanbanColumn.swift # @Model: title, order, cards │ └── Card.swift # @Model: title, notes, order, dueDate ├── Views/ │ ├── BoardView.swift # HScrollView of columns │ ├── ColumnView.swift # LazyVStack + drop target │ └── CardView.swift # draggable card tile ├── Store/ │ └── PurchaseManager.swift └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model with SwiftData
Define the three @Model classes with ordered, cascade-delete relationships so columns and cards persist and sort correctly across launches.
import SwiftData
import Foundation
@Model
final class Board {
var title: String
@Relationship(deleteRule: .cascade) var columns: [KanbanColumn] = []
init(title: String) { self.title = title }
}
@Model
final class KanbanColumn {
var title: String
var order: Int
@Relationship(deleteRule: .cascade) var cards: [Card] = []
init(title: String, order: Int) {
self.title = title
self.order = order
}
}
@Model
final class Card {
var title: String
var notes: String
var order: Int
var dueDate: Date?
var createdAt: Date
init(title: String, order: Int) {
self.title = title
self.notes = ""
self.order = order
self.createdAt = .now
}
}
2. Core board UI with horizontal column layout
Render the board as a horizontal ScrollView of fixed-width column tiles, each querying its cards sorted by order.
struct BoardView: View {
@Bindable var board: Board
@Environment(\.modelContext) private var context
var sortedColumns: [KanbanColumn] {
board.columns.sorted { $0.order < $1.order }
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 16) {
ForEach(sortedColumns) { column in
ColumnView(column: column)
.frame(width: 260)
}
Button {
let col = KanbanColumn(
title: "New Column",
order: board.columns.count
)
board.columns.append(col)
} label: {
Label("Add Column", systemImage: "plus")
.frame(width: 200, height: 44)
.background(.secondary.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.padding()
}
.navigationTitle(board.title)
}
}
3. Drag-and-drop card management
Use .draggable on each CardView and .dropDestination on each column to move cards between columns, re-assigning parent relationships in the SwiftData context.
struct ColumnView: View {
@Bindable var column: KanbanColumn
@Environment(\.modelContext) private var context
var sortedCards: [Card] {
column.cards.sorted { $0.order < $1.order }
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(column.title).font(.headline).padding(.horizontal, 8)
LazyVStack(spacing: 8) {
ForEach(sortedCards) { card in
CardView(card: card)
.draggable(card.persistentModelID.hashValue.description)
}
}
.dropDestination(for: String.self) { items, _ in
guard let idStr = items.first,
let idVal = Int(idStr) else { return false }
// Locate card by matching hashValue and move it
let all = try? context.fetch(FetchDescriptor())
if let card = all?.first(where: {
$0.persistentModelID.hashValue == idVal
}) {
card.order = column.cards.count
column.cards.append(card)
}
return true
}
.padding(6)
.background(.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
4. Privacy Manifest setup
Apple requires a PrivacyInfo.xcprivacy file for any app using certain system APIs — add it before submitting to avoid automatic rejection.
<!-- PrivacyInfo.xcprivacy (XML property list) -->
<?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
- Cascade delete not set on relationships. Without
deleteRule: .cascade, deleting a column orphans its cards in the SwiftData store — they'll surface as dangling objects and cause crashes. - Card order not persisted on reorder. SwiftUI's drop callback fires but developers often forget to update the
orderinteger on each card in the destination column. Always reindex after a drop. - Dragging across columns loses the source column reference. When a card drops into a new column, you must remove it from the old column's
cardsarray and append to the new one — SwiftData won't infer the inverse update automatically without a proper inverse relationship. - Missing Privacy Manifest causes App Store rejection. Any use of
UserDefaults(e.g. storing theme preference) requires a declared reason API entry. Omitting it results in a binary rejection email, not a review rejection — it won't surface in TestFlight. - ScrollView gesture conflicts. The horizontal board
ScrollViewand vertical column scroll can conflict with long-press drag gestures on iOS. Use.simultaneousGestureor set.scrollDisabled(true)on the column during an active drag.
Adding monetization: One-time purchase
Implement a one-time unlock using StoreKit 2's Product.purchase() API. Gate features like unlimited boards (free tier can have 1) or card attachments behind a single non-consumable IAP. At app launch, call Transaction.currentEntitlement(for:) to check purchase status and store the result in a @Published property on a lightweight PurchaseManager ObservableObject. Avoid restoring purchases manually — StoreKit 2 handles restoration automatically on reinstall via Transaction.currentEntitlement. Set the IAP to "No Ads" or "Pro Unlock" in App Store Connect and price it in the $2.99–$4.99 tier; one-time purchases on productivity tools convert well when the free tier is genuinely useful but visibly limited.
Shipping this faster with Soarias
Soarias scaffolds the full SwiftData model layer and column/card view hierarchy from a prompt, generates your PrivacyInfo.xcprivacy with the correct reason codes already filled in, wires up fastlane with match for code signing, and handles App Store Connect metadata — app description, keywords, screenshots at all required device sizes — and submits the binary. For a Kanban Board app that means the setup that typically costs half your first weekend (project structure, signing, ASC config) is done before you write your first line of drag-and-drop logic.
At intermediate complexity with roughly a week of part-time build time, Soarias realistically compresses the non-coding overhead — provisioning, screenshot production, ASC form-filling, fastlane config — from 4–6 hours spread across the week down to under 30 minutes. You spend the week on the actual product, not the pipeline.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. The free Apple ID lets you sideload onto your own device via Xcode, but TestFlight distribution and App Store submission both require an active Apple Developer Program membership at $99/year.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), upload via the Organizer or altool/fastlane, then complete the App Store Connect listing — screenshots, description, privacy labels, and pricing — before clicking "Submit for Review." First-time reviews typically take 1–2 business days.
Can I sync boards across devices with iCloud?
Yes — add the iCloud capability in Xcode, enable CloudKit in App Store Connect, and swap ModelContainer(for:) for a configuration that includes cloudKitDatabase: .automatic. Note that CloudKit sync requires your @Model classes to have all optional properties or default values, since CloudKit cannot store non-optional nil-less fields.
Last reviewed: 2026-05-12 by the Soarias team.