Xcoding with Alfian

Software Development Videos & Tutorials

Building Expense Tracker Apple TV App with SwiftUI & Core Data

Alt text

This tutorial is the final part of the tutorial series on how to build an expense tracker cross-platform App with SwiftUI, Core Data, & CloudKit. I recommend all of you to read the previous tutorials first before continuing, here are the previous tutorials:

Introduction

Alt text

tvOS is the operating system used by Apple TV to deliver immersive and rich contents, media, games, apps to users through the living room. As developers, we can use many existing Apple technologies such as UIKit, CoreData, CloudKit, and Metal to create anything from utility apps, media streaming, and high performance 3D games. It also supports many new technologies such as 4K HDR10, Dolby Vision, and Dolby Atmos to deliver the best video and audio experience.

tvOS uses Siri Remote as the main input for users. With the touchpad, we can use gestures such as swipe, tap, and clicking to navigate through the operating system. It also has built-in accelerometer and gyroscope that can be used to built an interactive control for games.

Focus is used as the focal point when users navigate through each UI element using the remote. When element is in focus, it will be highlighted so users won't get lost when navigating through the contents. When designing app for tvOS, we need to really consider what elements are focusable.

Apps need to be immersive and maximizing the horizontal edge to edge space of the TV. As users will be interacting with the TV in distance, the UI elements such as Text and Button should be legible and large enough. You can learn more about the design guidelines from Apple HIG website on Apple TV.

We can leverage SwiftUI to build user interface for tvOS. The declarative, composable, and reactive paradigms of SwiftUI enables us to build dynamic user interface rapidly. Using SwiftUI, we can learn once and apply our knowledge to build user interfaces for any devices.

What We Will Build


In this tutorial, we'll be focusing to build Expense Tracker Apple TV App with the following features:

  • Dashboard View to view total spending for each category in a Pie Chart and List.
  • List view to show expenses with details such as name, amount, date, and category.
  • Form View to create, edit, and delete expense.


To build our app, there are several main components we need to build along the way, here they are:

  • Root View with Tab based navigation.
  • Log View.
  • Log Form View.


You can download the completed app from the GitHub repository. Try to play around with it using the Apple TV simulator in Xcode!

The Starter Project

To begin this project, you need to download the starter project from the GitHub repository. The starter project already includes many components that will help us to just focus on building the user interface for the current app, here they are:

  • Completed Expense Tracker iOS, macOS, and watchOS App targets from the previous tutorials. To learn more about the Core Data part, please refer to the part 1 of this tutorial series, Building Expense Tracker iOS App with Core Data & SwiftUI.
  • tvOS App Target with empty implementation.
  • Shared Models, Core Data Managed Object Model, Utils, as well as extensions to help us build the project. These source files uses target membership to target macOS, iOS, watchOS, and tvOS platforms.
Alt text

Make sure to select ExpenseTrackertvOS and Apple TV simulator from the scheme to build and run the project. Let's move on to the next section, where we will create the root tab based navigation for our App.

Building Root View with Tab

Alt text

The Root View uses Tab Bar to group the Dashboard and Log View in the same hierarchy at the App level. The tab bar stays pinned at the top of the screen while people navigate between the view. Whenever user click on the menu button, the focus will return to tab bar.

Navigate to ContentView.swift, and copy the following code.

import SwiftUI

struct ContentView: View {
    
    @State private var selection = 0
 
    var body: some View {
        TabView(selection: $selection){
            DashboardView()
                .tabItem {
                    HStack {
                        Image(systemName: "chart.pie")
                        Text("Dashboard")
                    }
                }
                .tag(0)
            
            LogView()
                .tabItem {
                    HStack {
                        Image(systemName: "dollarsign.circle")
                        Text("Expenses")
                    }
                }
                .tag(1)
        }
    }
}

We use TabView passing the selection state property to bind the selected index. In the ViewBuilder, we declare the DashboardView and LogView with their respective tabItem. Each TabItem uses HStack to display the icon and text for the tab.

