SwiftData Tutorial: Create an Expenses Tracker App with SwiftData

Rizal Hilman
8 min readMay 24, 2024

--

SwiftData Tutorial by Rizal Hilman (@rz.hilman)

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

  1. 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
  2. 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:

  1. Set Up SwiftData Models: Define the data models you need for the Expenses Tracker app.
  2. Set Up Model Container and Schema: Configure the model container and schema to manage your data effectively.
  3. Create (Save) Data: Implement the functionality to save new category & expense.
  4. Read (Fetch) Data: Add code to fetch and display saved categories & expenses.
  5. Update (Edit) Data: Enable editing of existing category entries.
  6. 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.

Entity-Relationship Diagram (ERD) of Expenses Tracker App

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:

  1. Updating the calculateTotalExpenses() Function:
    This function sums up the expenses from all categories and assigns the result to the totalExpenses variable.
  2. 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, the calculateTotalExpenses() 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 and Expense 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/

Linkedin: https://www.linkedin.com/in/rizal-hilman/

--

--

Rizal Hilman

Tech Mentor at Apple Developer Academy - Batam | Apple Swift Certified Trainer | Apple Professional Learning Specialist | Apple Teacher | WWDC19 Winner