Xcoding with Alfian

Software Development Videos & Tutorials

Building Expense Tracker iOS App with Core Data & SwiftUI

Alt text

Persisting user data in a productivity-based offline application is the essential primary feature that we need to provide to users. There are many different data types associated with the recommended native storage solution in iOS, such as:

  • BLOB (Binary Large Object) type. Music, image, video, JSON, Plist archive files fall under this type. Apple recommends storing this in a file under the User document or temporary cache directory folder.
  • UserDefaults Key-Value storage type. It is a dictionary-based type suitable for storing bite-sized user preferences.
  • Core Data object graph-based storage. It's the best for storing relational based object similar to a database.


According to Apple, Core Data is recommended to use when we want to store data permanently for offline use or even as a cache when there is no internet connection. Under the hood, Core Data uses high performance and compact SQLite database as its default persistent store. There are many advantages of using Core Data instead of accessing SQLite API manually such as:

  • Automatically handles the mapping of object graph to the database storage.
  • Efficient memory usage with the use of fault and batch to load the object and property as needed.
  • Undo and Redo of individual or batched changes.
  • Provides background data task API to handle large dataset imports without blocking main UI thread.
  • View synchronization between data source and UI. For UIKit, FetchedResultsController can be used to sync data changes in table and collection views. While in SwiftUI, we can use @FetchedResults property wrapper to update the state of the View whenever the data source changes.
  • Provides versioning and migration when schema changes.


At last, our app size won't be affected as Core Data is built into the platform itself. Apple also uses it in most of its productivity-based apps, so we can ensure it is a reliable and battle-tested framework.

What We Will Build

In this tutorial, we are going to build an expense tracker iOS app using Core Data and SwiftUI. Here are the main features of the app:

  • Create, edit, and delete expense logs.
  • Display list of expenses.
  • Filter expenses by categories and search query.
  • Sort expenses by date or amount in the order of descending or ascending.
  • Display the dashboard of total expense sum for all and each category.
  • Display distribution of each category using a pie chart.


Before you begin, you can download and play around with the app from the completed project GitHub

The Starter Project

To begin, you need to download the Starter Project from GitHub. Most of the UI components, standard models, and helpers are already included in the starter project, so we can focus on implementing Core Data Stack within the app.

Here are the main models provided inside the starter project:

  • Category enum. It is an abstraction for the expense category, such as entertainment, food, transportation, utilities, etc . Each expense log belongs to a category.
  • SortOrder and Sort type enums . Both are an abstraction for the representation of sorting by date/order in ascending/descending order.
  • Utils struct provides a static property to retrieve formatter for number and date inside the app.


The app has two primary tabs, dashboard and logs tabs. For Views, we have several main components, such as:

  • DashboadTabView. It showcases the sum of the total expenses for all categories, individual categories, and categories expenses sum distribution inside a pie chart.
  • LogsTabView. This tab is a container that consists of the filters selection, sort by and order selection, and list of logs.
  • LogFormView. This view is a form used for creating and editing log. It has text fields for name and amount as well as a picker for date and category selection.
  • FilterCategoriesView. It is a carousel scroll view where users can apply multiple selections of categories as a filter. It accepts the binding of Set<Category> to bind the data from the parent state.
  • SelectSortOrderView. It is a HStack where the user can pick sort-by and order by type using segmented controls. It accepts the binding of SortOrder and SortType to bind the data from the parent state.
  • PieChartView. It is a Pie Chart View by AppPear. I cloned and made a slight modification to the source code so it can display different colors for each category.


Try to build the app and play around with it. The dashboard tab will be empty as we don't have any data yet to display. While in the logs tab, you can play around by applying categories filter and sort order types. Try to tap on the add button; the form will be presented, but the save button won't create a new log for now as we haven't implemented the Core Data persistence into the app.

Create Managed Object Data Model

To begin, let's create a new managed object data model file. Click on Menu Bar > New > File. Select Data Model from Core Data section and click next. You can also type data model from the filter text field to find it. Give it ExpenseTracker.xcdatamodeld as the filename.

Alt text

Click on the new file. It will open the Core Data editor. Click on the Add Entity button at the bottom to create one entity. Rename the entity to Expenselog. Click on the ExpenseLog, from the editor attributes section, click on + button 5 times to create new attributes. Here are the attributes with name and type association:

  • amount: Decimal.
  • date: Date.
  • category: String.
  • id: UUID.
  • name: String.


Rename all the attributes to match the list above. Xcode will automatically generate the managed object model subclass for the ExpenseLog. Close and reopen the Xcode to make sure the autogenerated class is added correctly to the project by Xcode.

Alt text