To pass the managed object context down to the app tree, we'll use environment modifier. Navigate to AppDelegate.swift, and copy the following code.

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    let coreDataStack = CoreDataStack.shared

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        // Create the SwiftUI view that provides the window contents.
        let contentView = ContentView()
            .environment(\.managedObjectContext, coreDataStack.viewContext)
                // ...
    }
    // ...
}

We declare the CoreDataStack instance and store it as property, then we pass the viewContext using the environment modifier after we declare the ContentView. This will inject the context at the root level of the View.

Try to build and run the app, you should be able to see the tab bar at the top and to navigate between dashboard and log view!

To navigate in Apple TV Simulator, you can use the arrow keys to simulate swipe, enter to simulate click, and escape to simulate clicking menu.

Building The Log View

Alt text

The LogView consists of List of expenses, Add Button, and Sort by Picker with Segmented style. We'll put the List into LogListView and the segmented picker into SelectSortOrderView.
Create a new SwiftUI View named SelectSortOrderView, and copy the following code.

struct SelectSortOrderView: View {
    
    @Binding var sortType: SortType
    private let sortTypes: [SortType] =  [.date, .amount]
    
    var body: some View {
        HStack {
            Text("Sort by")
            Picker(selection: $sortType, label: Text("Sort by")) {
                ForEach(sortTypes) { type in
                    Text(type.rawValue.capitalized)
                        .tag(type)
                }
            }
            .pickerStyle(SegmentedPickerStyle())
        }
        .padding(.horizontal)
    }
}

We use HStack to put a Text label and Picker. In the picker's ViewBuilder, we use ForEach to an array containing the SortType enum cases for date and amount. To bind the selected sort type, we declare the sortType property with @Binding so the parent view can pass the state down and get notification when the value changes.

The LogListView contains the expenses from the FetchedResults to the ExpenseLog entity using @FetchRequest property wrapper. To show each expense in a row, we'll need to build and use the LogRowView.

struct LogRowView: View {
    
    @Binding var log: ExpenseLog
    
    var body: some View {
        HStack {
            CategoryImageView(category: log.categoryEnum)
            VStack(alignment: .leading) {
                Text(log.nameText)
                HStack(spacing: 4) {
                    Text(log.dateText)
                    Text("-")
                    Text(log.categoryText)
                }
                .font(.caption)
            }
            Spacer()
            Text(log.amountText)
        }
        .font(.headline)
        .padding(.vertical)
    }
}

Using HStack, we use the provided CategoryImageView to show the image of the category, followed by a VStack containing the name and nested HStack of date and category text. Last, we show the amount text, to push to the trailing edge, we put a Spacer in between.

With LogRowView in place, now we can build the LogListView.

struct LogListView: View {
    
    @Environment(\.managedObjectContext)
    var context
    
    @State var logToEdit: ExpenseLog?
    @FetchRequest(
        entity: ExpenseLog.entity(),
        sortDescriptors: [
            NSSortDescriptor(keyPath: \ExpenseLog.date, ascending: false)
        ]
    )
    var result: FetchedResults<ExpenseLog>
    
    init(sortDescriptor: NSSortDescriptor) {
        let fetchRequest = NSFetchRequest<ExpenseLog>(entityName: ExpenseLog.entity().name ?? "ExpenseLog")
        fetchRequest.sortDescriptors = [sortDescriptor]
        _result = FetchRequest(fetchRequest: fetchRequest)
    }
    
    var body: some View {
        ForEach(self.result) { (log: ExpenseLog) in
            Button(action: {
                self.logToEdit = log
            }) {
                LogRowView(log: log)
            }
        }
        .sheet(item: self.$logToEdit) { (log: ExpenseLog) in
                // TODO: LogFormView
        }
    }
}

We use @FetchRequest property wrapper to fetch the ExpenseLog into FetchedResults. As the user can change the sort order based by amount or date, we'll add a custom initializer where we can inject a sortDescriptor to construct a new fetch request with it. Before we're able to fetch data with FetchRequest , we need to inject managed object context using the @Environment property wrapper.

