Building Expense Tracker Apple TV App with SwiftUI & Core Data
Published at May 15, 2020
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
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.
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
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 simulateclick
, andescape
to simulate clickingmenu
.
Building The Log View
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 afocusable
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:
- There are 2 state properties:
sortType
to bind the selected sort type inSelectedSortOrderView
, andisAddPresented
as a boolean state to determine the presentation ofmodal sheet
when user taps on theAdd Button
. - The
List
is the main container for all the views. Views placed in theViewBuilder
are scrollable. - The first row in the
List
is aHStack
. The stack shows theAdd Button
and theSelectSortOrderView
segmented picker. - For the remaining rows, we use the
LogListView
passing the sort descriptor to the initializer. Notice that we useExpenseLogSort
struct to construct the sort descriptor based on the sort type and defaultdescending
sort order. - At last, we add a
sheet
modifier to theList
. We'll return theLogFormView
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
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:
- To bind all the attributes for the inputs in the
Form
, we declare 3 state properties:name
,amount
, andcategory
. - There are 2 properties that needs to be injected from the initializer:
logToEdit
which will be passed when user edit an existing log andcontext
which is the managed object context to save the expense. - The
title
is a computed property, the value is determined by thelogToEdit
. If it exists, the value will becreate
and vice versa, the value will beedit
. - We use
NavigationView
as the root view in thebody
. We need to use this for thePicker
to work properly when user click and navigate to the category selection screen. In theViewBuilder
we useForm
to wrap all the remaining views. - In the first section, we put the text fields for
name
andamount
. To be able to bind the amount text field to theamount
property with type ofdouble
, we need to pass theNumberFormatter
which will be used to convert thestring
todouble
when user tapsreturn
on the keyboard. For category selection, we usePicker
passing all cases of the category. - In the second section, we have a
Save Button
that will invoke theonSaveTapped
method when clicked. In addition, when users edit log, we show aDelete Button
that will invoke theonDeleteTapped
method when clicked. - In the
onSaveClicked
method, we create a newExpenseLog
instance with uniqueUUID
and currentDate
iflogToEdit
isnil
. 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
. - In the
onDeleteClicked
method, we delete thelogToEdit
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
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:
- We declare the
@Environment
property wrapper to retrieve the managed object context passed down from the parent view. - There are 2 state properties:
totalExpenses
which is the value of the total spending for all categories combined andcategoriesSum
which is the array ofCategorySum
struct representing total spending of a category. - We use
ZStack
as the root view so we're able to add anText
showing info to users when the expenses are empty. We'll also add theonAppear
modifier that will invoke thefetchTotalSums
method when the view appears. - To show
PieChart
andList
side-by-side horizontally, we useHStack
. At leading side, we check iftotalExpense
is notnil
and the value is larger thanzero
before we show thePieChart
and the total expense text in aVStack
. - Before showing the
List
, we check if thecategoriesSum
is notnil
. If exists, we declareList
passing thecategoriesSum
which already conforms toIdentifiable
protocol. To make the rowfocusable
when user scrolls, we wrap theCategoryRowView
in aButton
with empty action closure. - If the
totalExpenses
andcategoriesSum
arenil
, we show aText
containing the information for users to add new expense from the expenses tab. - In
fetchTotalSums
method, we invoke theExpenseLog
static method to fetch the total sum for all the categories. In the closure, we assign thetotalExpense
property by usingreduce
method to sum all the categories total sum. ForcategoriesSum
, we transform the results usingmap
method by returningCategorySum
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
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!