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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 12, 2026

Prerequisites

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

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.