In the body of the View, we use ForEach passing the fetched results array. In the ViewBuilder, we use Button passing an action closure to assign the logToEdit state property with the selected log. At last, in the button's ViewBuilder, we return the LogRowView passing the log. The logEdit state determines whether the modal sheet to present the form will be shown.

Button is a focusable element which will get highlighted when the user navigates through it. When user click on the Siri Remote, it will invoke the action closure.

Finally, we can build the LogView from all the previous components we just built. Navigate to LogView and copy the following code.

struct LogView: View {
    
    // 1
    @State private var sortType = SortType.date
    @State private var isAddPresented: Bool = false
    
    var body: some View {
          // 2
        List {
                // 3
            HStack {
                Button(action: {
                    self.isAddPresented = true
                }) {
                    HStack(spacing: 32) {
                        Spacer()
                        Image(systemName: "plus.circle")
                        Text("Add Log")
                        Spacer()
                    }
                }
                .buttonStyle(BorderedButtonStyle())
                .font(.headline)
                
                Spacer()
                SelectSortOrderView(sortType: $sortType)
            }
            
            // 4
            LogListView(sortDescriptor: ExpenseLogSort(sortType: sortType, sortOrder: .descending).sortDescriptor)
        }
        .padding(.top)
        .sheet(isPresented: $isAddPresented) {
                // 5
                        // TODO: Return Log Form View
        }
    }
}

To explain all the code above, i provided several main points in a list, here they are:

  1. There are 2 state properties: sortType to bind the selected sort type in SelectedSortOrderView, and isAddPresented as a boolean state to determine the presentation of modal sheet when user taps on the Add Button.
  2. The List is the main container for all the views. Views placed in the ViewBuilder are scrollable.
  3. The first row in the List is a HStack. The stack shows the Add Button and the SelectSortOrderView segmented picker.
  4. For the remaining rows, we use the LogListView passing the sort descriptor to the initializer. Notice that we use ExpenseLogSort struct to construct the sort descriptor based on the sort type and default descending sort order.
  5. At last, we add a sheet modifier to the List. We'll return the LogFormView that we'll build on the next section.


That's it for the Log Tab! Try to build and run the project, you should be able to navigate to the expenses tab showing the add button and segmented picker. The list is empty for now, in the next section, we'll build the LogFormView so users can add and edit the expense!

Building The Log Form View

Alt text

The LogFormView is a View containing the Form for the user to create or edit expense name, amount using the TextField and Picker for assigning category.

Create a new file named LogFormView.swift and copy the following code.

struct LogFormView: View {
    
    // 1    
    @State private var name: String = ""
    @State private var amount: Double = 0
    @State private var category: Category = .utilities
    
    @Environment(\.presentationMode)
    var presentationMode
    
    // 2
    var logToEdit: ExpenseLog?
    var context: NSManagedObjectContext
    
    // 3
    var title: String {
        logToEdit == nil ? "Create Expense Log" : "Edit Expense Log"
    }
    
    var body: some View {
            // 4
        NavigationView {
            Form {
                  // 5
                Section {
                    HStack {
                        Text("Name")
                        Spacer()
                        TextField("Name", text: $name)
                    }
                    
                    HStack {
                        Text("Amount")
                        Spacer()
                        TextField("Amount", value: $amount, formatter: Utils.numberFormatter)
                    }
                    
                    Picker(selection: $category, label: Text("Category")) {
                        ForEach(Category.allCases) { category in
                            HStack {
                                CategoryImageView(category: category)
                                Text(category.rawValue.capitalized)
                            }
                            .tag(category)
                        }
                    }
                }
                
                // 6
                Section {
                    Button(action: self.onSaveTapped) {
                        HStack {
                            Spacer()
                            Text("Save")
                            Spacer()
                        }
                    }
                    
                    if self.logToEdit != nil {
                        Button(action: self.onDeleteTapped) {
                            HStack {
                                Spacer()
                                Text("Delete")
                                    .foregroundColor(Color.red)
                                Spacer()
                            }
                        }
                    }
                }
            }
            .navigationBarTitle(title)
        }
    }
    
