How to Build a Habit Tracker App in SwiftUI
A Habit Tracker app lets users define daily goals and check them off to build unbroken streaks — one of the most effective mechanics for long-term behavior change. This guide is for iOS developers who want a clean, App Store-ready habit tracker using SwiftUI, SwiftData, and Swift Charts.
Prerequisites
- Mac with Xcode 16 or later
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift/SwiftUI knowledge (you should be comfortable with views, state, and closures)
- A physical iOS device for testing UserNotifications — notification triggers behave unreliably in Simulator
Architecture overview
The app uses SwiftData as its persistence layer — each Habit is a @Model class that stores its name, emoji, reminder time, and an array of check-in dates. Views read data via @Query and write through the environment-injected ModelContext. Streak logic lives directly on the model, keeping views thin. A lightweight NotificationManager singleton wraps UNUserNotificationCenter to schedule per-habit daily reminders. Swift Charts renders a 7-day completion bar chart in the detail view with no third-party dependencies.
HabitTracker/ ├── App/ │ └── HabitTrackerApp.swift ← @main entry, .modelContainer setup ├── Models/ │ └── Habit.swift ← @Model, currentStreak, isCompletedToday ├── Views/ │ ├── ContentView.swift ← NavigationStack + @Query-powered List │ ├── HabitRowView.swift ← check-in button + streak flame badge │ ├── AddHabitView.swift ← sheet for creating a new habit │ └── HabitDetailView.swift ← Swift Charts 7-day bar chart ├── Managers/ │ └── NotificationManager.swift ← @Observable UNUserNotificationCenter wrapper └── PrivacyInfo.xcprivacy ← required for App Store submission
Step-by-step
1. Project setup and data model
Create a new iOS App project in Xcode 16 with SwiftUI as the interface and SwiftData as the storage engine. Define the Habit model with a UUID for notification identifiers, and keep streak computation directly on the model so views stay free of business logic.
// App/HabitTrackerApp.swift
import SwiftUI
import SwiftData
@main
struct HabitTrackerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.task {
await NotificationManager.shared.requestAuthorization()
}
}
.modelContainer(for: Habit.self)
}
}
// Models/Habit.swift
import Foundation
import SwiftData
@Model
final class Habit {
var id: UUID
var name: String
var emoji: String
var checkIns: [Date]
var reminderHour: Int
var reminderMinute: Int
var createdAt: Date
init(
name: String,
emoji: String,
reminderHour: Int = 9,
reminderMinute: Int = 0
) {
self.id = UUID()
self.name = name
self.emoji = emoji
self.checkIns = []
self.reminderHour = reminderHour
self.reminderMinute = reminderMinute
self.createdAt = .now
}
// MARK: – Streak computation
var currentStreak: Int {
let calendar = Calendar.current
// Build a set of unique start-of-day values for O(1) lookup
let checkedDays = Set(checkIns.map { calendar.startOfDay(for: $0) })
var streak = 0
var cursor = calendar.startOfDay(for: .now)
while checkedDays.contains(cursor) {
streak += 1
guard let prev = calendar.date(byAdding: .day, value: -1, to: cursor) else { break }
cursor = prev
}
return streak
}
var isCompletedToday: Bool {
checkIns.contains { Calendar.current.isDateInToday($0) }
}
}
2. Build the habit list view
@Query automatically fetches habits and triggers re-renders when SwiftData changes — no manual refresh needed. Use ContentUnavailableView for the empty state and NavigationLink(value:) rather than the deprecated NavigationLink(destination:).
// Views/ContentView.swift
import SwiftUI
import SwiftData
struct ContentView: View {
@Query(sort: \Habit.createdAt) private var habits: [Habit]
@Environment(\.modelContext) private var modelContext
@State private var showingAdd = false
var body: some View {
NavigationStack {
Group {
if habits.isEmpty {
ContentUnavailableView(
"No habits yet",
systemImage: "checkmark.circle",
description: Text("Tap + to track your first habit.")
)
} else {
List {
ForEach(habits) { habit in
NavigationLink(value: habit) {
HabitRowView(habit: habit)
}
}
.onDelete(perform: deleteHabits)
}
.listStyle(.insetGrouped)
}
}
.navigationTitle("Habits")
.navigationDestination(for: Habit.self) { habit in
HabitDetailView(habit: habit)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add", systemImage: "plus") { showingAdd = true }
}
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.sheet(isPresented: $showingAdd) {
AddHabitView()
}
}
}
private func deleteHabits(at offsets: IndexSet) {
for index in offsets { modelContext.delete(habits[index]) }
}
}
#Preview {
ContentView()
.modelContainer(for: Habit.self, inMemory: true)
}
// Views/AddHabitView.swift
import SwiftUI
import SwiftData
struct AddHabitView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var emoji = "✅"
var body: some View {
NavigationStack {
Form {
Section("Habit name") {
TextField("e.g. Read 30 minutes", text: $name)
}
Section("Icon") {
TextField("Emoji", text: $emoji)
}
}
.navigationTitle("New Habit")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
private func save() {
let habit = Habit(
name: name.trimmingCharacters(in: .whitespaces),
emoji: emoji.isEmpty ? "✅" : emoji
)
modelContext.insert(habit)
NotificationManager.shared.scheduleDailyReminder(for: habit)
dismiss()
}
}
#Preview {
AddHabitView()
.modelContainer(for: Habit.self, inMemory: true)
}
3. Streak tracking with daily check-ins
The row view shows the streak flame and a tap-to-toggle check-in button. Mutating a @Model property inside a live ModelContext is automatically saved — no explicit modelContext.save() call required for in-memory consistency.
// Views/HabitRowView.swift
import SwiftUI
struct HabitRowView: View {
let habit: Habit
var body: some View {
HStack(spacing: 12) {
Text(habit.emoji)
.font(.title2)
.frame(width: 36)
VStack(alignment: .leading, spacing: 3) {
Text(habit.name)
.font(.headline)
HStack(spacing: 4) {
Image(systemName: "flame.fill")
.foregroundStyle(habit.currentStreak > 0 ? .orange : .secondary)
.imageScale(.small)
Text(habit.currentStreak == 1
? "1 day streak"
: "\(habit.currentStreak) day streak")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Button {
withAnimation(.spring(duration: 0.25)) {
toggleToday()
}
} label: {
Image(
systemName: habit.isCompletedToday
? "checkmark.circle.fill"
: "circle"
)
.font(.title2)
.foregroundStyle(habit.isCompletedToday ? .green : .secondary)
.symbolEffect(.bounce, value: habit.isCompletedToday)
}
.buttonStyle(.plain)
}
.padding(.vertical, 4)
}
private func toggleToday() {
if habit.isCompletedToday {
habit.checkIns.removeAll { Calendar.current.isDateInToday($0) }
} else {
habit.checkIns.append(.now)
}
}
}
#Preview {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Habit.self, configurations: config)
let habit = Habit(name: "Read 30 minutes", emoji: "📚")
habit.checkIns = [.now]
container.mainContext.insert(habit)
return List {
HabitRowView(habit: habit)
}
.modelContainer(container)
}
4. Weekly progress chart
Add a detail view with a Swift Charts bar chart showing the past 7 days. The green/grey contrast immediately communicates consistency — it's a compelling value demonstration for the one-time purchase paywall.
// Views/HabitDetailView.swift
import SwiftUI
import Charts
struct DayBar: Identifiable {
let id = UUID()
let label: String // "Mon", "Tue", …
let completed: Bool
}
struct HabitDetailView: View {
let habit: Habit
private var weekData: [DayBar] {
let calendar = Calendar.current
let formatter = DateFormatter()
formatter.dateFormat = "EEE"
return (0..<7).reversed().compactMap { offset -> DayBar? in
guard let day = calendar.date(byAdding: .day, value: -offset, to: .now)
else { return nil }
let done = habit.checkIns.contains { calendar.isDate($0, inSameDayAs: day) }
return DayBar(label: formatter.string(from: day), completed: done)
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// Streak hero card
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("\(habit.currentStreak)")
.font(.system(size: 64, weight: .bold, design: .rounded))
Text(habit.currentStreak == 1 ? "day streak" : "day streak")
.font(.title3)
.foregroundStyle(.secondary)
}
Spacer()
Text(habit.emoji)
.font(.system(size: 72))
}
.padding()
.background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 16))
// 7-day chart
VStack(alignment: .leading, spacing: 8) {
Text("Last 7 days")
.font(.headline)
Chart(weekData) { bar in
BarMark(
x: .value("Day", bar.label),
y: .value("Done", bar.completed ? 1 : 0)
)
.foregroundStyle(
bar.completed ? Color.green : Color.secondary.opacity(0.25)
)
.cornerRadius(6)
}
.chartYAxis(.hidden)
.frame(height: 120)
}
// All-time stats
HStack(spacing: 0) {
StatCell(
label: "Total check-ins",
value: "\(habit.checkIns.count)"
)
Divider().frame(height: 40)
StatCell(
label: "This week",
value: "\(weekData.filter(\.completed).count) / 7"
)
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
}
.padding()
}
.navigationTitle(habit.name)
.navigationBarTitleDisplayMode(.inline)
}
}
private struct StatCell: View {
let label: String
let value: String
var body: some View {
VStack(spacing: 4) {
Text(value).font(.title2.bold())
Text(label).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
#Preview {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: Habit.self, configurations: config)
let habit = Habit(name: "Meditate", emoji: "🧘")
let calendar = Calendar.current
for offset in [0, 1, 2, 4, 5] {
if let day = calendar.date(byAdding: .day, value: -offset, to: .now) {
habit.checkIns.append(day)
}
}
container.mainContext.insert(habit)
return NavigationStack {
HabitDetailView(habit: habit)
}
.modelContainer(container)
}
5. Daily reminders and Privacy Manifest
Schedule a per-habit UNCalendarNotificationTrigger when the user saves a new habit. Then add a PrivacyInfo.xcprivacy file — this is mandatory for App Store submission and will cause an automatic rejection at upload if missing.
// Managers/NotificationManager.swift
import UserNotifications
@Observable
final class NotificationManager {
static let shared = NotificationManager()
private(set) var isAuthorized = false
private init() {}
func requestAuthorization() async {
let center = UNUserNotificationCenter.current()
let granted = (try? await center.requestAuthorization(
options: [.alert, .sound, .badge]
)) ?? false
isAuthorized = granted
}
func scheduleDailyReminder(for habit: Habit) {
let center = UNUserNotificationCenter.current()
let content = UNMutableNotificationContent()
content.title = "\(habit.emoji) Habit reminder"
content.body = "Time to \(habit.name.lowercased())!"
content.sound = .default
var components = DateComponents()
components.hour = habit.reminderHour
components.minute = habit.reminderMinute
let trigger = UNCalendarNotificationTrigger(
dateMatching: components,
repeats: true
)
let request = UNNotificationRequest(
identifier: habit.id.uuidString,
content: content,
trigger: trigger
)
center.add(request)
}
func cancelReminder(for habit: Habit) {
UNUserNotificationCenter.current()
.removePendingNotificationRequests(withIdentifiers: [habit.id.uuidString])
}
}
// ---------------------------------------------------------
// PrivacyInfo.xcprivacy
// In Xcode: File → New File → Privacy Manifest
//
// Set NSPrivacyTracking to NO (this app doesn't track users).
// Set NSPrivacyCollectedDataTypes to an empty array if you
// collect no user data beyond on-device habit records.
//
// If you use UserDefaults anywhere, add:
// NSPrivacyAccessedAPITypes → NSPrivacyAccessedAPICategoryUserDefaults
// Reason: CA92.1 (access info from same app)
// ---------------------------------------------------------
// Info.plist — add this key or App Store review rejects immediately:
// NSUserNotificationsUsageDescription
// Value: "We send daily reminders to help you stay on track with your habits."
Common pitfalls
-
✕
Timezone bugs in streak logic. Always normalise dates with
Calendar.current.startOfDay(for:)before comparison. Storing rawDatevalues and using==directly will silently break streaks when a user crosses midnight in a different timezone. -
✕
Notifications are unreliable in Simulator.
UNCalendarNotificationTriggerwithrepeats: truedoes not fire on schedule in the iOS Simulator. Always test reminder delivery on a physical device before submitting to App Store review. -
✕
Missing Privacy Manifest causes automatic rejection at upload. Since early 2025, App Store Connect rejects
.ipafiles missingPrivacyInfo.xcprivacybefore a human reviewer ever sees your app. Add it early and declare any required-reason APIs (e.g.UserDefaultsaccess). -
✕
Adding new
@Modelproperties without defaults crashes on upgrade. SwiftData requires a schema migration when you add a stored property with no default. Always provide a default value (= false,= "", etc.) or define aVersionedSchemamigration plan before shipping a model change. -
✕
Missing
NSUserNotificationsUsageDescriptionin Info.plist. App Store review checks this key independently of runtime permission requests. A missing or vague description string ("We use notifications") frequently triggers rejection with a request to be more specific about the purpose.
Adding monetization: One-time purchase
Implement a permanent unlock using StoreKit 2. In App Store Connect, create a Non-Consumable in-app purchase — for example com.yourapp.habittracker.pro — and gate premium features such as unlimited habits (beyond a free tier of three) and the weekly chart view behind it. At app launch, call Product.products(for: ["com.yourapp.habittracker.pro"]) to fetch the product, then listen for entitlement changes with Transaction.updates in a long-lived .task. When the user taps "Unlock", call product.purchase(), await the Product.PurchaseResult, and handle .success, .userCancelled, and .pending. Always call transaction.finish() after verifying a successful purchase to clear it from the queue. Check current entitlement on every cold launch with Transaction.currentEntitlement(for:) so restored purchases are honoured without a separate "Restore" UI step.
Shipping this faster with Soarias
Soarias automates the parts of this build that take the longest to get right. It scaffolds the SwiftData stack, NotificationManager, and StoreKit paywall from your description, statically analyses which privacy-required APIs your code calls and generates the correct PrivacyInfo.xcprivacy entries, wires up fastlane snapshot for automated screenshots across all required device sizes, and submits to App Store Connect with all required metadata fields pre-filled — including age rating, export compliance, and content rights declarations.
For a beginner-complexity app like this one, Soarias typically cuts the path from blank Xcode project to a live TestFlight build from two full weekends to an afternoon. The scaffolding alone saves a few hours; the Privacy Manifest generation and fastlane wiring save another day of documentation spelunking — time you can spend refining the streak animation and copy-testing your paywall instead.
FAQ
Does this work on iOS 16?
No. This guide uses SwiftData (@Model, @Query) and ContentUnavailableView, both of which require iOS 17 or later. If you need iOS 16 support, replace SwiftData with Core Data or a Codable + FileManager persistence layer, and rebuild the empty-state view manually.
Do I need a paid Apple Developer account to test?
No — you can run the full app on the iOS Simulator and on your personal device via free provisioning without the $99/year membership. You do need a paid account to distribute via TestFlight, access push notification production certificates, and submit to the App Store.
How do I submit this to the App Store?
Archive the app in Xcode via Product → Archive, then upload through the Organizer or fastlane deliver. In App Store Connect, complete the required metadata: description, keywords, screenshots for every required device size (iPhone 16 Pro Max, iPhone SE, and iPad if you support it), privacy nutrition labels, age rating questionnaire, and export compliance. After all fields are green, click Submit for Review. Expect 24–48 hours for a first-time review.
What happens to the streak if the user misses a day?
The streak resets to zero — intentionally. Strict streaks are what make the mechanic motivating. If you want a "grace day" variant, modify currentStreak to allow one gap: walk backwards and skip one missing day before breaking the loop. Be thoughtful here — overly lenient streak rules can draw App Store review scrutiny around misleading gamification, especially if your paywall highlights the streak count as a value proposition.
Last reviewed: 2026-05-12 by the Soarias team.