Create Core Data Stack and Inject Managed Object Context Dependency

Before we can use Core Data, we need to initialize a NSPersistentContainer at first. We will encapsulate this into the CoreDataStack class. In the initializer, we pass the containerName for initializing NSPersistentContainer, the container name itself is the name of the xcdatamodeld file, which in our case is ExpenseTracker.

Using NSPersistentContainer that was introduced by Apple in iOS 10, we don't have to manually initialize all Core Data Stacks such as the persistent store coordinator, description, file URL, main managed object context. It will automatically handle it for us as well as exposing property for the UI context and a function to generate a background thread context.

The ManagedObjectContext class itself will be the main workhorse where all the managed object models will be stored. It is not a thread-safe object, so we have to be very careful only to use the main thread context when fetching data from UI. A background thread context is suitable when we want to import large datasets from an API on the internet.

Create a new Swift File named CoreDataStack and copy the following code below. I also added a simple extension to save context only if it has changed.

import CoreData

class CoreDataStack {
    
    private let containerName: String
    var viewContext: NSManagedObjectContext { persistentContainer.viewContext }
    
    private lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: containerName)
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                print(error.localizedDescription)
            }
            print(storeDescription)
        })
        return container
    }()
    
    init(containerName: String) {
        self.containerName = containerName
        _ = persistentContainer
    }
}

extension NSManagedObjectContext {
    
    func saveContext() throws {
        guard hasChanges else { return }
        try save()
    }
}


Next, we will inject the managed object context into the root view using SwiftUI Environment so all the children view can explicitly receive this using environment keypath. We will do this in the SceneDelegate, copy the following code into the file.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?
    
    var coreDataStack = CoreDataStack(containerName: "ExpenseTracker")

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        let contentView = ContentView()
            .environment(\.managedObjectContext, coreDataStack.viewContext)

        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }
}

Extend Generated ExpenseLog Managed Object Model

Before we begin integrating the Core Data model to our views, we will create an extension for the ExpenseLog model that will make our life easier later. In here, we declare several properties such as:

  • categoryEnum. Core Data doesn't support enum as the attribute for an entity. We store the category as String, so using this computed property, we can easily access the category enum type for our ExpenseLog.
    • nameText. Core Data attributes are optional, so to make it easier integrating with SwiftUI, we create a non-optional computed property to return the name String with an empty string as default value.
    • dateText. In our LogListView, we use a relative date formatted text to display the date of the expense relative to the current date.
    • amountText. This helps us to display the amount as a currency number formatted text.


Create a new Swift file named ExpenseLog+Extension inside the Models folder and copy the following code.

import Foundation
import CoreData

extension ExpenseLog: Identifiable {
    
    var categoryEnum: Category {
        Category(rawValue: category ?? "") ?? .other
    }
    
    var nameText: String {
        name ?? ""
    }

    var dateText: String {
        Utils.dateFormatter.localizedString(for: date ?? Date(), relativeTo: Date())
    }
        
    var amountText: String {
        Utils.numberFormatter.string(from: NSNumber(value: amount?.doubleValue ?? 0)) ?? ""
    }


Before we move to the next section, navigate to Sort.swift file, and implement the sortDescriptor computed property to return the NSSortDescriptor based on the sort type of date or amount. We will use this on the next section to create a fetch request in the LogListView

struct ExpenseLogSort {
      // ....
    var sortDescriptor: NSSortDescriptor {
        switch sortType {
        case .date:
            return NSSortDescriptor(keyPath: \ExpenseLog.date, ascending: isAscending)
        case .amount:
            return NSSortDescriptor(keyPath: \ExpenseLog.amount, ascending: isAscending)
        }
    }
}

Using Fetch Request with Predicate and Sort Descriptor to Query Logs in LogListView

In this section, we are going to add FetchRequest on the LogListView to query our expense logs dataset from CoreData. To query data from a managed object context, we need to use NSFetchRequest. It will have several parameters to configure:

  • Entity Name. The string text of the entity named in the Data Model.
  • Sort Descriptors. An array of NSSortDescriptor to configure the order of the result set from Core Data. It uses the property key and sorts it in ascending or descending order.
  • Predicate (Optional). NSPredicate object where we can filter the results set by constraining the result to match a specific expression and value (similar to WHERE statement in SQL)


In SwiftUI, Apple creates a property wrapper named @FetchRequest where it drives and binds the View using a NSFetchRequest. Whenever the result set changes, it will update the View depending on the state of the data source. It makes sure the View and data source are synchronized automatically.

Navigate to the LogListView file, then copy the following code below:



import SwiftUI
import CoreData

struct LogListView: View {
        