    // 7
    private func onSaveClicked() {
        let log: ExpenseLog
        if let logToEdit = self.logToEdit {
            log = logToEdit
        } else {
            log = ExpenseLog(context: self.context)
            log.id = UUID()
            log.date = Date()
        }
        
        log.name = self.name
        log.category = self.category.rawValue
        log.amount = NSDecimalNumber(value: self.amount)
        do {
            try context.saveContext()
        } catch let error as NSError {
            print(error.localizedDescription)
        }
        self.presentationMode.wrappedValue.dismiss()
    }
    
    // 8
    private func onDeleteClicked() {
        guard let logToEdit = self.logToEdit else { return }
        self.context.delete(logToEdit)
        try? context.saveContext()
        self.presentationMode.wrappedValue.dismiss()
    }
}

To help explaining all the code above, i have break down them into several main points, here they are:

  1. To bind all the attributes for the inputs in the Form, we declare 3 state properties: name, amount, and category.
  2. There are 2 properties that needs to be injected from the initializer: logToEdit which will be passed when user edit an existing log and context which is the managed object context to save the expense.
  3. The title is a computed property, the value is determined by the logToEdit. If it exists, the value will be create and vice versa, the value will be edit.
  4. We use NavigationView as the root view in the body. We need to use this for the Picker to work properly when user click and navigate to the category selection screen. In the ViewBuilder we use Form to wrap all the remaining views.
  5. In the first section, we put the text fields for name and amount. To be able to bind the amount text field to the amount property with type of double, we need to pass the NumberFormatter which will be used to convert the string to double when user taps return on the keyboard. For category selection, we use Picker passing all cases of the category.
  6. In the second section, we have a Save Button that will invoke the onSaveTapped method when clicked. In addition, when users edit log, we show a Delete Button that will invoke the onDeleteTapped method when clicked.
  7. In the onSaveClicked method, we create a new ExpenseLog instance with unique UUID and current Date if logToEdit is nil. Then, we retrieve and assign all the state properties to the expense log. At last, we save the context and dismiss the modal sheet using presentation mode that we retrieve using the @Environment.
  8. In the onDeleteClicked method, we delete the logToEdit from the context, save, and dismiss the modal sheet.


Before we can use the LogFormView, we need to add it in 2 places: LogView and LogListView. Navigate to LogView, find the sheet modifier, and add the following code.

.sheet(isPresented: $isAddPresented) {
    LogFormView(context: self.context)
}

Navigate to LogListView, find the sheet modifier, and add the following code.

  .sheet(item: self.$logToEdit) { (log: ExpenseLog) in
    LogFormView(
        logToEdit: log,
        context: self.context,
        name: log.name ?? "",
        amount: log.amount?.doubleValue ?? 0,
        category: Category(rawValue: log.category ?? "") ?? .food
    )
}

In the edit form, we'll also pass the log attributes it can fill the textfields and picker with the existing value.

Try to build and run the project, you should be able to click the Add Button from the expenses tab. Fill the form using your keyboard and save. Also, try to edit and delete an expense log to make sure all the features are working properly.

Building The Dashboard View

Alt text

The DashboardView shows the Pie Chart of the total spending distribution for each category and List of category rows with total amount text side by side using a HStack . Before we build the DashboardView, let's build the CategoryRowView component first.

Create a View named CategoryRowView, and copy the following code.

struct CategoryRowView: View {
    let category: Category
    let sum: Double
    
    var body: some View {
        HStack {
            CategoryImageView(category: category)
            Text(category.rawValue.capitalized)
            Spacer()
            Text(sum.formattedCurrencyText)
        }
        .font(.headline)
        .padding(.vertical)
    }
}

