Day 37
Day 37 êŽë š
Project 7, part 2
Today youâre going to build a complete app using @ObservedObject
, @Published
, sheet()
, Codable
, UserDefaults
, and more. I realize that seems like a lot, but I want you to try to think about all the things that are happening in the background:
@Published
publishes change announcements automatically.@StateObject
watches for those announcements and refreshes any views using the object.sheet()
watches a condition we specify and shows or hides a view automatically.Codable
can convert Swift objects into JSON and back with almost no code from us.UserDefaults
can read and write data so that we can save settings and more instantly.
Yes, we need to write the code to put those things in place, but so much boilerplate code has been removed that what remains is quite remarkable. As French writer and poet Antoine de Saint-Exupery once said, âperfection is achieved not when there is nothing more to add, but rather when there is nothing more to take away.â
Today you have five topics to work through, in which youâll put into practice everything you learned about @StateObject
, sheet()
, onDelete()
, and more.
Building a list we can delete from
Building a list we can delete from
In this project we want a list that can show some expenses, and previously we would have done this using an @State
array of objects. Here, though, weâre going to take a different approach: weâre going to create an Expenses
class that will be attached to our list using @StateObject
.
This might sound like weâre over-complicating things a little, but it actually makes things much easier because we can make the Expenses
class load and save itself seamlessly â it will be almost invisible, as youâll see.
First, we need to decide what an expense is â what do we want it to store? In this instance it will be three things: the name of the item, whether itâs business or personal, and its cost as a Double
.
Weâll add more to this later, but for now we can represent all that using a single ExpenseItem
struct. You can put this into a new Swift file called ExpenseItem.swift
, but you donât need to â you can just put this into ContentView.swift
if you like, as long as you donât put it inside the ContentView
struct itself.
Regardless of where you put it, this is the code to use:
struct ExpenseItem {
let name: String
let type: String
let amount: Double
}
Now that we have something that represents a single expense, the next step is to create something to store an array of those expense items inside a single object. This needs to conform to the ObservableObject
protocol, and weâre also going to use @Published
to make sure change announcements get sent whenever the items
array gets modified.
As with the ExpenseItem
struct, this will start off simple and weâll add to it later, so add this new class now:
class Expenses: ObservableObject {
@Published var items = [ExpenseItem]()
}
That finishes all the data required for our main view: we have a struct to represent a single item of expense, and a class to store an array of all those items.
Letâs now put that into action with our SwiftUI view, so we can actually see our data on the screen. Most of our view will just be a List
showing the items in our expenses, but because we want users to delete items they no longer want we canât just use a simple List
â we need to use a ForEach
inside the list, so we get access to the onDelete()
modifier.
First, we need to add an @StateObject
property in our view, that will create an instance of our Expenses
class:
@StateObject var expenses = Expenses()
Remember, using @StateObject
here asks SwiftUI to watch the object for any change announcements, so any time one of our @Published
properties changes the view will refresh its body. Itâs only used when creating a class instance â all other times you âll use @ObservedObject
instead.
Second, we can use that Expenses
object with a NavigationView
, a List
, and a ForEach
, to create our basic layout:
NavigationView {
List {
ForEach(expenses.items, id: \.name) { item in
Text(item.name)
}
}
.navigationTitle("iExpense")
}
That tells the ForEach
to identify each expense item uniquely by its name, then prints the name out as the list row.
Weâre going to add two more things to our simple layout before weâre done: the ability to add new items for testing purposes, and the ability to delete items with a swipe.
Weâre going to let users add their own items soon, but itâs important to check that our list actually works well before we continue. So, weâre going to add a toolbar button that adds example ExpenseItem
instances for us to work with â add this modifier to the List
now:
.toolbar {
Button {
let expense = ExpenseItem(name: "Test", type: "Personal", amount: 5)
expenses.items.append(expense)
} label: {
Image(systemName: "plus")
}
}
That brings our app to life: you can launch it now, then press the + button repeatedly to add lots of testing expenses.
Now that we can add expenses, we can also add code to remove them. This means adding a method capable of deleting an IndexSet
of list items, then passing that directly on to our expenses
array:
func removeItems(at offsets: IndexSet) {
expenses.items.remove(atOffsets: offsets)
}
And to attach that to SwiftUI, we add an onDelete()
modifier to our ForEach
, like this:
ForEach(expenses.items, id: \.name) { item in
Text(item.name)
}
.onDelete(perform: removeItems)
Go ahead and run the app now, press + a few times, then swipe to delete the rows.
Now, remember: when we say id: \.name
weâre saying we can identify each item uniquely by its name, which isnât true here â we have the same name multiple times, and we canât guarantee our expenses will be unique either.
Often this will Just Work, but sometimes it will cause bizarre, broken animations in your project, so letâs look at a better solution next.
Working with Identifiable items in SwiftUI
Working with Identifiable items in SwiftUI
When we create static views in SwiftUI â when we hard-code a VStack
, then a TextField
, then a Button
, and so on â SwiftUI can see exactly which views we have, and is able to control them, animate them, and more. But when we use List
or ForEach
to make dynamic views, SwiftUI needs to know how it can identify each item uniquely otherwise it will struggle to compare view hierarchies to figure out what has changed.
In our current code, we have this:
ForEach(expenses.items, id: \.name) { item in
Text(item.name)
}
.onDelete(perform: removeItems)
In English, that means âcreate a new row for every item in the expense items, identified uniquely by its name, showing that name in the row, and calling the removeItems()
method to delete it.â
Then, later, we have this code:
Button {
let expense = ExpenseItem(name: "Test", type: "Personal", amount: 5)
expenses.items.append(expense)
} label: {
Image(systemName: "plus")
}
Every time that button is pressed, it adds a test expense to our list, so we can make sure adding and deleting works.
Can you see the problem?
Every time we create an example expense item weâre using the name âTestâ, but weâve also told SwiftUI that it can use the expense name as a unique identifier. So, when our code runs and we delete an item, SwiftUI looks at the array beforehand â âTestâ, âTestâ, âTestâ, âTestâ â then looks at the array afterwards â âTestâ, âTestâ, âTestâ â and canât easily tell what changed. Something has changed, because one item has disappeared, but SwiftUI canât be sure which.
In this situation weâre lucky, because List
knows exactly which row we were swiping on, but in many other places that extra information wonât be available and our app will start to behave strangely.
This is a logic error on our behalf: our code is fine, and it doesnât crash at runtime, but weâve applied the wrong logic to get to that end result â weâve told SwiftUI that something will be a unique identifier, when it isnât unique at all.
To fix this we need to think more about our ExpenseItem
struct. Right now it has three properties: name
, type
, and amount
. The name by itself might be unique in practice, but itâs also likely not to be â as soon as the user enters âLunchâ twice weâll start hitting problems. We could perhaps try to combine the name, type and amount into a new computed property, but even then weâre just delaying the inevitable; itâs still not really unique.
The smart solution here is to add something to ExpenseItem
that is unique, such as an ID number that we assign by hand. That would work, but it does mean tracking the last number we assigned so we donât use duplicates there either.
There is in fact an easier solution, and itâs called UUID
â short for âuniversally unique identifierâ, and if that doesnât sound unique Iâm not sure what does.
UUIDs are long hexadecimal strings such as this one: 08B15DB4-2F02-4AB8-A965-67A9C90D8A44. So, thatâs eight digits, four digits, four digits, four digits, then twelve digits, of which the only requirement is that thereâs a 4 in the first number of the third block. If we subtract the fixed 4, we end up with 31 digits, each of which can be one of 16 values â if we generated 1 UUID every second for a billion years, we might begin to have the slightest chance of generating a duplicate.
Now, we could update ExpenseItem
to have a UUID
property like this:
struct ExpenseItem {
let id: UUID
let name: String
let type: String
let amount: Int
}
And that would work. However, it would also mean we need to generate a UUID
by hand, then load and save the UUID
along with our other data. So, in this instance weâre going to ask Swift to generate a UUID
automatically for us like this:
struct ExpenseItem {
let id = UUID()
let name: String
let type: String
let amount: Int
}
Now we donât need to worry about the id
value of our expense items â Swift will make sure they are always unique.
With that in place we can now fix our ForEach
, like this:
ForEach(expenses.items, id: \.id) { item in
Text(item.name)
}
If you run the app now youâll see our problem is fixed: SwiftUI can now see exactly which expense item got deleted, and will animate everything correctly.
Weâre not done with this step quite yet, though. Instead, Iâd like you to modify the ExpenseItem
to make it conform to a new protocol called Identifiable
, like this:
struct ExpenseItem: Identifiable {
let id = UUID()
let name: String
let type: String
let amount: Int
}
All weâve done is add Identifiable
to the list of protocol conformances, nothing more. This is one of the protocols built into Swift, and means âthis type can be identified uniquely.â It has only one requirement, which is that there must be a property called id that contains a unique identifier. We just added that, so we donât need to do any extra work â our type conforms to Identifiable
just fine.
Now, you might wonder why we added that, because our code was working fine before. Well, because our expense items are now guaranteed to be identifiable uniquely, we no longer need to tell ForEach
which property to use for the identifier â it knows there will be an id
property and that it will be unique, because thatâs the point of the Identifiable
protocol.
So, as a result of this change we can modify the ForEach
again, to this:
ForEach(expenses.items) { item in
Text(item.name)
}
Much better!
Sharing an observed object with a new view
Sharing an observed object with a new view
Classes that conform to ObservableObject
can be used in more than one SwiftUI view, and all of those views will be updated when the published properties of the class change.
In this app, weâre going to design a view specially for adding new expense items. When the user is ready, weâll add that to our Expenses
class, which will automatically cause the original view to refresh its data so the expense item can be shown.
To make a new SwiftUI view you can either press Cmd+N or go to the File menu and choose New > File. Either way, you should select âSwiftUI Viewâ under the User Interface category, then name the file AddView.swift
. Xcode will ask you where to save the file, so make sure you see a folder icon next to âiExpenseâ, then click Create to have Xcode show you the new view, ready to edit.
As with our other views, our first pass at AddView
will be simple and weâll add to it. That means weâre going to add text fields for the expense name and amount, plus a picker for the type, all wrapped up in a form and a navigation view.
This should all be old news to you by now, so letâs get into the code:
struct AddView: View {
@State private var name = ""
@State private var type = "Personal"
@State private var amount = 0.0
let types = ["Business", "Personal"]
var body: some View {
NavigationView {
Form {
TextField("Name", text: $name)
Picker("Type", selection: $type) {
ForEach(types, id: \.self) {
Text($0)
}
}
TextField("Amount", value: $amount, format: .currency(code: "USD"))
.keyboardType(.decimalPad)
}
.navigationTitle("Add new expense")
}
}
}
Yes, that always uses US dollars for the currency type â youâll need to make that smarter in the challenges for this project.
Weâll come back to the rest of that code in a moment, but first letâs add some code to ContentView
so we can show AddView
when the + button is tapped.
In order to present AddView
as a new view, we need to make three changes to ContentView
. First, we need some state to track whether or not AddView
is being shown, so add this as a property now:
@State private var showingAddExpense = false
Next, we need to tell SwiftUI to use that Boolean as a condition for showing a sheet â a pop-up window. This is done by attaching the sheet()
modifier somewhere to our view hierarchy. You can use the List
if you want, but the NavigationView
works just as well. Either way, add this code as a modifier to one of the views in ContentView
:
.sheet(isPresented: $showingAddExpense) {
// show an AddView here
}
The third step is to put something inside the sheet. Often that will just be an instance of the view type you want to show, like this:
.sheet(isPresented: $showingAddExpense) {
AddView()
}
Here, though, we need something more. You see, we already have the expenses
property in our content view, and inside AddView
weâre going to be writing code to add expense items. We donât want to create a second instance of the Expenses
class in AddView
, but instead want it to share the existing instance from ContentView
.
So, what weâre going to do is add a property to AddView
to store an Expenses
object. It wonât create the object there, which means we need to use @ObservedObject
rather than @StateObject
.
Please add this property to AddView
:
@ObservedObject var expenses: Expenses
And now we can pass our existing Expenses
object from one view to another â they will both share the same object, and will both monitor it for changes. Modify your sheet()
modifier in ContentView
to this:
.sheet(isPresented: $showingAddExpense) {
AddView(expenses: expenses)
}
Weâre not quite done with this step yet, for two reasons: our code wonât compile, and even if it did compile it wouldnât work because our button doesnât trigger the sheet.
The compilation failure happens because when we made the new SwiftUI view, Xcode also added a preview provider so we can look at the design of the view while we were coding. If you find that down at the bottom of AddView.swift
, youâll see that it tries to create an AddView
instance without providing a value for the expenses
property.
That isnât allowed any more, but we can just pass in a dummy value instead, like this:
struct AddView_Previews: PreviewProvider {
static var previews: some View {
AddView(expenses: Expenses())
}
}
The second problem is that we donât actually have any code to show the sheet, because right now the + button in ContentView
adds test expenses. Fortunately, the fix is trivial â just replace the existing action with code to toggle our showingAddExpense
Boolean, like this:
Button {
showingAddExpense = true
} label: {
Image(systemName: "plus")
}
If you run the app now the whole sheet should be working as intended â you start with ContentView
, tap the + button to bring up an AddView
where you can type in the various fields, then can swipe to dismiss.
Making changes permanent with UserDefaults
Making changes permanent with UserDefaults
At this point, our appâs user interface is functional: youâve seen we can add and delete items, and now we have a sheet showing a user interface to create new expenses. However, the app is far from working: any data placed into AddView
is completely ignored, and even if it werenât ignored then it still wouldnât be saved for future times the app is run.
Weâll tackle those problems in order, starting with actually doing something with the data from AddView
. We already have properties that store the values from our form, and previously we added a property to store an Expenses
object passed in from the ContentView
.
We need to put those two things together: we need a button that, when tapped, creates an ExpenseItem
out of our properties and adds it to the expenses
items.
Add this modifier below navigationTitle()
in AddView
:
.toolbar {
Button("Save") {
let item = ExpenseItem(name: name, type: type, amount: amount)
expenses.items.append(item)
}
}
Although we have more work to do, I encourage you to run the app now because itâs actually coming together â you can now show the add view, enter some details, press âSaveâ, then swipe to dismiss, and youâll see your new item in the list. That means our data synchronization is working perfectly: both the SwiftUI views are reading from the same list of expense items.
Now try launching the app again, and youâll immediately hit our second problem: any data you add isnât stored, meaning that everything starts blank every time you relaunch the app.
This is obviously a pretty terrible user experience, but thanks to the way we have Expense
as a separate class itâs actually not that hard to fix.
Weâre going to leverage four important technologies to help us save and load data in a clean way:
- The
Codable
protocol, which will allow us to archive all the existing expense items ready to be stored. UserDefaults
, which will let us save and load that archived data.- A custom initializer for the
Expenses
class, so that when we make an instance of it we load any saved data fromUserDefaults
- A
didSet
property observer on theitems
property ofExpenses
, so that whenever an item gets added or removed weâll write out changes.
Letâs tackle writing the data first. We already have this property in the Expenses
class:
@Published var items = [ExpenseItem]()
Thatâs where we store all the expense item structs that have been created, and thatâs also where weâre going to attach our property observer to write out changes as they happen.
This takes four steps in total: we need to create an instance of JSONEncoder
that will do the work of converting our data to JSON, we ask that to try encoding our items
array, and then we can write that to UserDefaults
using the key âItemsâ.
Modify the items
property to this:
@Published var items = [ExpenseItem]() {
didSet {
if let encoded = try? JSONEncoder().encode(items) {
UserDefaults.standard.set(encoded, forKey: "Items")
}
}
}
Tip: Using JSONEncoder().encode()
means âcreate an encoder and use it to encode something,â all in one step, rather than creating the encoder first then using it later.
Now, if youâre following along youâll notice that code doesnât actually compile. And if youâre following along closely youâll have noticed that I said this process takes four steps while only listing three.
The problem is that the encode()
method can only archive objects that conform to the Codable
protocol. Remember, conforming to Codable
is what asks the compiler to generate code for us able to handle archiving and unarchiving objects, and if we donât add a conformance for that then our code wonât build.
Helpfully, we donât need to do any work other than add Codable
to ExpenseItem
, like this:
struct ExpenseItem: Identifiable, Codable {
let id = UUID()
let name: String
let type: String
let amount: Int
}
Swift already includes Codable
conformances for the UUID
, String
, and Int
properties of ExpenseItem
, and so itâs able to make ExpenseItem
conform automatically as soon as we ask for it.
However, you will see a warning that id
will not be decoded because we made it a constant and assigned a default value. This is actually the behavior we want, but Swift is trying to be helpful because itâs possible you did plan to decode this value from JSON. To make the warning go away, make the property variable like this:
var id = UUID()
With that change, weâve written all the code needed to make sure our items are saved when the user adds them. However, itâs not effective by itself: the data might be saved, but it isnât loaded again when the app relaunches.
To solve that â and also to make our code compile again â we need to implement a custom initializer. That will:
- Attempt to read the âItemsâ key from
UserDefaults
. - Create an instance of
JSONDecoder
, which is the counterpart ofJSONEncoder
that lets us go from JSON data to Swift objects. - Ask the decoder to convert the data we received from
UserDefaults
into an array ofExpenseItem
objects. - If that worked, assign the resulting array to
items
and exit. - Otherwise, set
items
to be an empty array. Add this initializer to theExpenses
class now:
init() {
if let savedItems = UserDefaults.standard.data(forKey: "Items") {
if let decodedItems = try? JSONDecoder().decode([ExpenseItem].self, from: savedItems) {
items = decodedItems
return
}
}
items = []
}
The two key parts of that code are the data(forKey: "Items")
line, which attempts to read whatever is in âItemsâ as a Data
object, and try? JSONDecoder().decode([ExpenseItem].self, from: savedItems)
, which does the actual job of unarchiving the Data
object into an array of ExpenseItem
objects.
Itâs common to do a bit of a double take when you first see [ExpenseItem].self
â what does the .self
mean? Well, if we had just used [ExpenseItem]
, Swift would want to know what we meant â are we trying to make a copy of the class? Were we planning to reference a static property or method? Did we perhaps mean to create an instance of the class? To avoid confusion â to say that we mean weâre referring to the type itself, known as the type object â we write .self
after it.
Now that we have both loading and saving in place, you should be able to use the app. Itâs not finished quite yet, though â letâs add some final polish!
Final polish
Final polish
If you try using the app, youâll soon see it has two problems:
- Adding an expense doesnât dismiss
AddView
; it just stays there. - When you add an expense, you canât actually see any details about it.
Before we wrap up this project, letâs fix those to make the whole thing feel a little more polished.
First, dismissing AddView
is done by calling dismiss()
on the environment when the time is right. This is controlled by the viewâs environment, and links to the isPresented
parameter for our sheet â that Boolean gets set to true by us to show AddView
, but will be flipped back to false by the environment when we call dismiss()
.
Start by adding this property to AddView
:
@Environment(\.dismiss) var dismiss
Youâll notice we donât specify a type for that â Swift can figure it out thanks to the @Environment
property wrapper.
Next, we need to call dismiss()
when we want the view to dismiss itself. This causes the showingAddExpense
Boolean in ContentView
to go back to false, and hides the AddView
. We already have a Save button in AddView
that creates a new expense item and appends it to our existing expenses, so add this on the line directly after:
dismiss()
That solves the first problem, which just leaves the second: we show the name of each expense item but nothing more. This is because the ForEach
for our list is trivial:
ForEach(expenses.items) { item in
Text(item.name)
}
Weâre going to replace that with a stack within another stack, to make sure all the information looks good on screen. The inner stack will be a VStack
showing the expense name and type, then around that will be a HStack
with the VStack
on the left, then a spacer, then the expense amount. This kind of layout is common on iOS: title and subtitle on the left, and more information on the right.
Replace the existing ForEach
in ContentView
with this:
ForEach(expenses.items) { item in
HStack {
VStack(alignment: .leading) {
Text(item.name)
.font(.headline)
Text(item.type)
}
Spacer()
Text(item.amount, format: .currency(code: "USD"))
}
}
Now run the program one last time and try it out â weâre done!
Thatâs another app built, and lots more techniques used in context too â great job!