Right now the Expense Tracker forgets everything when you close it. To keep data on the phone you need persistence. Apple's modern tool for it is SwiftData, which arrived with iOS 17. It sits on the same storage technology as the older Core Data, but it is written in plain Swift, and it works well with SwiftUI. You will still see Core Data and plain SQLite in jobs, so know that they exist. Start with SwiftData for new apps with a minimum of iOS 17.
Make the model storable
Change the Expense struct to a class and put @Model in front of it. This one macro makes the class storable and observable. A class is needed here because SwiftData tracks changes to one shared object, which is the case where lesson 4 said a class is the right choice.
import SwiftData
import SwiftUI
enum Category: String, Codable, CaseIterable {
case food = "Food", travel = "Travel", fun = "Fun", other = "Other"
}
@Model
final class Expense {
var title: String
var amount: Double
var category: Category
var date: Date
var note: String
init(title: String, amount: Double, category: Category = .other,
date: Date = .now, note: String = "") {
self.title = title
self.amount = amount
self.category = category
self.date = date
self.note = note
}
}
Properties of simple types, text, numbers, dates, booleans and enums that are Codable, are stored automatically. You do not write a table, a schema or any SQL. The id is looked after for you.
Give the app a container
A model container is the database for your app. You attach it once, at the top, and every view below it can use it.
@main
struct ExpenseTrackerApp: App {
var body: some Scene {
WindowGroup {
ExpenseListView()
}
.modelContainer(for: Expense.self)
}
}
Read with @Query, write with modelContext
@Query loads stored objects into a property and keeps it up to date. When something is added or changed, the view redraws by itself, the same way as with @State. To change data you use the model context, which the system puts in the environment.
struct ExpenseListView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \Expense.date, order: .reverse) private var expenses: [Expense]
@State private var showAdd = false
var body: some View {
NavigationStack {
List {
ForEach(expenses) { expense in
HStack {
VStack(alignment: .leading) {
Text(expense.title)
Text(expense.date, format: .dateTime.day().month())
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(expense.amount, format: .currency(code: "USD"))
}
}
.onDelete(perform: delete)
}
.overlay {
if expenses.isEmpty {
ContentUnavailableView("No expenses yet", systemImage: "tray",
description: Text("Tap + to add your first one."))
}
}
.navigationTitle("Expenses")
.toolbar {
Button("Add", systemImage: "plus") { showAdd = true }
}
.sheet(isPresented: $showAdd) {
ExpenseFormView { expense in
modelContext.insert(expense)
}
}
}
}
private func delete(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(expenses[index])
}
}
}
@Query(sort:order:)fetches and sorts. The path\Expense.datepoints to the property to sort by.modelContext.insert(_:)stores a new object, andmodelContext.delete(_:)removes one. SwiftData saves automatically at sensible moments, and you can calltry modelContext.save()when you want to be sure.- Editing is simply changing a property of a stored object, for example
expense.title = "Dinner". SwiftData notices, and the screen updates. ContentUnavailableViewis the built-in empty state for a screen with nothing to show.
Filtering
A predicate says which objects to load. It is written in normal-looking Swift with the #Predicate macro. Because the filter value often comes from outside the view, the usual trick is to set the query in the view's init.
struct FilteredExpenses: View {
@Query private var expenses: [Expense]
init(minimum: Double) {
_expenses = Query(
filter: #Predicate<Expense> { $0.amount >= minimum },
sort: [SortDescriptor(\Expense.amount, order: .reverse)]
)
}
var body: some View {
List(expenses) { Text($0.title) }
}
}
Previews and testing with a temporary database
In previews and tests you do not want to touch real data. Make a container that lives only in memory.
#Preview {
ExpenseListView()
.modelContainer(for: Expense.self, inMemory: true)
}
Small settings: AppStorage
For a single setting, such as a currency code or a dark mode choice, a database is too much. @AppStorage keeps a small value in the app's user defaults and acts like @State.
@AppStorage("currencyCode") private var currencyCode = "USD"
Things to know before you ship
- Changing the model later needs care. Adding a property with a default is usually easy. Renaming or removing one needs a *migration*. While you are learning, deleting the app from the Simulator starts you fresh. Before real users have data, read Apple's notes on schema versions.
- Money should use
Decimalin a real finance app, becauseDoublecannot hold some decimal values exactly. We keptDoubleto keep the lessons short. - Sync across devices is possible with iCloud, but it needs extra set-up in your developer account, and we do not cover it here. Services such as Firebase are another route, and many job posts mention them. Learn them after the basics.
- Core Data is older and still everywhere in existing apps. Its ideas, a container, a context, fetch requests and saving, map directly to what you just learned.
Your task
- Change
Expenseto a@Modelclass and add the model container to the app. - Replace the in-memory array with
@Query, and make add and swipe to delete work withmodelContext. - Close the app completely in the Simulator, open it again, and check that the data is still there.
- Add a screen that shows only expenses above an amount typed in a field, using
#Predicate.
How to check it worked
- Your expenses are still there after you restart the app.
- The newest expense is at the top.
- You can say the difference between
@State,@Queryand@AppStorage.