    // 1
    @Environment(\.managedObjectContext)
    var context: NSManagedObjectContext
    
    // 2
    @FetchRequest(
        entity: ExpenseLog.entity(),
        sortDescriptors: [
            NSSortDescriptor(keyPath: \ExpenseLog.date, ascending: false)
        ]
    )
    private var result: FetchedResults<ExpenseLog>
    
    // 3
    init(predicate: NSPredicate?, sortDescriptor: NSSortDescriptor) {
        let fetchRequest = NSFetchRequest<ExpenseLog>(entityName: ExpenseLog.entity().name ?? "ExpenseLog")
        fetchRequest.sortDescriptors = [sortDescriptor]
        
        if let predicate = predicate {
            fetchRequest.predicate = predicate
        }
        _result = FetchRequest(fetchRequest: fetchRequest)
    }
    
    // 4
    var body: some View {
        List {
            ForEach(result) { (log: ExpenseLog) in
                Button(action: {
                    // TODO: Implement Edit
                }) {
                    HStack(spacing: 16) {
                        CategoryImageView(category: log.categoryEnum)
                        VStack(alignment: .leading, spacing: 8) {
                            Text(log.nameText).font(.headline)
                            Text(log.dateText).font(.subheadline)
                        }
                        Spacer()
                        Text(log.amountText).font(.headline)
                    }
                    .padding(.vertical, 4)
                }                
            }
            .onDelete(perform: onDelete)
        }
    }
    
    private func onDelete(with indexSet: IndexSet) {
        // TODO: Implement Delete
    }
}


To help you understand the detail of the code, I provided a detailed explanation for each of the number points:

  1. Using SwiftUI @Environment property wrapper, we inject the managed object context-dependency from the parent view.
  2. Using @FetchRequest property wrapper, we provide a default initializer and store the FetchedResults in a property named result. The default fetch request won't have any predicate and sort the result by date in descending order.
  3. We create an initializer that accepts an optional predicate and sort descriptor. The predicate will be used to filter the results based on selected categories and search text filter from the LogListView while sort descriptor will be used to sort the result based on amount or date in ascending or descending order.
  4. Inside the List, we use ForEach passing the result of ExpenseLog that is already conforming to Identifiable protocol. In each loop, we wrap the row inside a Button so it can be tappable to invoke a function. The row itself uses HStack containing the icon, text for name, formatted date, and amount.


Next, let's move to ExpenseLog+Extension file to add a method to generate NSPredicate based on the selected categories and search text. Copy the following code into the body of extension.

extension ExpenseLog {
       
    // ...
    // 1
    static func predicate(with categories: [Category], searchText: String) -> NSPredicate? {
        var predicates = [NSPredicate]()
        
        // 2
        if !categories.isEmpty {
            let categoriesString = categories.map { $0.rawValue }
            predicates.append(NSPredicate(format: "category IN %@", categoriesString))
        }
        
        // 3
        if !searchText.isEmpty {
            predicates.append(NSPredicate(format: "name CONTAINS[cd] %@", searchText.lowercased()))
        }
        
        // 4
        if predicates.isEmpty {
            return nil
        } else {
            return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
        }
    }
}


Here is the detailed explanation for each of the number points above:

  1. We declare a static function that accepts an array of categories and search text String. In the beginning, we initialize an empty array of NSPredicate.
  2. If the categories array is not empty, we map it to an array of string and append an NSPredicate using the IN expression to only return the category is contained inside the array.
  3. If search text is not empty, we append the NSPredicate using CONTAINS[cd]. This will only return the result if the name attribute contains the searchText String using case insensitive comparison .
  4. If predicates are not empty, we use NSCompoundPredicate to combine the predicates in the array using similar AND expression in SQL. If it is empty, we return nil, which won't filter the results in the fetch request.


Finally, let's move to the LogsTabView file and add the following code where we initialize the LogListView.

   LogListView(predicate: ExpenseLog.predicate(with: Array(selectedCategories), searchText: searchText), sortDescriptor: ExpenseLogSort(sortType: sortType, sortOrder: sortOrder).sortDescriptor)

Here, we initialize the LogListView using the predicate from the helper method we create before. The LogListView itself already provided the states for the set of selected filters, selected sort type and sort order, as well as the search text string. ExpenseLogSort sort is a wrapper that accepts the sort type and sort order and generates a sort descriptor based on those two values.

Try to build and run the project to make sure it compiled successfully. As of now, we don't have any data yet to display. So, let's move on to the next section, where we will implement create expense log!

Create or Edit Expense Log from LogFormView

In this section, we'll be working on the create expense log feature using Core Data. The included LogFormView already provide a form where user can input the expense data such as name, amount, category, and date. We just need to handle the save function by creating an expense log managed object and save it into the managed object context.

Navigate to LogFormView file and add the following code.

import SwiftUI
import CoreData

struct LogFormView: View {
    
