- Görkem Güray/
- SwiftUI in 100 Days Notes/
- Day 36 - SwiftUI: @Observable, onDelete(), UserDefaults, @AppStrorage, Codable/
Day 36 - SwiftUI: @Observable, onDelete(), UserDefaults, @AppStrorage, Codable
Table of Contents
In this chapter we will work on topics like UserDefaults
, @Observable
, sheet()
and onDelete()
. Also, the name of the application we will create in this chapter will be iExpense. With this application, we will enter more complex applications that have multiple screens, load and save user data. With this application;
- We will present a second screen and close it.
- We will delete a row from the list.
- We will save and load the user’s data.
Using @State with Classes #
SwiftUI’s @State
property wrapper is designed for simple data local to the current view, but when we want to share data we need to take some important extra steps.
Let’s explain this in code, let’s create a struct to store the user’s first and last name;
struct User {
var firstName = "Bilbo"
var lastName = "Baggins"
}
Now we can use this by creating an @State
property in a SwiftUI view and using things like $user.firstName
and $user.lastName
.
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)
}
}
}
It all works: SwiftUI is smart enough to understand that an object contains all our data and will update the UI whenever any value changes. What happens behind the scenes is that every time a value inside the struct changes, the whole struct changes. It means that every time we type a letter for first and last name, the struct changes. This may sound wasteful, but it’s actually extremely fast.
Earlier we looked at the differences between classes and structs and there are two important differences. First, structs always have unique owners, whereas classes can have more than one thing pointing to the same value. Second, methods that modify properties of classes don’t need the mutating
keyword, because we can modify properties of constant classes.
What this means in practice is that if there are two SwiftUI views and we send the same struct to both of them to work with, they will in fact each have a unique copy of that struct; if one changes it, the other will not see it. On the other hand, if we create an instance of a class and send it to both views, they will share the changes.
For SwiftUI developers, this means that if we want to share data between multiple views (that is, if we want two or more views to point to the same data so that when one changes, they all get those changes), we should use classes instead of structs.
So let’s change User
to a class. From this
struct User {
Buna;
class User {
When we run the application again, we will see that it no longer works as we expected. We can write to TexFields as before, but the Text view above does not change.
When we use @State
, we ask SwiftUI to watch a property for changes. So, if we change a string, convert a Boolean, add to an array, etc., the property has changed and SwiftUI will call the view’s body property again.
When User
was a struct, every time we changed a property of this struct, Swift actually created a new instance of the struct. @State
would recognize this change and automatically reload the view. Now that we have a class, this behavior no longer happens. Because Swift can change the value directly.
Remember how we had to use the mutating
keyword for struct methods that change properties? This is because if we create struct properties as variables but the struct itself is constant, we cannot change the properties. Swift needs to be able to destroy and recreate the whole struct when a property changes, and this is not possible for constant structs. Classes don’t need the mutating
keyword, because Swift can change variable properties even if the class instance is marked as a constant.
The trick is that since User is now a class, the property itself doesn’t change, so @State
doesn’t notice anything and can’t reload the view. Yes, the values inside the class change, but @State doesn’t track them, so what happens is that the values inside our class are changed but the view is not reloaded to reflect that change.
We can solve this problem with a small change. Let’s add the line @Observable
before the class;
@Observable
class User {
And now our code will run again.
Sharing SwiftUI State with @Observable #
If we use @State
with a struct, the SwiftUI view is automatically updated when the value changes. However, if we use @State
with a class and we want SwiftUI to monitor the changes, we need to mark this class with @Observable
.
Let’s take a closer look at this code to understand what’s going on;
@Observable
class User {
var firstName = "Bilbo"
var lastName = "Baggins"
}
This is a class with two string variables, but it starts with @Observable
. This tells SwiftUI to monitor each property in the class for changes and reload all views that depend on it when a property changes.
Let’s dig a little deeper and `import’ something new.
import Observation
This line @Observable
is a macro. After importing Observation
, we can examine the macro code by expanding it by right clicking on @Observable
and clicking expand macro. There are 3 points to mention here;
- The two properties are marked as
@ObservationTracked
. This means that Swift and SwiftUI are watching them for changes. - If you right click on
@ObservationTracked
you can also expand this macro. This macro has the job of tracking when any property is read or written, so SwiftUI can only update views that absolutely need to be refreshed. - Our class has been made compliant with the
Observable
protocol. This is important because some parts of SwiftUI expect this to mean “this class can be monitored for changes”.
All three are important, but it’s the middle one that does the heavy lifting. iOS keeps track of every SwiftUI view that reads properties from the @Observed
object so that when one property changes, it can intelligently update all views that depend on it while leaving the others unchanged.
When working with struct, the @State
property wrapper keeps a value alive and also tracks changes. On the other hand, when working with classes, @State
only exists to keep the object alive, so all change tracking and view updating is handled by @Observable
.
Show and Hide Views #
There are several ways to show views in SwiftUI, and one of the most basic is sheet. A sheet is a new view presented on top of the existing view. On iOS, this automatically gives us a card-like presentation where the existing view slides away a bit and the new view moves on top.
Sheet works like warnings in that they are not presented directly with mySheet.present()
or similar code. We define the conditions under which a sheet should be presented, and when these conditions are true or false, the sheet is presented or closed.
Let’s start with a simple example that shows one view from another using a sheet. First, we create the view we want to show inside a sheet as follows;
struct SecondView: View {
var body: some View {
Text("Second View")
}
}
There is nothing special about this view. It does not know, nor does it need to know, that it will be shown on a sheet.
Next, we create our first view that will display the second view;
struct ContentView: View {
var body: some View {
Button("Show Sheet") {
// show the sheet
}
}
}
This requires four steps, which we will discuss separately
First we need some state to keep track of whether the page is being shown or not. Just like alerts, this can be a simple Boolean, so let’s add the following property to ContentView
;
@State private var showingSheet = false
Secondly, we need to change it when our button is touched, so let’s change the comment // show the sheet
;
showingSheet.toggle()
Third, we need to attach the sheet somewhere in our view hierarchy. If you remember, we showed alerts using isPresented
with two-way binding to the state property and here we use almost the same thing: sheet(ispresented:)
sheet()
is a modifier just like alert()
, so let’s add this modifier to the button;
.sheet(isPresented: $showingSheet) {
// contents of the sheet
}
Fourth, we need to decide what should actually be on the page. In our case, we already know exactly what we want: We want to create and show an instance of SecondView
.
Therefore, the completed 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 app, you will see that you can tap the button to make the second view slide up from the bottom and then drag it down to close it.
When you create a view like this, you can pass it the parameters it needs to run. For example, we might want to send SecondView
a name that it can display, like this;
struct SecondView: View {
let name: String
var body: some View {
Text("Hello, \(name)!")
}
}
And now it is not enough to just use SeconView()
in the sheet, we also need to add a name string to be displayed. For example, we can enter my Twitter username like this.
.sheet(isPresented: $showingSheet) {
SecondView(name: "@grkmgry")
}
Now the sheet will show “Hello, @grkmgry”.
Swift does a lot of work on our behalf here. As soon as we tell it that SecondView
has a name property, Swift ensures that our code is not generated until all instances of SecondView()
are SecondView(name: "some name")
, which eliminates a number of potential bugs.
Let’s examine how a view can close itself.
To close a different view, we need another property wrapper. This is called @Environment
and it allows us to create properties that store externally supplied values. Is the user in light mode or dark mode? Did they request smaller or larger fonts? What time zone are they in? All these and more are values from the environment and in this example we will ask the environment to close the view.
To try it out, let’s add this property to SecondView
, this property creates a property called dismiss
based on a value in the environment.
@Environment(\.dismiss) var dismiss
Let’s replace the text view in SecondView
with this button;
Button("Dismiss") {
dismiss()
}
We can now close the page with the button on the second page.
Delete Items Using onDelete() #
SwiftUI gives us the onDelete()
modifier to control how objects are deleted from a collection. In practice, this is almost exclusively used with List
and ForEach
: ForEach
to create a list of the lines shown, then add onDelete()
to this ForEach
so that the user can remove the lines they don’t want.
This is another place where SwiftUI does a lot of work on our behalf, but as you will see it has a few interesting quirks.
First, let’s create an instance that we can work on: a list of numbers and each time we tap the button a new number appears.
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 there is no need for ForEach
because the list is all dynamic rows, so instead we can write the following,
List(numbers, id: \.self) {
Text("Row \($0)")
}
This also works, 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 have to put the items in ForEach. This means a small amount of extra code for when we only have dynamic rows, but on the other hand it means it’s easier to create lists where only some rows can be deleted.
To make onDelete()
work, we need to implement a method of type IndexSet
that takes a single parameter. This is similar to a set of integers except that it is sorted and tells us the locations of all items in ForEach
that need to be removed.
We can handle this by writing a closure to the onDelete()
method as follows.
ForEach(numbers, id: \.self) {
Text("Row \($0)")
}
.onDelete{ indexSet in
numbers.remove(atOffsets: indexSet)
}
The result is that we can now delete elements from the list.
We can also add an Edit/Done button to the Navigation Bar that allows users to delete a few lines more easily.
Wrap VStack
with a NavigationStack
and then add the following modifier to VStack
;
.toolbar {
EditButton()
}
Storing User Settings with UserDefaults #
Most users expect apps to store their data so that they can create more customized experiences, and so it’s no surprise that iOS offers us a variety of ways to read and write user data.
A common way to store a small amount of data is called UserDefaults
and is great for simple user preferences. There is no specific number for a “small amount”, but anything you store in UserDefaults
will be loaded automatically when your application starts. So if we store too much data here, it will slow down the launch of our application. To at least give you an idea, we should not store more than 512KB in UserDefaults
.
UserDefaults
is great for storing things like when the user last launched the app, what news they last read, or other passively collected information. Even better, SwiftUI can often wrap UserDefaults
in a nice and simple property wrapper called @AppStorage
.
Here, there is a view that shows the number of touches and increments this number each time the button is touched.
struct ContentView: View {
@State private var tapCount = 0
var body: some View {
Button("Tap count: \(tapCount)") {
tapCount += 1
}
}
}
We want to record the number of touches the user makes, so that when they return to the app in the future they can pick up where they left off.
To realize this, we need to write UserDefaults
in the closure of our button. So, let’s add this after tapCount += 1
;
UserDefaults.standard.set(tapCount, forKey: "Tap")
In just this one line of code you can see three things happening:
- We need to use
UserDefaults.standard
. This is the built-inUserDefaults
instance added to our application, but in more advanced applications we can create our own instance. For example, if you want to share User Defaults across several application extensions, you can create your ownUserDefaults
instance. - There is a single
set()
method that accepts all kinds of data such as Integers, Booleans, Array and more. - To this data we add a string name, in our case the “Tap” key. This key is case sensitive, just like normal Swift strings, and is important. Because we need to use the same key to read the data back from
UserDefaults
.
Instead of initializing tapCount
to 0, we should make it read the value back from UserDefaults
as follows;
@State private var tapCount = UserDefaults.standard.integer(forKey: "Tap")
Let’s run the application and try it, the number will increase as we tap the button, when we run the application again, the value will not be reset, it will continue where it left off.
There are two important things we cannot see in this code. First, what happens if the “Tap” switch is not set? This will be the case when the application is run for the first time, but as you just saw it works fine, it just sends back 0 if the key is not found.
Sometimes it helps to have a default value like 0, but other times it can be confusing. For example, if boolean(forkey:)
doesn’t find the key we want, we get false, but is this false value the value we want, or does it mean that there is no value?
Second, iOS takes a while to write your data to persistent storage. In case there are several changes in a row, they don’t write the updates right away, instead they wait a while and then write all the changes at once. We don’t know how long, but a few seconds should be enough.
As a consequence, if you tap the button and quickly restart the app from Xcode, you will see that your last tap count is not immediately saved. There used to be a way to force updates to be written immediately, but it’s worthless at this point, because even if the user immediately starts the process of terminating our app after making a selection, our default data is written immediately, so nothing is lost.
Now, SwiftUI’s @AppStorage
property wrapper around UserDefaults
really helps in simple cases like this. What it does is allow us to completely ignore UserDefaults
and use @AppStorage
instead of @State
.
struct ContentView: View {
@AppStorage("tapCount") private var tapCount = 0
var body: some View {
Button("Tap count: \(tapCount)") {
tapCount += 1
}
}
}
There are three points to note here;
- Our access to
UserDefaults
is through the@AppStorage
property wrapper. This works like@State
: when the value changes, it calls thebody
property again so that our UI reflects the new data. - We add a string with the key
UserDefaults
where we want to store the data. Here we used “tapCount”, but it can be anything. - The rest of the property is declared in the normal way, including providing the default value 0. This value will be used if there is no existing value stored in
UserDefaults
.
Obviously using @AppStorage
is easier than using UserDefaults
. One line of code instead of two, and it also means we don’t have to repeat the key name every time. But at least at the moment @AppStorage
doesn’t make it easy to store complex objects like Swift struct.
Important : When we submit an app to the App Store, Apple requires us to tell them why we are loading and saving data using UserDefaults
. This also applies to the @AppStorage
property wrapper.
Archiving Swift Objects with Codable #
@AppStorage
is great for storing simple data like integers and Booleans, but when it comes to complex data we need to do a bit more work. This is where we need to use UserDefaults
itself directly instead of the @AppStorage
property wrapper.
Let’s start with a simple User
data structure.
struct User {
let firstName: String
let lastName: String
}
This data structure has two strings. When working with data like this, Swift provides us with a great protocol called Codable
. Specifically a protocol for archiving and unarchiving data, which is a fancy way of saying “converting objects to plain text and retrieving them again”.
We will look more into Codable
in future projects, but for now we will keep it as simple as possible. We want to archive a custom type so we can put it in UserDefaults
and retrieve it when we need it.
When working with a type with only simple properties (string, integer, boolean, string array, etc.) all we need to do to support archiving and unarchiving is to add Codable
as follows.
struct User: Codable {
let firstName: String
let lastName: String
}
Swift will automatically generate some code to archive and unarchive User
instances for us when needed, but we still need to tell Swift when to archive and what to do with the data.
This part of the process is supported by a new type called JSONEncoder
. Its job is to take something that matches Codable
and send that object back as JavaScript Object Notation (JSON).
The Codable
protocol does not require us to use JSON and in fact there are other formats available, but this is the most common one. In this example, we don’t actually care what kind of data is used, because it will just be stored in UserDefaults
.
To convert our user
data into JSON data, we need to call the encode()
method on a JSONEncoder. This can throw errors, so it should be called with try
or try?
to handle errors properly. For example, if we had a property like this to store a User
instance;
@State private var user = User(firstName: "Taylor", lastName: "Swift")
We can then create a button that archives the user and save it in UserDefaults
as follows;
Button("Save User") {
let encoder = JSONEncoder()
if let data = try? encoder.encode(user) {
UserDefaults.standard.set(data, forKey: "UserData")
}
}
This accesses UserDefaults
directly instead of going through @AppStorage
, because the @AppStorage
property wrapper does not work here.
This data
constant is a new data type called Data
and is designed to store any kind of data you can think of, such as strings, images, zip files, and so on. But the only thing we care about here is that it is one of the data types that we can write directly into UserDefaults
.
When we want to read the data back, that is, when we have JSON data and we want to convert it to Swift Codable
types, we have to use JSONDecoder
instead of JSONEncoder
, but the process is almost the same.
You can also read this article in Turkish.
Bu yazıyı Türkçe olarak da okuyabilirsiniz.