Introduction
We've covered SwiftUI fundamentals, state management, and networking in previous articles. Now it's time to put those pieces together and build something real: a habit tracker that users will actually use. Building a real app: habit tracker — a step-by-step guide means tackling persistence, widgets, notifications, and a polished UI all at once—exactly what you'll encounter in production.
A habit tracker is an ideal capstone project because it forces you to think about data modeling, local storage, background notifications, and home screen presence. By the end of this guide, you'll have a working app that syncs habits to the lock screen and reminds users daily to check in.
Model: SwiftData Habit and CheckIn Models
SwiftData replaced Core Data as the modern persistence layer in iOS 17. It's lighter and integrates seamlessly with SwiftUI's observation system. Let's start with our domain models.
A habit needs a name, a description, a creation date, and a way to track daily completions. We'll use two models: Habit for the definition and CheckIn for each day's completion record.
import SwiftData
import Foundation
@Model
final class Habit {
var id: UUID = UUID()
var name: String
var description: String
var createdAt: Date = Date()
var color: String = "blue"
@Relationship(deleteRule: .cascade, inverse: \CheckIn.habit)
var checkIns: [CheckIn] = []
init(name: String, description: String, color: String = "blue") {
self.name = name
self.description = description
self.color = color
}
func isCompletedToday() -> Bool {
let today = Calendar.current.startOfDay(for: Date())
return checkIns.contains { Calendar.current.startOfDay(for: $0.date) == today }
}
func completionStreak() -> Int {
var streak = 0
let today = Calendar.current.startOfDay(for: Date())
for i in 0... {
let checkDate = Calendar.current.date(byAdding: .day, value: -i, to: today)!
if checkIns.contains(where: { Calendar.current.startOfDay(for: $0.date) == checkDate }) {
streak += 1
} else {
break
}
}
return streak
}
}
@Model
final class CheckIn {
var id: UUID = UUID()
var date: Date = Date()
var habit: Habit?
init(date: Date = Date(), habit: Habit? = nil) {
self.date = date
self.habit = habit
}
}Notice the cascade delete rule: when a habit is deleted, all its check-ins go with it. The isCompletedToday() method checks if there's a check-in for today's date. The completionStreak() method counts consecutive days backward from today—useful for gamification.
These models are simple but powerful. In a production app, you might add recurrence patterns (daily, weekdays only), tags, or color preferences. The pattern here scales well.
UI: List of Habits with Daily Check-Off
The main screen shows a list of habits and allows users to tap a button to mark them complete for today. We'll use a SwiftUI list with a chevron-style indicator and a tap gesture.
import SwiftUI
import SwiftData
struct HabitListView: View {
@Query(sort: \Habit.createdAt, order: .reverse) var habits: [Habit]
@Environment(\.modelContext) var modelContext
@State private var showAddSheet = false
var body: some View {
NavigationStack {
List {
ForEach(habits) { habit in
NavigationLink(destination: HabitDetailView(habit: habit)) {
HabitRowView(habit: habit)
}
}
.onDelete(perform: deleteHabit)
}
.navigationTitle("Habits")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: { showAddSheet = true }) {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddSheet) {
AddHabitView()
.environment(\.modelContext, modelContext)
}
}
}
private func deleteHabit(_ indexSet: IndexSet) {
indexSet.forEach { index in
let habit = habits[index]
modelContext.delete(habit)
}
}
}
struct HabitRowView: View {
@Bindable var habit: Habit
@Environment(\.modelContext) var modelContext
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(habit.name)
.font(.headline)
Text(habit.description)
.font(.caption)
.foregroundColor(.secondary)
HStack(spacing: 12) {
Label("\(habit.completionStreak()) day streak", systemImage: "flame.fill")
.font(.caption2)
.foregroundColor(.orange)
}
}
Spacer()
Button(action: {
if habit.isCompletedToday() {
if let checkIn = habit.checkIns.first(where: { Calendar.current.startOfDay(for: $0.date) == Calendar.current.startOfDay(for: Date()) }) {
habit.checkIns.removeAll { $0.id == checkIn.id }
}
} else {
let newCheckIn = CheckIn(date: Date(), habit: habit)
habit.checkIns.append(newCheckIn)
}
try? modelContext.save()
}) {
Image(systemName: habit.isCompletedToday() ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundColor(habit.isCompletedToday() ? .green : .gray)
}
.buttonStyle(.plain)
}
.padding(.vertical, 8)
}
}
struct AddHabitView: View {
@Environment(\.dismiss) var dismiss
@Environment(\.modelContext) var modelContext
@State private var name = ""
@State private var description = ""
@State private var selectedColor = "blue"
let colors = ["red", "orange", "yellow", "green", "blue", "purple"]
var body: some View {
NavigationStack {
Form {
Section("Habit Details") {
TextField("Habit name", text: $name)
TextField("Description", text: $description)
}
Section("Color") {
HStack {
ForEach(colors, id: \.self) { color in
Circle()
.fill(Color(color))
.frame(width: 44, height: 44)
.overlay(
Circle()
.stroke(Color.primary, lineWidth: selectedColor == color ? 2 : 0)
)
.onTapGesture {
selectedColor = color
}
}
}
}
}
.navigationTitle("New Habit")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
let habit = Habit(name: name, description: description, color: selectedColor)
modelContext.insert(habit)
dismiss()
}
.disabled(name.isEmpty)
}
}
}
}
}The key here is the @Query macro, which automatically re-runs whenever SwiftData detects changes. When a user taps the check-in button, we create or remove a CheckIn record and call try? modelContext.save(). The UI responds immediately because of SwiftUI's observation.
Widgets: Today's Progress on the Home Screen
WidgetKit lets users see habit progress without opening the app. A simple widget shows how many habits are complete today out of the total.
import WidgetKit
import SwiftUI
import SwiftData
struct HabitWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> HabitWidgetEntry {
HabitWidgetEntry(
date: Date(),
completedCount: 0,
totalCount: 0
)
}
func getSnapshot(in context: Context, completion: @escaping (HabitWidgetEntry) -> Void) {
let entry = HabitWidgetEntry(
date: Date(),
completedCount: 0,
totalCount: 0
)
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
// Load from shared user defaults or SwiftData
let defaults = UserDefaults(suiteName: "group.com.yourapp.habits")
let completed = defaults?.integer(forKey: "completedToday") ?? 0
let total = defaults?.integer(forKey: "totalHabits") ?? 0
let entry = HabitWidgetEntry(
date: Date(),
completedCount: completed,
totalCount: total
)
// Refresh tomorrow at midnight
let nextRefresh = Calendar.current.date(byAdding: .day, value: 1, to: Date())!
let timeline = Timeline(entries: [entry], policy: .after(nextRefresh))
completion(timeline)
}
}
struct HabitWidgetEntry: TimelineEntry {
let date: Date
let completedCount: Int
let totalCount: Int
}
struct HabitWidgetEntryView: View {
var entry: HabitWidgetProvider.Entry
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Today's Habits")
.font(.headline)
HStack {
VStack(alignment: .leading) {
Text("\(entry.completedCount)/\(entry.totalCount)")
.font(.system(.title, design: .rounded))
.fontWeight(.bold)
Text("Completed")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
ZStack {
Circle()
.stroke(Color.gray.opacity(0.2), lineWidth: 4)
Circle()
.trim(from: 0, to: entry.totalCount > 0 ? CGFloat(entry.completedCount) / CGFloat(entry.totalCount) : 0)
.stroke(Color.green, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(-90))
}
.frame(width: 60, height: 60)
}
}
.padding()
.background(Color(.systemBackground))
}
}
@main
struct HabitWidget: Widget {
let kind: String = "HabitWidget"
var body: some WidgetConfiguration {
StaticConfiguration(
kind: kind,
provider: HabitWidgetProvider()
) { entry in
HabitWidgetEntryView(entry: entry)
}
.configurationDisplayName("Today's Habits")
.description("See your daily habit completion at a glance")
.supportedFamilies([.systemSmall])
}
}Widgets can't directly access SwiftData (they run in an extension process), so we sync data via shared UserDefaults with an app group. Every time the main app updates a habit, it writes the counts to this shared defaults. The widget reads from there and refreshes at midnight.
Notifications: Daily Reminders with Local Notifications
Local notifications remind users to check in. We schedule them once at app launch and reschedule them daily. iOS handles the delivery even if the app is closed.
import UserNotifications
class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationManager()
func requestAuthorization() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
}
func scheduleDailyReminder(at hour: Int, minute: Int) {
let content = UNMutableNotificationContent()
content.title = "Time for your habits"
content.body = "Check in on your daily habits"
content.sound = .default
content.badge = NSNumber(value: UIApplication.shared.applicationIconBadgeNumber + 1)
var dateComponents = DateComponents()
dateComponents.hour = hour
dateComponents.minute = minute
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true)
let request = UNNotificationRequest(identifier: "dailyHabitReminder", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Error scheduling notification: \(error)")
}
}
}
func cancelReminder() {
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["dailyHabitReminder"])
}
}In your app's main view or scene delegate, request authorization and schedule the reminder:
import SwiftUI
@main
struct HabitTrackerApp: App {
init() {
NotificationManager.shared.requestAuthorization()
NotificationManager.shared.scheduleDailyReminder(at: 9, minute: 0) // 9 AM daily
}
var body: some Scene {
WindowGroup {
HabitListView()
.modelContainer(for: Habit.self)
}
}
}The notification fires at 9 AM every day. Users can customize the time in settings by re-scheduling with a different hour and minute.
Common Mistakes
- Not saving SwiftData changes: Remember that inserting or modifying models doesn't auto-save. Call
try? modelContext.save()after mutations. - Widgets accessing SwiftData directly: Widgets run in their own process and can't read SwiftData. Use shared
UserDefaultsor a shared database instead. - Forgetting to set app group identifier: If your main app and widget need to share data, both targets must have the same app group in capabilities.
- Scheduling notifications without user permission: Always request authorization first. iOS silently fails if permission is denied.
- Not resetting the badge count: Remember to set
UIApplication.shared.applicationIconBadgeNumber = 0when users open the app, or badges pile up.
Key Takeaways
- SwiftData models with relationships handle complex data structures cleanly; use cascade delete rules to maintain referential integrity.
- The
@Querymacro keeps UI in sync with data changes automatically—no manual fetching required. - Widgets can't access SwiftData; sync state via shared
UserDefaultsand app groups. - Local notifications require explicit user permission and must be scheduled during app initialization.
- Test notifications and widgets on real devices; the simulator's notification timing and widget refresh can be unreliable.
- A streak counter is a simple but effective way to drive user engagement—show it prominently.






Comments
Loading comments…