    // ....
    //1
    var logToEdit: ExpenseLog?
    var context: NSManagedObjectContext
    
    // 2
    var title: String {
        logToEdit == nil ? "Create Expense Log" : "Edit Expense Log"
    }
        // ...
        
        // 3
    private func onSaveTapped() {
    
        let log: ExpenseLog
        if let logToEdit = self.logToEdit {
            log = logToEdit
        } else {
            log = ExpenseLog(context: self.context)
            log.id = UUID()
        }
        
        log.name = self.name
        log.category = self.category.rawValue
        log.amount = NSDecimalNumber(value: self.amount)
        log.date = self.date
        
        do {
            try context.save()
        } catch let error as NSError {
            print(error.localizedDescription)
        }
        
        self.presentationMode.wrappedValue.dismiss()
    }   
}


Here is the detailed explanation for each of the number points above:

  1. We add two new properties. First, the managed object context (notice we don't use environment to inject this as this View will be presented so it won't be the child of the root view). Second, we add an optional logToEdit property that will be passed when we want to edit a log.
  2. We update the title computed property to dynamically return the title for the navigation bar, depending on whether the logToEdit exists or not.
  3. In the onSaveTapped method, there are two possibilities of the log object depending on whether the logToEdit exists. If it exists, we assign that as the log, otherwise we initialize a new ExpenseLog in the context as well as assigning unique UUID. Next, we assign all the attributes using the value from the properties of the state. Finally, we just need to save the context and dismiss the presented form view.


Next, let's move to the LogsTabView. In this View, we have a navigation button item where the user can tap to create a new log. In SwiftUI, to be able to present a model view, we can use the sheet modifier. The sheet uses the state to determine whether it should be displayed. In this case, we use isAddFromPresented boolean property to toggle this to true when the user taps on the add button. Inside the sheet, we just need to declare the LogFormView passing the managed object context. Don't forget we also need to inject the managed object context property using the @Environment property wrapper.

struct LogsTabView: View {
            
                
      @Environment(\.managedObjectContext)
      var context: NSManagedObjectContext
            // ....
            
        var body: some View {
        NavigationView {
            ...
            .sheet(isPresented: $isAddFormPresented) {
                LogFormView(context: self.context)
            }
                      ...
        }
    }
}


Finally, let's move on to the LogListView. In this View, the user can tap on the row of the expense log to edit the associated log. In this case, we already wrapped our row inside a Button. Inside the action closure, we just need to assign the logToEdit state property with the selected log. The logToEdit state property will control whether the sheet containing the LogFormView will be presented using the binding passed to the initializer. If logToEdit is not nil, the sheet will be presented. In this case, we declare the LogFormView passing the logToEdit as well as initializing all the form properties using the existing log attributes values.

struct LogListView: View {
    // ...
    @State var logToEdit: ExpenseLog?

