Skip to main content
  1. SwiftUI in 100 Days Notes/

Day 36 - SwiftUI: @Observable, onDelete(), UserDefaults, @AppStrorage, Codable

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;

  1. The two properties are marked as @ObservationTracked. This means that Swift and SwiftUI are watching them for changes.
  2. 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.
  3. 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.

SwiftUI Remove Item From 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()
}

SwiftUI List Edit Button

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:

  1. We need to use UserDefaults.standard. This is the built-in UserDefaults 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 own UserDefaults instance.
  2. There is a single set() method that accepts all kinds of data such as Integers, Booleans, Array and more.
  3. 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;

  1. Our access to UserDefaults is through the @AppStorage property wrapper. This works like @State: when the value changes, it calls the body property again so that our UI reflects the new data.
  2. We add a string with the key UserDefaults where we want to store the data. Here we used “tapCount”, but it can be anything.
  3. 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.

This article contains the notes I took for myself from the articles found at SwiftUI Day 36. Please use the link to follow the original lesson.