SwiftUI’s 5 Main Property Wrappers and How to Use Them Effectively

June 5, 2023 7 min read

Hey there! I would like to tell you about SwiftUI, a framework for building user interfaces on iOS and macOS. It is very convenient to use because it employs a declarative approach to programming. With SwiftUI, you can describe what your interface should do and look like, and the framework will take care of the rest.

One of the key elements of SwiftUI is the use of property wrappers. These are functional elements that allow you to provide additional logic for properties.

SwiftUI has five main property wrappers:

  1. @State
  2. @Binding
  3. @ObservedObject
  4. @StateObject
  5. @EnvironmentObject

They will become your best friends in development.

@State

@State allows you to create properties that can be changed and, if necessary, update the interface based on these changes. For example, if you want to create a button that changes its color when pressed, you can create a @State variable to store the color and add it to the button:

struct MyButton: View {
    @State var buttonColor = Color.blue

    var body: some View {
        Button("Press me!") {
            buttonColor = Color.red
        }
        .background(buttonColor)
    }
}

@Binding

@Binding allows you to use a value that is stored in one part of the code in another part of the code. It is typically used in SwiftUI to pass a value from one view to another, allowing them to interact with each other. For example, imagine we have two views – one with a text field and the other with a button. We want the text field to update when the user presses the button. To do this, we can use @Binding:

struct ContentView: View {
    @State private var text = ""

    var body: some View {
        VStack {
            TextField("Enter text", text: $text)
            Button("Update text") {
                text = "New text"
            }
            SecondView(text: $text)
        }
    }
}

struct SecondView: View {
    @Binding var text: String

    var body: some View {
        Text(text)
    }
}

In this example, @Binding is used to pass the value from $text (which is in ContentView) to text (which is in SecondView), so when the user presses the button, the text field will update and display the new text.

@ObservedObject

@ObservedObject is used to mark properties that are observed and can change depending on external data changes. This property wrapper subscribes to changes in the object that conforms to the ObservableObject protocol and automatically updates the relevant parts of the interface if the data has changed. Here’s a brief example of using @ObservedObject:

class UserData: ObservableObject {
    @Published var name = "John"
}

struct ContentView: View {
    @ObservedObject var userData = UserData()

    var body: some View {
        VStack {
            Text("Hello, \(userData.name)!")
            TextField("Enter your name", text: $userData.name)
        }
    }
}

In this example, we create a class called UserData, which contains a @Published name. In the ContentView structure, we create a property called userData with the type UserData, using @ObservedObject. We display the value of userData.name in a text field and output it on the screen.

When the user changes the value in the text field, SwiftUI automatically updates the corresponding part of the interface, as the name property is published and observed using @Published. This means we don’t need our own code to update the interface, and we allow SwiftUI to do it for us.

Note: If you don’t know, @Published is a property wrapper from the Combine framework that can be added to a class or structure property, which automatically sends notifications of any changes to the value of that property to anyone who has subscribed to it. In other words, it’s a helper attribute for properties that can be tracked for changes.

@StateObject

@StateObject is a property wrapper used to initialize a class object and store it in the view state in SwiftUI. This means that the object is stored as long as the view exists and is destroyed along with it. Typically, using @StateObject is more practical for class objects that are needed for multiple views, not just one. For example:

class UserData: ObservableObject {
    @Published var name = "John"
    @Published var age = 30
}

struct ContentView: View {
    @StateObject var userData = UserData()
    
    var body: some View {
        NavigationView {
            VStack {
                Text("Name: \(userData.name)")
                Text("Age: \(userData.age)")
                
                NavigationLink(
                    destination: ProfileView(userData: userData),
                    label: {
                        Text("Edit Profile")
                    })
            }
            .navigationTitle("Home")
        }
    }
}

struct ProfileView: View {
    @ObservedObject var userData: UserData
    
    var body: some View {
        Form {
            TextField("Name", text: $userData.name)
            Stepper("Age: \(userData.age)", value: $userData.age)
        }
        .navigationTitle("Profile")
    }
}

In this example,UserData is an object of a class that contains several properties that can be used in multiple views. The class is marked as ObservableObject so it can be used with @StateObject and @ObservedObject.

In ContentView, we create a new UserData object using @StateObject to save the state between transitions between different views. In this case, ContentView displays user data, visualizes it, and contains a link to another view (ProfileView) that can be used to edit the user data.

In ProfileView, we get access to the same UserData object using @ObservedObject to modify user data. When the user changes data, it is automatically updated in ContentView because the same UserData object is used.

Note: Use @ObservedObject if you need to observe changes in a class object from one view and @StateObject if you need to save the state of a class object that affects the display of multiple views.

If you use @ObservedObject instead of @StateObject for an object needed in multiple views, each view will have its own instance of the object, which can lead to problems with data synchronization between views. Therefore, in this case, it is better to use @StateObject.

@EnvironmentObject

@EnvironmentObject is a property wrapper for passing data objects through the SwiftUI view hierarchy. It allows access to the data object from any view in the SwiftUI hierarchy that belongs to the Environment container (e.g., Scene, View, App, etc.). For example, imagine we have a task list management app. We can have a root ContentView that contains a list of tasks and the ability to create new tasks. For this, we create a separate TaskListView view that displays the list of tasks and a button to add new tasks. After adding a new task, the user should be redirected to the add task screen, so we create a separate AddTaskView view.

To pass the UserManager object to all three views, we can create its instance in ContentView, and then pass it as a parameter to both TaskListView and AddTaskView. However, this can become a problem if we decide to add even more nested views, as we will need to pass UserManager through many intermediate views.

Instead of this, we can use @EnvironmentObject to pass UserManager down through the view hierarchy. This way, all views that need access to UserManager can simply declare it as an @EnvironmentObjectand use it as needed.


struct TaskManagerApp: App {
    @StateObject var userManager = UserManager()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(userManager)
        }
    }
}

struct ContentView: View {
    var body: some View {
        NavigationView {
            TaskListView()
        }
    }
}

struct TaskListView: View {
    @EnvironmentObject var userManager: UserManager
    
    var body: some View {
        List(userManager.tasks) { task in
            TaskRow(task: task)
        }
        .navigationBarTitle("Tasks")
        .navigationBarItems(trailing:
            Button(action: {
                // Navigate to AddTaskView
            }) {
                Image(systemName: "plus")
            }
        )
    }
}

struct AddTaskView: View {
    @EnvironmentObject var userManager: UserManager
    
    var body: some View {
        // Add new task using userManager
    }
}

So, now the UserManager object will be automatically passed to TaskListView and AddTaskView via @EnvironmentObject. Note that we can modify the state of UserManager in one view, and the changes will automatically be reflected in the other view.

These property wrappers form the foundation of working with app state in SwiftUI. Use this article as a cheat sheet to have the basic property wrappers at your fingertips, which are necessary for developing apps with SwiftUI. By applying this knowledge, you will be able to build more complex user interfaces with dynamically changing states and integrate data from your models into SwiftUI.

Don't See Your Dream Job?