We use HStack to show the CategoryImageView, name, and formatted amount text horizontally. To make sure the texts are legible, we add the headline font modifier.

With CategoryRowView in place, navigate to DashboardView and copy the following code.

struct DashboardView: View {
    
    // 1
    @Environment(\.managedObjectContext)
    var context

      // 2    
    @State private var totalExpenses: Double?
    @State private var categoriesSum: [CategorySum]?
    
    var body: some View {
              // 3
        ZStack {
                // 4
            HStack(alignment: .center, spacing: 64) {
                if totalExpenses != nil && totalExpenses! > 0 {
                    VStack(alignment: .center, spacing: 2) {
                        Text("Total expenses")
                            .font(.headline)
                        
                        if categoriesSum != nil  {
                            PieChartView(
                                data: categoriesSum!.map { ($0.sum, $0.category.color) },
                                style: Styles.pieChartStyleOne,
                                form: CGSize(width: 512, height: 384),
                                dropShadow: false
                            ).padding()
                        }
                        Text(totalExpenses!.formattedCurrencyText)
                            .font(.title)
                    }
                }
                
                // 5
                if categoriesSum != nil {
                    List(self.categoriesSum!) { (categorySum: CategorySum) in
                        Button(action: {}) {
                            CategoryRowView(category: categorySum.category, sum: categorySum.sum)
                        }
                    }
                    .listRowBackground(Divider())
                }
            }
            .padding(.top)
            
            // 6
            if totalExpenses == nil && categoriesSum == nil {
                Text("No expenses data\nPlease add log from the Expenses tab")
                    .multilineTextAlignment(.center)
                    .font(.headline)
                    .padding()
            }
        }
        .onAppear(perform: fetchTotalSums)
    }
    
    // 7
    func fetchTotalSums() {
        ExpenseLog.fetchAllCategoriesTotalAmountSum(context: self.context) { (results) in
            guard !results.isEmpty else {
                self.totalExpenses = nil
                self.categoriesSum = nil
                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)
            })
        }
    }
}

Explanation of each points in the code:

  1. We declare the @Environment property wrapper to retrieve the managed object context passed down from the parent view.
  2. There are 2 state properties: totalExpenses which is the value of the total spending for all categories combined and categoriesSum which is the array of CategorySum struct representing total spending of a category.
  3. We use ZStack as the root view so we're able to add an Text showing info to users when the expenses are empty. We'll also add the onAppear modifier that will invoke the fetchTotalSums method when the view appears.
  4. To show PieChart and List side-by-side horizontally, we use HStack. At leading side, we check if totalExpense is not nil and the value is larger than zero before we show the PieChart and the total expense text in a VStack.
  5. Before showing the List, we check if the categoriesSum is not nil. If exists, we declare List passing the categoriesSum which already conforms to Identifiable protocol. To make the row focusable when user scrolls, we wrap the CategoryRowView in a Button with empty action closure.
  6. If the totalExpenses and categoriesSum are nil, we show a Text containing the information for users to add new expense from the expenses tab.
  7. In fetchTotalSums method, we invoke the ExpenseLog static method to fetch the total sum for all the categories. In the closure, we assign the totalExpense property by using reduce method to sum all the categories total sum. For categoriesSum, we transform the results using map method by returning CategorySum instance containing the category with total sum.


Build and run the project to view the stats in PieChart and List in their glory! Make sure you have added several expense log with different categories before.

Setup Core Data CloudKit Syncing in tvOS Target

Alt text

To make sure Core Data CloudKit syncing is working, you need to add a valid CloudKit container with the same identifier to all the targets. To try this, make sure you sign in into iCloud in the simulator or your physical devices when you want to test.

Conclusion

Congratulations! you have successfully built the Expense Tracker Apple TV App with SwiftUI and Core Data. With this knowledge and experience, you should be able to create more amazing apps with your awesome creativity and ideas. This final part concludes the tutorial series on building an Expense Tracker cross-platform App with SwiftUI and Core Data.

Until the next one! Let's keep the lifelong learning goes on!