SwiftData Tutorial: Create an Expenses Tracker App with SwiftData
Welcome to the SwiftData Tutorial! In this guide, you’ll learn how to integrate SwiftData into your SwiftUI project to build an Expenses Tracker app with features like creating, reading, updating, and deleting data.
Getting Started
- Clone the Starter Project: Clone or download the starter project from the GitHub repository at the
starter_project
branch:
GitHub - drawrs/SwiftData-Expense-Tracker-App/tree/starter_project - Open the Project: Once downloaded, open the project in Xcode.
Integrating SwiftData into Your SwiftUI Project
When creating a new SwiftUI project, you can choose to include SwiftData. If you selected this option, Xcode will automatically generate SwiftData model samples, which you can find in the Project Navigator. If not, you’ll need to set up SwiftData manually.
Step-by-Step Integration
These are the steps that we’re going to do on this tutorial:
- Set Up SwiftData Models: Define the data models you need for the Expenses Tracker app.
- Set Up Model Container and Schema: Configure the model container and schema to manage your data effectively.
- Create (Save) Data: Implement the functionality to save new category & expense.
- Read (Fetch) Data: Add code to fetch and display saved categories & expenses.
- Update (Edit) Data: Enable editing of existing category entries.
- Delete (Remove) Data: Allow deletion of expenses & categories from the tracker.
Okay let’s get started!
Entity-Relationship Diagram (ERD) of Expenses Tracker App
This serves as the visual representation of our Expense Tracker app’s database structure. We’ll utilize this diagram as a guiding blueprint for constructing our SwiftData models.
Category Model
Begin by updating Category.swift
located within the Models/SwiftData
folder:
import Foundation
import SwiftData
@Model
final class Category {
var name: String
@Relationship(deleteRule: .cascade, inverse: \Expense.category)
var expenses: [Expense]?
init(name: String) {
self.name = name
}
}
Expense Model
Next, make the necessary adjustments to Expense.swift
:
import Foundation
import SwiftData
@Model
final class Expense {
var amount: Double
var note: String
var date: Date
var createdAt: Date
@Attribute(.externalStorage)
var photo: Data?
var category: Category?
init(amount: Double, note: String, date: Date) {
self.amount = amount
self.note = note
self.date = date
self.createdAt = Date.now
}
}
With these updates, we’ve established the core structure of our data models, incorporating necessary relationships and attributes. Specifically, we’ve implemented a one-to-many relationship, indicating that a single category can contain multiple expense entries.
Now that our models and their relationships are defined, we’re ready to proceed the next steps.
⚠️ Note: The DetailExpenseView
will encounter an error because the Expense
model now contains additional properties. To resolve this issue, please open the DetailExpenseView
and update the preview code accordingly.
#Preview {
NavigationStack {
DetailExpenseView(expense: Expense(amount: 100,
note: "notes",
date: Date.now))
}
}
Step 2: Set Up Model Container and Schema
Open SwiftData_Expense_Tracker_AppApp, define the database configuration:
import SwiftUI
import SwiftData
@main
struct SwiftData_Expense_Tracker_AppApp: App {
var sharedModelContainer: ModelContainer = {
let scheme = Schema([
// entities here
Expense.self,
Category.self
])
let modelConfiguration = ModelConfiguration(schema: scheme,
isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: scheme, configurations: modelConfiguration)
} catch {
fatalError("Could not create model container \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(.light)
}
.modelContainer(sharedModelContainer)
}
}
Step 3: Create (Save) Data
Create new Category
Open ContentView.swift
, import SwiftData, and define the modelContext
environment variable:
import SwiftUI
import SwiftData // Don't forget to import the framework
struct ContentView: View {
// MARK: ModelContext for db manipulation
@Environment(\.modelContext) var modelContext
//... the rest of ContentView.swift code
}
Add the saveCategory()
function:
private func saveCategory() {
print("perform save")
let category = Category(name: categoryName)
modelContext.insert(category)
categoryName = ""
print("saved!")
}
Run the project, and you should be able to save a new category. However, it won’t appear on the list yet. We’ll address that in the next steps.
Create new Expense
Open EntryExpenseView.swift
, import SwiftData, and define the modelContext
environment variable:
struct EntryExpenseView: View {
@Environment(\.modelContext) var modelContext
@Query(sort: \Category.name, order: .forward) var categories: [Category]
//... the rest of the codes
}
Update the category picker section:
private var categoryPickerSection: some View {
Section {
Picker("Category", selection: $selectedCategoryID) {
Text("Choose category").tag(nil as Int?)
ForEach(categories) { category in
Text(category.name).tag(category.id.hashValue as Int?)
}
}
.pickerStyle(.navigationLink)
}
}
Update the save()
function:
private func save() {
guard let category = categories.first(where: { $0.id.hashValue == selectedCategoryID }) else { return }
let expense = Expense(amount: amount, note: notes, date: date)
expense.photo = selectedImageData
expense.category = category
modelContext.insert(expense)
print("saved!")
}
Run the project, and you should be able to save a new expense. However, it won’t appear on the list yet. We’ll work on that in the following steps.
Step 4: Read (Fetch) Data
Fetching Expense Categories
Open ContentView.swift
and add the @Query
property:
struct ContentView: View {
// MARK: SwiftData CRUD preparation
@Environment(\.modelContext) var modelContext
// MARK: Fetch categories with order by it's name
@Query(sort: \Category.name, order: .forward) var categories: [Category]
//... the rest of the codes
}
Update the topSpendingSection
:
private var topSpendingSection: some View {
Section {
ForEach(categories, id: \.self) { category in
NavigationLink(destination: ExpenseListView(category: category)) {
VStack {
HStack {
VStack(alignment: .leading) {
Text(category.name)
.font(.headline)
Text(category.totalExpenses().asRupiah())
.foregroundStyle(.secondary)
}
Spacer()
Text(category.expensePercentage(of: totalExpenses))
.font(.headline)
}
ProgressView(value: category.progressValue(for: totalExpenses))
.onAppear {
print(CGFloat(category.totalExpenses() / totalExpenses))
}
}
}
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
categoryName = category.name
isEditCategoryInputPresented.toggle()
} label: {
Text("Edit")
}
.tint(.blue)
}
}
.onDelete(perform: delete)
} header: {
HStack {
Text("Top Spending")
.font(.headline)
Spacer()
Button(action: {
categoryName = ""
isCategoryInputPresented.toggle()
}) {
Label("New category", systemImage: "plus")
.font(.subheadline)
}
}
}
}
You might get an error because we don’t have that function in our Category
model.
To resolve this error, add extensions inside your Category
model:
extension Category {
func totalExpenses() -> Double {
return (expenses ?? []).reduce(0) { $0 + $1.amount }
}
func expensePercentage(of totalExpenses: Double) -> String {
let percentage = totalExpenses == 0 ? 0 : self.totalExpenses() / totalExpenses
return String(format: "%.0f%%", percentage * 100)
}
func progressValue(for totalExpenses: Double) -> CGFloat {
let value = totalExpenses == 0 ? 0 : self.totalExpenses() / totalExpenses
return CGFloat(min(max(value, 0), 1))
}
}
Run the project, and you will see the result.
Fetching Total Expense
If you notice that the total expenses label and progress view for each expense category are not working as expected, follow these steps to fix the issue:
Update the calculateTotalExpenses()
function in ContentView.swift
:
private func calculateTotalExpenses() {
totalExpenses = categories.reduce(0) { $0 + $1.totalExpenses() }
}
Update the totalSpendingSection
view in ContentView.swift
private var totalSpendingSection: some View {
Section {
VStack {
Text("Total Spending")
.font(.headline)
.multilineTextAlignment(.center)
Text(totalExpenses.asRupiah())
.font(.largeTitle)
.padding(5)
Button(action: {
isEntryFormPresented.toggle()
}) {
Label("Record Expense", systemImage: "square.and.pencil")
.foregroundColor(.white)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.sheet(isPresented: $isEntryFormPresented, onDismiss: calculateTotalExpenses) {
EntryExpenseView(isPresented: $isEntryFormPresented)
}
}
.padding(.vertical)
}
}
Explanation:
- Updating the
calculateTotalExpenses()
Function:
This function sums up the expenses from all categories and assigns the result to thetotalExpenses
variable. - Updating the
totalSpendingSection
View:
This view displays the total spending amount and includes a button to record a new expense. When the entry form is dismissed, thecalculateTotalExpenses()
function is called to update the total expenses.
Now run the project, and you’ll see the result like this
Fetching Expenses List
Open ExpenseListView.swift
and update the body view like this:
var body: some View {
List {
ForEach(category?.expenses ?? [], id: \.self) { expense in
NavigationLink {
DetailExpenseView(expense: expense)
} label: {
HStack {
VStack(alignment: .leading) {
Text(expense.amount.asRupiah())
.font(.headline)
Text(expense.note)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Text(expense.date.asFormattedString())
}
}
}
.onDelete(perform: delete)
}
.navigationTitle(category?.name ?? "Title placeholder")
.navigationBarTitleDisplayMode(.large)
}
Fetching Expense Detail
To display the details of an expense, update your DetailExpenseView.swift
to look like this:
struct DetailExpenseView: View {
var expense: Expense
var body: some View {
Form {
Section(header: Text("Expense Details")) {
DetailRow(label: "Category", value: expense.category?.name ?? "Category Placeholder")
DetailRow(label: "Amount", value: expense.amount.asRupiah())
DetailRow(label: "Notes", value: expense.note)
}
if let photoData = expense.photo, let uiImage = UIImage(data: photoData) {
Section(header: Text("Receipt Photo")) {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10.0))
.padding(.vertical, 10)
}
}
}
.navigationTitle(expense.date.asFormattedString())
.navigationBarTitleDisplayMode(.large)
}
}
Now run your project and you’ll get this result:
Step 5: Update (Edit) Data
Note: In this example project, only the category name can be edited.
Open ContentView.swift
, and add a new state property to hold the category data that will be edited:
@State var selectedCategory: Category?
Modify the .swipeActions
modifier. When the "Edit" button is tapped, assign the category to the selectedCategory
variable
// ... other code
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
selectedCategory = category // Assign to selected category
categoryName = category.name
isEditCategoryInputPresented.toggle()
} label: {
Text("Edit")
}
.tint(.blue)
}
// ... other code
Update the saveEditCategory
function to save the edited category:
private func saveEditCategory() {
print("perform edit")
if let category = selectedCategory {
category.name = categoryName
do {
try modelContext.save()
print("Updated!")
} catch {
print("Failed to update: \(error.localizedDescription)")
}
}
}
Save your changes and run the project!
Step 6: Delete (Remove) Data
Delete Expense Item
Open ExpenseListView.swift
and add the model context property variable:
@Environment(\.modelContext) var modelContext
Update the delete(at:)
function to remove an expense item:
func delete(at offsets: IndexSet) {
for i in offsets {
if let expense = category?.expenses?[i] {
modelContext.delete(expense)
print("deleted!")
}
}
}
Delete Category Expenses
Open ContentView.swift
and update the delete(at:)
function to remove a category:
private func delete(at offsets: IndexSet) {
for i in offsets {
let category = categories[i]
modelContext.delete(category)
print("deleted!")
}
}
Save your changes and run the project!
Summary
In this tutorial, you’ve learned how to integrate SwiftData into your SwiftUI project to build a fully functional Expenses Tracker app. Here’s a recap of what we’ve covered:
- Setting Up SwiftData Models: Defined the
Category
andExpense
data models necessary for the app. - Configuring the Model Container and Schema: Configured the model container and schema to manage your data effectively.
- Creating (Saving) Data: Implemented functionality to save new expense categories and expense entries.
- Reading (Fetching) Data: Added code to fetch and display saved expense categories and their corresponding expenses.
- Updating (Editing) Data: Enabled the editing of existing categories.
- Deleting (Removing) Data: Allowed deletion of expenses and categories from the tracker.
By following these steps, you successfully integrated SwiftData into your SwiftUI project and built an Expenses Tracker app with robust CRUD functionalities.
Complete project
You can find the complete project on GitHub: drawrs/SwiftData-Expense-Tracker-App
This repository includes a demo project showcasing CRUD operations in SwiftData within the context of an Expense Tracker app.
Let get connected
Have questions? or want to have a private lesson with me about iOS development? Let’s connect!
Instagram: https://www.instagram.com/rz.khilman/