Day 36
Day 36 êŽë š
Project 7, part 1
Linus Torvalds, the creator of the open source Linux operating system, was once asked if he had any advice for developers who wanted to build a large software project. Here is the response he gave:
Nobody should start to undertake a large project. You start with a small trivial project, and you should never expect it to get large. If you do, you'll just overdesign and generally think it is more important than it likely is at that stage. Or worse, you might be scared away by the sheer size of the work you envision.
In writing this course, Iâve already had people emailing me asking âwhy didnât I use X to solve a problem in project 1?â or âY would have been much better than Z in project 4.â They are probably right, but if I tried to teach you everything in project 1 youâd have found it overwhelming and unpleasant, so instead we built a small app. Then in project 2 we built a second small app. Then we built a third and a fourth, with each one adding to your skills.
Today youâll start project 7, which is still most definitely a small app. However, in the process youâll learn how to show another screen, how to share data across screens, how to load and save user data, and more â the kinds of features that really help take your SwiftUI skills to the next level.
That doesnât mean the app is perfect â as youâll learn later, UserDefaults
isnât the ideal choice for what weâre doing here, and instead something like the much bigger and more complex Core Data would be a better fit â but thatâs okay. Remember, weâre setting out to build something small and work our way up, rather than just jumping into one all-encompassing mega-project.
If youâre all set, letâs get to it!
Today you have seven topics to work through, in which youâll learn about @StateObject
, sheet()
, onDelete()
, and more.
iExpense: Introduction
iExpense: Introduction
Our next two projects are going to start pushing your SwiftUI skills beyond the basics, as we explore apps that have multiple screens, that load and save user data, and have more complex user interfaces.
In this project weâre going to build iExpense, which is an expense tracker that separates personal costs from business costs. At its core this is an app with a form (how much did you spend?) and a list (here are the amounts you spent), but in order to accomplish those two things youâre going to need to learn how to:
- Present and dismiss a second screen of data.
- Delete rows from a list
- Save and load user data
âŠand more.
Thereâs lots to do, so letâs get started: create a new iOS app using the App template, naming it âiExpenseâ. Weâll be using that for the main project, but first lets take a closer look at the new techniques required for this projectâŠ
Why @State
only works with structs
Why @State only works with structs
SwiftUIâs State
property wrapper is designed for simple data that is local to the current view, but as soon as you want to share data between views it stops being useful.
Letâs break this down with some code â hereâs a struct to store a userâs first and last name:
struct User {
var firstName = "Bilbo"
var lastName = "Baggins"
}
We can now use that in a SwiftUI view by creating an @State
property and attaching things to $user.firstName
and $user.lastName
, like this:
struct ContentView: View {
@State private var user = User()
var body: some View {
VStack {
Text("Your name is \(user.firstName) \(user.lastName).")
TextField("First name", text: $user.firstName)
TextField("Last name", text: $user.lastName)
}
}
}
That all works: SwiftUI is smart enough to understand that one object contains all our data, and will update the UI when either value changes. Behind the scenes, whatâs actually happening is that each time a value inside our struct changes the whole struct changes â itâs like a new user every time we type a key for the first or last name. That might sound wasteful, but itâs actually extremely fast.
Previously we looked at the differences between classes and structs, and there were two important differences I mentioned. First, that structs always have unique owners, whereas with classes multiple things can point to the same value. And second, that classes donât need the mutating
keyword before methods that change their properties, because you can change properties of constant classes.
In practice, what this means is that if we have two SwiftUI views and we send them both the same struct to work with, they actually each have a unique copy of that struct; if one changes it, the other wonât see that change. On the other hand, if we create an instance of a class and send that to both views, they will share changes.
For SwiftUI developers, what this means is that if we want to share data between multiple views â if we want two or more views to point to the same data so that when one changes they all get those changes â we need to use classes rather than structs.
So, please change the User
struct to be a class. From this:
struct User {
To this:
class User {
Now run the program again and see what you think.
Spoiler: it doesnât work any more. Sure, we can type into the text fields just like before, but the text view above doesnât change.
When we use @State
, weâre asking SwiftUI to watch a property for changes. So, if we change a string, flip a Boolean, add to an array, and so on, the property has changed and SwiftUI will re-invoke the body
property of the view.
When User
was a struct, every time we modified a property of that struct Swift was actually creating a new instance of the struct. @State
was able to spot that change, and automatically reloaded our view. Now that we have a class, that behavior no longer happens: Swift can just modify the value directly.
Remember how we had to use the mutating
keyword for struct methods that modify properties? This is because if we create the structâs properties as variable but the struct itself is constant, we canât change the properties â Swift needs to be able to destroy and recreate the whole struct when a property changes, and that isnât possible for constant structs. Classes donât need the mutating
keyword, because even if the class instance is marked as constant Swift can still modify variable properties.
I know that all sounds terribly theoretical, but hereâs the twist: now that User
is a class the property itself isnât changing, so @State
doesnât notice anything and canât reload the view. Yes, the values inside the class are changing, but @State
doesnât monitor those, so effectively whatâs happening is that the values inside our class are being changed but the view isnât being reloaded to reflect that change.
To fix this, itâs time to leave @State
behind. Instead we need a more powerful property wrapper called @StateObject
â letâs look at that nowâŠ
Sharing SwiftUI state with @StateObject
Sharing SwiftUI state with @StateObject
If you want to use a class with your SwiftUI data â which you will want to do if that data should be shared across more than one view â then SwiftUI gives us three property wrappers that are useful: @StateObject
, @ObservedObject
, and @EnvironmentObject
. Weâll be looking at environment objects later on, but for now letâs focus on the first two.
Hereâs some code that creates a User
class, and shows that user data in a view:
class User {
var firstName = "Bilbo"
var lastName = "Baggins"
}
struct ContentView: View {
@State private var user = User()
var body: some View {
VStack {
Text("Your name is \(user.firstName) \(user.lastName).")
TextField("First name", text: $user.firstName)
TextField("Last name", text: $user.lastName)
}
}
}
However, that code wonât work as intended: weâve marked the user
property with @State
, which is designed to track local structs rather than external classes. As a result, we can type into the text fields but the text view above wonât be updated.
To fix this, we need to tell SwiftUI when interesting parts of our class have changed. By âinteresting partsâ I mean parts that should cause SwiftUI to reload any views that are watching our class â itâs possible you might have lots of properties inside your class, but only a few should be exposed to the wider world in this way.
Our User
class has two properties: firstName
and lastName
. Whenever either of those two changes, we want to notify any views that are watching our class that a change has happened so they can be reloaded. We can do this using the @Published
property observer, like this:
class User {
@Published var firstName = "Bilbo"
@Published var lastName = "Baggins"
}
@Published
is more or less half of @State
: it tells Swift that whenever either of those two properties changes, it should send an announcement out to any SwiftUI views that are watching that they should reload.
How do those views know which classes might send out these notifications? Thatâs another property wrapper, @StateObject
, which is the other half of @State
â it tells SwiftUI that weâre creating a new class instance that should be watched for any change announcements.
So, change the user
property to this:
@StateObject var user = User()
I removed the private
access control there, but whether or not you use it depends on your usage â if youâre intending to share that object with other views then marking it as private
will just cause confusion.
Now that weâre using @StateObject
, our code will no longer compile. Itâs not a problem, and in fact itâs expected and easy to fix: the @StateObject
property wrapper can only be used on types that conform to the ObservableObject
protocol. This protocol has no requirements, and really all it means is âwe want other things to be able to monitor this for changes.â
So, modify the User
class to this:
class User: ObservableObject {
@Published var firstName = "Bilbo"
@Published var lastName = "Baggins"
}
Our code will now compile again, and, even better, it will now actually work again â you can run the app and see the text view update when either text field is changed.
As youâve seen, rather than just using @State
to declare local state, we now take three steps:
- Make a class that conforms to the
ObservableObject
protocol. - Mark some properties with
@Published
so that any views using the class get updated when they change. - Create an instance of our class using the
@StateObject
property wrapper.
The end result is that we can have our state stored in an external object, and, even better, we can now use that object in multiple views and have them all point to the same values.
However, there is a catch. Like I said earlier, @StateObject
tells SwiftUI that weâre creating a new class instance that should be watched for any change announcements, but that should only be used when youâre creating the object like we are with our User
instance.
When you want to use a class instance elsewhere â when youâve created it in view A using @StateObject
and want to use that same object in view B â you use a slightly different property wrapper called @ObservedObject
. Thatâs the only difference: when creating the shared data use @StateObject
, but when youâre just using it in a different view you should use @ObservedObject
instead.
Showing and hiding views
Showing and hiding views
There are several ways of showing views in SwiftUI, and one of the most basic is a sheet: a new view presented on top of our existing one. On iOS this automatically gives us a card-like presentation where the current view slides away into the distance a little and the new view animates in on top.
Sheets work much like alerts, in that we donât present them directly with code such as mySheet.present()
or similar. Instead, we define the conditions under which a sheet should be shown, and when those conditions become true or false the sheet will either be presented or dismissed respectively.
Letâs start with a simple example, which will be showing one view from another using a sheet. First, we create the view we want to show inside a sheet, like this:
struct SecondView: View {
var body: some View {
Text("Second View")
}
}
Thereâs nothing special about that view at all â it doesnât know itâs going to be shown in a sheet, and doesnât need to know itâs going to be shown in a sheet.
Next we create our initial view, which will show the second view. Weâll make it simple, then add to it:
struct ContentView: View {
var body: some View {
Button("Show Sheet") {
// show the sheet
}
}
}
Filling that in requires four steps, and weâll tackle them individually.
First, we need some state to track whether the sheet is showing. Just as with alerts, this can be a simple Boolean, so add this property to ContentView
now:
@State private var showingSheet = false
Second, we need to toggle that when our button is tapped, so replace the // show the sheet
comment with this:
showingSheet.toggle()
Third, we need to attach our sheet somewhere to our view hierarchy. If you remember, we show alerts using isPresented
with a two-way binding to our state property, and we use something almost identical here: sheet(isPresented:)
.
sheet()
is a modifier just like alert()
, so please add this modifier to our button now:
.sheet(isPresented: $showingSheet) {
// contents of the sheet
}
Fourth, we need to decide what should actually be in the sheet. In our case, we already know exactly what we want: we want to create and show an instance of SecondView
. In code that means writing SecondView()
, then⊠er⊠well, thatâs it.
So, the finished ContentView
struct should look like this:
struct ContentView: View {
@State private var showingSheet = false
var body: some View {
Button("Show Sheet") {
showingSheet.toggle()
}
.sheet(isPresented: $showingSheet) {
SecondView()
}
}
}
If you run the program now youâll see you can tap the button to have our second view slide upwards from the bottom, and you can then drag that down to dismiss it.
When you create a view like this, you can pass in any parameters it needs to work. For example, we could require that SecondView
be sent a name it can display, like this:
struct SecondView: View {
let name: String
var body: some View {
Text("Hello, \(name)!")
}
}
And now just using SecondView()
in our sheet isnât good enough â we need to pass in a name string to be shown. For example, we could pass in my Twitter username like this:
.sheet(isPresented: $showingSheet) {
SecondView(name: "@twostraws")
}
Now the sheet will show âHello, @twostrawsâ.
Swift is doing a ton of work on our behalf here: as soon as we said that SecondView
has a name property, Swift ensured that our code wouldnât even build until all instances of SecondView()
became SecondView(name: "some name")
, which eliminates a whole range of possible errors.
Before we move on, thereâs one more thing I want to demonstrate, which is how to make a view dismiss itself. Yes, youâve seen that the user can just swipe downwards, but sometimes you will want to dismiss views programmatically â to make the view go away because a button was pressed, for example.
To dismiss another view we need another property wrapper â and yes, I realize that so often the solution to a problem in SwiftUI is to use another property wrapper.
Anyway, this new one is called @Environment
, and it allows us to create properties that store values provided to us externally. Is the user in light mode or dark mode? Have they asked for smaller or larger fonts? What timezone are they on? All these and more are values that come from the environment, and in this instance weâre going to ask the environment to dismiss our view.
Yes, we need to ask the environment to dismiss our view, because it might have been presented in any number of different ways. So, weâre effectively saying âhey, figure out how my view was presented, then dismiss it appropriately.â
To try it out add this property to SecondView
, which creates a property called dismiss
based on a value from the environment:
@Environment(\.dismiss) var dismiss
Now replace the text view in SecondView
with this button:
Button("Dismiss") {
dismiss()
}
Anyway, with that button in place, you should now find you can show and hide the sheet using button presses.
Deleting items using onDelete()
Deleting items using onDelete()
SwiftUI gives us the onDelete()
modifier for us to use to control how objects should be deleted from a collection. In practice, this is almost exclusively used with List
and ForEach
: we create a list of rows that are shown using ForEach
, then attach onDelete()
to that ForEach
so the user can remove rows they donât want.
This is another place where SwiftUI does a heck of a lot of work on our behalf, but it does have a few interesting quirks as youâll see.
First, letâs construct an example we can work with: a list that shows numbers, and every time we tap the button a new number appears. Hereâs the code for that:
struct ContentView: View {
@State private var numbers = [Int]()
@State private var currentNumber = 1
var body: some View {
VStack {
List {
ForEach(numbers, id: \.self) {
Text("Row \($0)")
}
}
Button("Add Number") {
numbers.append(currentNumber)
currentNumber += 1
}
}
}
}
Now, you might think that the ForEach
isnât needed â the list is made up of entirely dynamic rows, so we could write this instead:
List(numbers, id: \.self) {
Text("Row \($0)")
}
That would also work, but hereâs our first quirk: the onDelete()
modifier only exists on ForEach
, so if we want users to delete items from a list we must put the items inside a ForEach
. This does mean a small amount of extra code for the times when we have only dynamic rows, but on the flip side it means itâs easier to create lists where only some rows can be deleted.
In order to make onDelete()
work, we need to implement a method that will receive a single parameter of type IndexSet
. This is a bit like a set of integers, except itâs sorted, and itâs just telling us the positions of all the items in the ForEach
that should be removed.
Because our ForEach
was created entirely from a single array, we can actually just pass that index set straight to our numbers
array â it has a special remove(atOffsets:)
method that accepts an index set.
So, add this method to ContentView
now:
func removeRows(at offsets: IndexSet) {
numbers.remove(atOffsets: offsets)
}
Finally, we can tell SwiftUI to call that method when it wants to delete data from the ForEach
, by modifying it to this:
ForEach(numbers, id: \.self) {
Text("Row \($0)")
}
.onDelete(perform: removeRows)
Now go ahead and run your app, then add a few numbers. When youâre ready, swipe from right to left across any of the rows in your list, and you should find a delete button appears. You can tap that, or you can also use iOSâs swipe to delete functionality by swiping further.
Given how easy that was, I think the result works really well. But SwiftUI has another trick up its sleeve: we can add an Edit/Done button to the navigation bar, that lets users delete several rows more easily.
First, wrap your VStack
in a NavigationView
, then add this modifier to the VStack
:
.toolbar {
EditButton()
}
Thatâs literally all it takes â if you run the app youâll see you can add some numbers, then tap Edit to start deleting those rows. When youâre ready, tap Done to exit editing mode. Not bad, given how little code it took!
Storing user settings with UserDefaults
Storing user settings with UserDefaults
Most users pretty much expect apps to store their data so they can create more customized experiences, and as such itâs no surprise that iOS gives us several ways to read and write user data.
One common way to store a small amount of data is called UserDefaults
, and itâs great for simple user preferences. There is no specific number attached to âa small amountâ, but everything you store in UserDefaults
will automatically be loaded when your app launches â if you store a lot in there your app launch will slow down. To give you at least an idea, you should aim to store no more than 512KB in there.
Tip: If youâre thinking â512KB? How much is that?â then let me give you a rough estimate: itâs about as much text as all the chapters youâve read in this book so far.
UserDefaults
is perfect for storing things like when the user last launched the app, which news story they last read, or other passively collected information. Even better, SwiftUI can often wrap up UserDefaults
inside a nice and simple property wrapper called @AppStorage
â it only supports a subset of functionality right now, but it can be really helpful.
Enough chat â letâs look at some code. Hereâs a view with a button that shows a tap count, and increments that count every time the button is tapped:
struct ContentView: View {
@State private var tapCount = 0
var body: some View {
Button("Tap count: \(tapCount)") {
tapCount += 1
}
}
}
As this is clearly A Very Important App, we want to save the number of taps that the user made, so when they come back to the app in the future they can pick up where they left off.
To make that happen, we need to write to UserDefaults
inside our buttonâs action closure. So, add this after the tapCount += 1
line:
UserDefaults.standard.set(self.tapCount, forKey: "Tap")
In just that single line of code you can see three things in action:
- We need to use
UserDefaults.standard
. This is the built-in instance ofUserDefaults
that is attached to our app, but in more advanced apps you can create your own instances. For example, if you want to share defaults across several app extensions you might create your ownUserDefaults
instance. - There is a single
set()
method that accepts any kind of data â integers, Booleans, strings, and more. - We attach a string name to this data, in our case itâs the key âTapâ. This key is case-sensitive, just like regular Swift strings, and itâs important â we need to use the same key to read the data back out of
UserDefaults
.
Speaking of reading the data back, rather than start with tapCount
set to 0 we should instead make it read the value back from UserDefaults
like this:
@State private var tapCount = UserDefaults.standard.integer(forKey: "Tap")
Notice how that uses exactly the same key name, which ensures it reads the same integer value.
Go ahead and give the app a try and see what you think â you ought to be able tap the button a few times, go back to Xcode, run the app again, and see the number exactly where you left it.
There are two things you canât see in that code, but both matter. First, what happens if we donât have the âTapâ key set? This will be the case the very first time the app is run, but as you just saw it works fine â if the key canât be found it just sends back 0.
Sometimes having a default value like 0 is helpful, but other times it can be confusing. With Booleans, for example, you get back false if boolean(forKey:)
canât find the key you asked for, but is that false a value you set yourself, or does it mean there was no value at all?
Second, it takes iOS a little time to write your data to permanent storage â to actually save that change to the device. They donât write updates immediately because you might make several back to back, so instead they wait some time then write out all the changes at once. How much time is another number we donât know, but a couple of seconds ought to do it.
As a result of this, if you tap the button then quickly relaunch the app from Xcode, youâll find your most recent tap count wasnât saved. There used to be a way of forcing updates to be written immediately, but at this point itâs worthless â even if the user immediately started the process of terminating your app after making a choice, your defaults data would be written immediately so nothing will be lost.
Now, I mentioned that SwiftUI provides an @AppStorage
property wrapper around UserDefaults
, and in simple situations like this one itâs really helpful. What it does is let us effectively ignore UserDefaults
entirely, and just use @AppStorage
rather than @State
, like this:
struct ContentView: View {
@AppStorage("tapCount") private var tapCount = 0
var body: some View {
Button("Tap count: \(tapCount)") {
tapCount += 1
}
}
}
Again, there are three things I want to point out in there:
- Our access to the
UserDefaults
system is through the@AppStorage
property wrapper. This works like@State
: when the value changes, it will reinvoked the body property so our UI reflects the new data. - We attach a string name, which is the
UserDefaults
key where we want to store the data. Iâve used âtapCountâ, but it can be anything at all â it doesnât need to match the property name. - The rest of the property is declared as normal, including providing a default value of 0. That will be used if there is no existing value saved inside
UserDefaults
.
Clearly using @AppStorage
is easier than UserDefaults
: itâs one line of code rather than two, and it also means we donât have to repeat the key name each time. However, right now at least @AppStorage
doesnât make it easy to handle storing complex objects such as Swift structs â perhaps because Apple wants us to remember that storing lots of data in there is a bad idea!
Archiving Swift objects with Codable
Archiving Swift objects with Codable
@AppStorage
is great for storing simple settings such as integers and Booleans, but when it comes to complex data â custom Swift types, for example â we need to do a little more work. This is where we need to poke around directly with UserDefaults
itself, rather than going through the @AppStorage
property wrapper.
Hereâs a simple User
data structure we can work with:
struct User {
let firstName: String
let lastName: String
}
That has two strings, but those arenât special â they are just pieces of text. The same goes for integer (plain old numbers), Boolean
(true or false), and Double
(plain old numbers, just with a dot somewhere in there). Even arrays and dictionaries of those values are easy to think about: thereâs one string, then another, then a third, and so on.
When working with data like this, Swift gives us a fantastic protocol called Codable
: a protocol specifically for archiving and unarchiving data, which is a fancy way of saying âconverting objects into plain text and back again.â
Weâre going to be looking at Codable
much more in future projects, but for now weâre going to keep it as simple as possible: we want to archive a custom type so we can put it into UserDefaults
, then unarchive it when it comes back out from UserDefaults
.
When working with a type that only has simple properties â strings, integers, Booleans, arrays of strings, and so on â the only thing we need to do to support archiving and unarchiving is add a conformance to Codable
, like this:
struct User: Codable {
let firstName: String
let lastName: String
}
Swift will automatically generate some code for us that will archive and unarchive User
instances for us as needed, but we still need to tell Swift when to archive and what to do with the data.
This part of the process is powered by a new type called JSONEncoder
. Its job is to take something that conforms to Codable
and send back that object in JavaScript Object Notation (JSON) â the name implies itâs specific to JavaScript, but in practice we all use it because itâs so fast and simple.
The Codable
protocol doesnât require that we use JSON, and in fact other formats are available, but it is by far the most common. In this instance, we donât actually care what sort of data is used, because itâs just going to be stored in UserDefaults
.
To convert our user
data into JSON data, we need to call the encode()
method on a JSONEncoder
. This might throw errors, so it should be called with try
or try?
to handle errors neatly. For example, if we had a property to store a User
instance, like this:
@State private var user = User(firstName: "Taylor", lastName: "Swift")
Then we could create a button that archives the user and save it to UserDefaults
like this:
Button("Save User") {
let encoder = JSONEncoder()
if let data = try? encoder.encode(user) {
UserDefaults.standard.set(data, forKey: "UserData")
}
}
That accesses UserDefaults
directly rather than going through @AppStorage
, because the @AppStorage
property wrapper just doesnât work here.
That data
constant is a new data type called, perhaps confusingly, Data
. Itâs designed to store any kind of data you can think of, such as strings, images, zip files, and more. Here, though, all we care about is that itâs one of the types of data we can write straight into UserDefaults
.
When weâre coming back the other way â when we have JSON data and we want to convert it to Swift Codable
types â we should use JSONDecoder
rather than JSONEncoder()
, but the process is much the same.
That brings us to the end of our project overview, so go ahead and reset your project to its initial state ready to build on.
Tips
Itâs easy to feel overwhelmed by the various property wrappers used in SwiftUI, so you might find this article helpful: Which SwiftUI property wrapper to choose in any situation.
Donât forget to post your progress somewhere online â weâre well past a third of the way through the course, and youâre doing great!