6️⃣Property Wrappers

Fall 2023 | Vin Bui

Because SwiftUI is declarative, the way we write the logic in our apps is completely different from how we would do it in UIKit. Let’s take a look at the following code that increments the value inside of a Text by 1 every time we click on a button:

struct ContentView: View {
    var count: Int = 0

    var body: some View {
        VStack {
            Text("Count: \(count)")

            Button {
                count += 1
            } label: {
                Text("Add")
            }
        }
    }
}

The above code will give us an error for the line containing count += 1 indicating that ‘self’ is immutable. Why? That’s because we are using a struct. When we change the value of a property inside of a struct, the entire struct changes. If that’s the case, then how do we keep the struct object alive while being able to change the value of its properties? We use property wrappers.

Using @State

To make the above code work, we can add the @State property wrapper to the count property. Because this object creates and owns this property, we should mark it as private for good practice:

struct ContentView: View {
    @State private var count: Int = 0

    var body: some View {
        VStack {
            Text("Count: \(count)")

            Button {
                count += 1
            } label: {
                Text("Add")
            }
        }
    }
}

The powerful thing about using these property wrappers is that SwiftUI automatically updates the views for us. When we incremented the value of count, the Text view using this property gets updated automatically.

Two Way Binding

Now, let’s change things up and use a String with a TextField:

struct ContentView: View {
    @State private var name: String = "Vin"

    var body: some View {
        VStack {
            Text("Hello \(name)")

            TextField("Change Me", text: name)
        }
    }
}

If we try to run the above code, it will not work. That’s because the name variable that we pass into the TextField is a String and NOT Binding<String>. What does this “binding” mean? Let’s think about the difference between a Text and a TextField. A Text only reads the value that we pass in whereas a TextField reads AND writes a value. In this case, we must add a $ to perform a two-way binding (in other words, a read and a write):

TextField("Change Me", text: name) // Incorrect
TextField("Change Me", text: $name) // Correct

Reference Types

You may have noticed that we only used value types in the examples above. That’s because @State only works for value types such as structs, strings, ints, etc. However, there are times when we want to pass one reference around to multiple views. In this case, we want to use a reference type.

Consider the following code:

class User {
    var firstName: String = "Vin"
    var lastName: String = "Bui"
}

struct ContentView: View {
    @State private var user = User()

    var body: some View {
        VStack {
            Text("Hello \(user.firstName) \(user.lastName)")

            TextField("First Name", text: $user.firstName)
            TextField("Last Name", text: $user.lastName)
        }
    }
}

If we change the values inside of the text fields, the Text view will not be updated properly. One way to fix this is to change the User class to a struct. However, we want to keep it as a reference type.

When dealing with reference types, we must use a different property wrapper: @StateObject. To properly use this property wrapper, we must do the following (iOS 16 and below):

  1. Conform our class to the ObservableObject protocol.

  2. Mark the properties we want to observe with @Published.

  3. Use @StateObject on our reference type property.

class User: ObservableObject {
    @Published var firstName: String = "Vin"
    @Published var lastName: String = "Bui"
}

struct ContentView: View {
    @StateObject private var user = User()

    var body: some View {
        VStack {
            Text("Hello \(user.firstName) \(user.lastName)")

            TextField("First Name", text: $user.firstName)
            TextField("Last Name", text: $user.lastName)
        }
    }
}

You may be wondering, “Why does @State work for structs but not classes?” When we use @State SwiftUI observes the entire value. When we change the property of a struct, the entire value changes since structs are value types. On the other hand, when we change the property of a class, the entire object does not change and a new copy is not created like it does with structs.

Property Wrappers Cheat Sheet

The above property wrappers that we used only works if the view creates and owns the property. However, properties are often shared among views. If one child view changes a property, we also want to update it in the parent view. There are also different ways of sharing properties: (1) either directly (1-on-1) from a parent to child or (2) shared among every view through the environment.

Below is a list of some of the most common property wrappers and when we would use them.

@State

  • The view itself creates and owns the instance we want to wrap.

  • Property being wrapped is a value type (such as struct or enum).

  • Mark as private for best practice. No external source should modify @State properties.

  • Use $ for a two-way binding to the property (read and write).

@StateObject

  • Similar to @State but used on an ObservableObject (reference types).

  • Changes to @Published properties are notified.

  • The view itself creates and owns the instance.

@Binding

  • @State Creates → @Binding Receives.

  • Read and write to a property that's owned by a parent view.

  • Property being wrapped is a value type (such as struct or enum).

  • Use $ for a two-way binding to the property (read and write).

@ObservedObject

  • @StateObject Creates → @ObservedObject Receives.

  • Similar to @Binding but used on an ObservableObject (reference types).

  • Changes to @Published properties are notified.

  • The view itself DOES NOT create or own the instance.

@EnvironmentObject

  • Similar to @ObservedObject but shared to MULTIPLE views.

  • The view itself DOES NOT create or own the instance.

Flow Chart

Where does the data come from?

  • Owned

    • Immutable Value? Regular Property

    • Mutable Value? @State

    • ObservableObject? @StateObject

  • Parent

    • Immutable Value? Regular Property

    • Mutable Value? @Binding

    • ObservableObject? @ObservedObject

  • Environment (@Environment is to @EnvironmentObject → @State is to @StateObject)

    • EnvironmentalValues, keyPath? @Environment

    • ObservableObject? @EnvironmentObject

  • Saved on Disk

    • UserDefaults?

      • Whole app? @AppStorage

      • Single scene (for MacOS)? @SceneStorage

    • CoreData? @FetchRequest

iOS 17 Onwards

Starting with iOS 17, we longer need to mark our class properties with @Published and conform our class to ObservableObject. All we have to do is add the @Observable property wrapper to our class. Additionally, we can just simply use @State on the class object.

Below is an iOS 17 version of the code we used earlier:

@Observable
class User {
    var firstName: String = "Vin"
    var lastName: String = "Bui"
}

struct ContentView: View {
    @State private var user = User()

    var body: some View {
        VStack {
            Text("Hello \(user.firstName) \(user.lastName)")

            TextField("First Name", text: $user.firstName)
            TextField("Last Name", text: $user.lastName)
        }
    }
}

Last updated