    var body: some View {
        List {
            ForEach(result) { (log: ExpenseLog) in
                Button(action: {
                    self.logToEdit = log
                }) {... }
             ... 
            .onDelete(perform: onDelete)
            .sheet(item: $logToEdit, onDismiss: {
                self.logToEdit = nil
            }) { (log: ExpenseLog) in
                LogFormView(
                    logToEdit: log,
                    context: self.context,
                    name: log.name ?? "",
                    amount: log.amount?.doubleValue ?? 0,
                    category: Category(rawValue: log.category ?? "") ?? .food,
                    date: log.date ?? Date()
                )
            }
        }
    }
    // ...
}


That's it! Try to build and run the project. Create several logs to play with the filtering and sorting! If you have a problem setting the amount text field, make sure to press enter/return on the keyboard before assigning the value so it can be properly formatted to number.

Delete Expense Log from LogListView

For the deletion of log, we just need to implement the onDelete(with:IndexSet. This method will be invoked when the user performs a left swipe gesture to delete a row in the list. It will pass the IndexSet containing the index of rows. In this case, we can loop, retrieve the log, and pass it to the context for deletion. Also, we need to save the context for the deletion to be committed to the data store.

      private func onDelete(with indexSet: IndexSet) {
            indexSet.forEach { index in
             let log = result[index]
             context.delete(log)
        }
        try? context.saveContext()
    }

Using Expression and Fetch Request to query total expenses sum grouped by category in DashboardTabView

In this section, we are going to focus on the DashboardTabView. We need to fetch the sum of the total expenses for grouped by each of the categories so we can display it in a List as well as the Pie Chart.

Navigate to the ExpenseLog+Extension.swift file and copy the following code into the body of extension.

extension ExpenseLog {

        // ....
        
        // 1
    static func fetchAllCategoriesTotalAmountSum(context: NSManagedObjectContext, completion: @escaping ([(sum: Double, category: Category)]) -> ()) {
    
            // 2
        let keypathAmount = NSExpression(forKeyPath: \ExpenseLog.amount)
        let expression = NSExpression(forFunction: "sum:", arguments: [keypathAmount])
        
        let sumDesc = NSExpressionDescription()
        sumDesc.expression = expression
        sumDesc.name = "sum"
        sumDesc.expressionResultType = .decimalAttributeType
        
        // 3
        let request = NSFetchRequest<NSFetchRequestResult>(entityName: ExpenseLog.entity().name ?? "ExpenseLog")
        request.returnsObjectsAsFaults = false
        request.propertiesToGroupBy = ["category"]
        request.propertiesToFetch = [sumDesc, "category"]
        request.resultType = .dictionaryResultType
        
        // 4
        context.perform {
            do {
                let results = try request.execute()
                let data = results.map { (result) -> (Double, Category)? in
                    guard
                        let resultDict = result as? [String: Any],
                        let amount = resultDict["sum"] as? Double, amount > 0,
                        let categoryKey = resultDict["category"] as? String,
                        let category = Category(rawValue: categoryKey) else {
                            return nil
                    }
                    return (amount, category)
                }.compactMap { $0 }
                completion(data)
            } catch let error as NSError {
                print((error.localizedDescription))
                completion([])
            }
        }
        
    }
}


Here is the detailed explanation for each of the number points above:

  1. We declare a static method that accepts a managed object context. As this is an asynchronous operation, we'll use a closure as the completion handler passing the tuple array of Category associated with the value of the total sum for it.
  2. We declare NSExpression passing the keyPath of amount to the initializer. We create a second NSExpression passing :sum for the function to the initializer as well as passing the previous expression to the arguments. Finally, we create a NSExpressionDescription and assigns the expression and name. The expression result type is a .decimalAttributeType as our amount attribute type is Decimal.
  3. We declare NSFetchRequest passing the ExpenseLog as the entity name. We assign category as the propertiesToGroupBy as we want the result grouped by category. For propertiesToFech, we pass the sum expression and category so the request can return those two values in a single result. At last, we set the result type as .dictionaryResultType. In this case, the results will be an array of Dictionary with values of sum and category instead of managed object model.
  4. At last, we tell the context to execute the request and store the result in property. Then, we applied map for each of result so we can transform it to a tuple of category and value of total sum by parsing the dictionary using the sum and category key. Finally, we pass the array to the closure completion handler.


To integrate this, navigate to DashboardTabView and implement the fetchTotalSums method with the following code.

struct DashboardTabView: View {
    
    @Environment(\.managedObjectContext)
    var context: NSManagedObjectContext
    // ....

    func fetchTotalSums() {
        ExpenseLog.fetchAllCategoriesTotalAmountSum(context: self.context) { (results) in
            guard !results.isEmpty else { return }
            
            let totalSum = results.map { $0.sum }.reduce(0, +)
            self.totalExpenses = totalSum
            self.categoriesSum = results.map({ (result) -> CategorySum in
                return CategorySum(sum: result.sum, category: result.category)
            })
        }
    }
    
}


Here, we just use the static method from ExpenseLog to fetch the total sum for each category. Then, in the completion handler, we use reduce to get the total expenses for all categories and assign it to the totalExpenses state. At last, we map the array of a tuple into an array of CategorySum struct as this type implements Identifiable and will be used to drive the list of categories expense and the Pie Chart.

Try to build and run the project to see the final result of the project!

Conclusion

That's it! Congratulations on building your own expense tracker app using Core Data for persistence. With Core Data, we can create model entities, fetch data using complex predicate query filters, and sort descriptors. We also don't have to worry about view synchronization as we can use @FetchRequest property wrapper to bind between UI and data source change. In the next article, we will explore how we can sync our Core Data to the cloud using CloudKit integration.

Until the next time, let's keep the lifelong learning goes on!

Challenge

I have several challenges for you all to improve this App to be much better in terms of features with your new knowledge. There are several essential features that the App are still missing, such as:

  1. Monthly dashboard based View. Currently, we have shown all the total sum of expenses without constraining the date. You can improve this by showing current month data at a glance.
  2. Add an additional date range filter mechanism to filter the data based on the date when querying.
  3. Add Income log and budget features to the App.