SwiftUI State Properties, Observable, State and Environment Objects

Earlier chapters have described how SwiftUI emphasizes a data-driven approach to app development whereby the views in the user interface are updated in response to changes in the underlying data without the need to write handling code. This approach is achieved by establishing a publisher and subscriber relationship between the data and the views in the user interface.

SwiftUI offers four options for implementing this behavior in the form of state properties, observation, and environment objects, all of which provide the state that drives the way the user interface appears and behaves. In SwiftUI, the views that make up a user interface layout are never updated directly within code. Instead, the views are updated automatically based on the state objects to which they have been bound as they change over time.

This chapter will describe these four options and outline when they should be used. Later chapters, A SwiftUI Example Tutorial and SwiftUI Observable and Environment Objects – A Tutorial) will provide practical examples demonstrating their use.

State Properties

The most basic form of state is the state property. State properties are used exclusively to store state that is local to a view layout, such as whether a toggle button is enabled, the text being entered into a text field, or the current selection in a Picker view. State properties are used for storing simple data types such as a String or an Int value and are declared using the @State property wrapper, for example:

struct ContentView: View {
 
    @State private var wifiEnabled = true
    @State private var userName = ""
 
    var body: some View {
.
.Code language: Swift (swift)

Since state values are local to the enclosing view, they should be declared as private properties.

 

 

You are reading a sample chapter from iOS 17 App Development Essentials.

Buy the full book now in eBook (PDF and ePub) or Print format.

The full book contains 68 chapters, over 580 pages of in-depth information, and downloadable source code.

Learn more.

Preview  Buy eBook  Buy Print

 

Every change to a state property value signals to SwiftUI that the view hierarchy within which the property is declared needs to be re-rendered. This involves rapidly recreating and displaying all of the views in the hierarchy, which, in turn, ensures that any views that rely on the property in some way are updated to reflect the latest value.

Once declared, bindings can be established between state properties and the views contained in the layout. Changes within views referencing the binding are then automatically reflected in the corresponding state property. A binding could, for example, be established between a Toggle view and the Boolean wifiEnabled property declared above. SwiftUI automatically updates the state property to match the new toggle setting whenever the user switches the toggle.

A binding to a state property is implemented by prefixing the property name with a ‘$’ sign. In the following example, a TextField view establishes a binding to the userName state property to use as the storage for text entered by the user:

struct ContentView: View {
    
    @State private var wifiEnabled = true
    @State private var userName = ""
 
    var body: some View {
        VStack {
            TextField("Enter user name", text: $userName)
        }
    }
}Code language: Swift (swift)

With each keystroke performed as the user types into the TextField, the binding will store the current text into the userName property. Each change to the state property will, in turn, cause the view hierarchy to be rerendered by SwiftUI.

Of course, storing something in a state property is only one side of the process. As previously discussed, a state change usually results in a change to other views in the layout. In this case, a Text view might need to be updated to reflect the user’s name as it is typed. This can be achieved by declaring the userName state property value as the content for a Text view:

 

 

You are reading a sample chapter from iOS 17 App Development Essentials.

Buy the full book now in eBook (PDF and ePub) or Print format.

The full book contains 68 chapters, over 580 pages of in-depth information, and downloadable source code.

Learn more.

Preview  Buy eBook  Buy Print

 

var body: some View {
   VStack {
        TextField("Enter user name", text: $userName)
        Text(userName)
    }
}Code language: Swift (swift)

The Text view will automatically update as the user types to reflect the user’s input. The userName property is declared without the ‘$’ prefix in this case. This is because we are now referencing the value assigned to the state property (i.e., the String value being typed by the user) instead of a binding to the property.

Similarly, the hypothetical binding between a Toggle view and the wifiEnabled state property described above could be implemented as follows:

var body: some View {
    
    VStack {
        Toggle(isOn: $wifiEnabled) {
            Text("Enable Wi-Fi")
        }
        TextField("Enter user name", text: $userName)
        Text(userName)  
        Image(systemName: wifiEnabled ? "wifi" : "wifi.slash")
    }
}Code language: Swift (swift)

The above declaration establishes a binding between the Toggle view and the state property. The value assigned to the property is then used to decide which image will be displayed on an Image view.

State Binding

A state property is local to the view it is declared in and any child views. Situations may occur, however, where a view contains one or more subviews that may also need access to the same state properties. Consider, for example, a situation whereby the WiFi Image view in the above example has been extracted into a subview:

.
.
    VStack {
        Toggle(isOn: $wifiEnabled) {
            Text("Enable WiFi")
        }
        TextField("Enter user name", text: $userName)
        WifiImageView()
    }
}
 
struct WifiImageView: View {
 
    var body: some View {
        Image(systemName: wifiEnabled ? "wifi" : "wifi.slash")
    }
}Code language: Swift (swift)

Clearly, the WifiImageView subview still needs access to the wifiEnabled state property. As an element of a separate subview, however, the Image view is now out of the scope of the main view. Within the scope of WifiImageView, the wifiEnabled property is an undefined variable.

 

 

You are reading a sample chapter from iOS 17 App Development Essentials.

Buy the full book now in eBook (PDF and ePub) or Print format.

The full book contains 68 chapters, over 580 pages of in-depth information, and downloadable source code.

Learn more.

Preview  Buy eBook  Buy Print

 

This problem can be resolved by declaring the property using the @Binding property wrapper as follows:

struct WifiImageView: View {
    
    @Binding var wifiEnabled : Bool
    
    var body: some View {
        Image(systemName: wifiEnabled ? "wifi" : "wifi.slash")
    }
}Code language: Swift (swift)

Now, when the subview is called, it simply needs to be passed a binding to the state property:

WifiImageView(wifiEnabled: $wifiEnabled)Code language: Swift (swift)

Observable Objects

State properties provide a way to store the state of a view locally, are available only to the local view, and, as such, cannot be accessed by other views unless they are subviews and state binding is implemented. State properties are also transient in that when the parent view goes away, the state is also lost. On the other hand, Observable objects represent persistent data that is both external and accessible to multiple views.

An Observable object takes the form of a class that conforms to the ObservableObject protocol. Though the implementation of an observable object will be application-specific depending on the nature and source of the data, it will typically be responsible for gathering and managing one or more data values known to change over time. Observable objects can also handle events such as timers and notifications.

The observable object publishes the data values it is responsible for as published properties. Observer objects then subscribe to the publisher and receive updates whenever published properties change. As with the state properties outlined above, by binding to these published properties, SwiftUI views will automatically update to reflect changes in the data stored in the observable object.

 

 

You are reading a sample chapter from iOS 17 App Development Essentials.

Buy the full book now in eBook (PDF and ePub) or Print format.

The full book contains 68 chapters, over 580 pages of in-depth information, and downloadable source code.

Learn more.

Preview  Buy eBook  Buy Print

 

Before the introduction of iOS 17, observable objects were managed using the Combine framework, which was introduced to make it easier to establish relationships between publishers and subscribers. While this option is still available, a simpler alternative is now available following the introduction of the Observation framework (typically referred to as just “Observation”).

However, before we look at how to use Observation, we will cover the old Combine framework approach. We are doing this for two reasons. First, learning about the old way will help you to understand how the new Observation works behind the scenes. Second, you will encounter many code examples online that use the Combine framework. Understanding how to migrate to Observation will help you re-purpose those examples for your needs.

Observation using Combine

The Combine framework provides a platform for building custom publishers for performing various tasks, from merging multiple publishers into a single stream to transforming published data to match subscriber requirements. This allows for complex, enterprise-level data processing chains to be implemented between the original publisher and the resulting subscriber. That being said, one of the built-in publisher types will typically be all that is needed for most requirements. The easiest way to implement a published property within an observable object is to use the @Published property wrapper when declaring a property. This wrapper sends updates to all subscribers each time the wrapped property value changes.

The following class shows a simple observable object declaration with two published properties:

import Foundation
import Combine
 
class DemoData : ObservableObject {
    
    @Published var playerName = ""
    @Published var score = 0
    
    init() {
        // Code here to initialize data
        updateData()
    }
    
    func updateData() {
        // Code here to update the data
        score += 1 
    }
}Code language: Swift (swift)

A subscriber uses either the @ObservedObject or @StateObject property wrapper to subscribe to the observable object. Once subscribed, that view and any of its child views access the published properties using the same techniques used with state properties earlier in the chapter. A sample SwiftUI view designed to subscribe to an instance of the above DemoData class might read as follows:

 

 

You are reading a sample chapter from iOS 17 App Development Essentials.

Buy the full book now in eBook (PDF and ePub) or Print format.

The full book contains 68 chapters, over 580 pages of in-depth information, and downloadable source code.

Learn more.

Preview  Buy eBook  Buy Print

 

import SwiftUI
 
struct ContentView: View {
    
    @ObservedObject var demoData : DemoData = DemoData(player: "John")
    
    var body: some View {
        VStack {
            Text("\(demoData.playerName)'s Score = \(demoData.score)")
            Button(action: {
                demoData.update()
            }, label: {
                Text("Update")
            })
            .padding()
        }
    }
}Code language: Swift (swift)

When the update button is clicked, the published score variable will change, and SwiftUI will automatically rerender the view layout to reflect the new state.

Combine State Objects

The State Object property wrapper (@StateObject) was introduced in iOS 14 as an alternative to the @ ObservedObject wrapper. The key difference between a state object and an observed object is that an observed object reference is not owned by the view in which it is declared and, as such, is at risk of being destroyed or recreated by the SwiftUI system while still in use (for example as the result of the view being re-rendered).

Using @StateObject instead of @ObservedObject ensures that the reference is owned by the view in which it is declared and, therefore, will not be destroyed by SwiftUI while it is still needed, either by the local view in which it is declared or any child views. For example:

import SwiftUI
 
struct ContentView: View {
    
    @StateObject var demoData : DemoData = DemoData()
    
    var body: some View {
.
.
}Code language: Swift (swift)

Using the Observation Framework

Using Observation instead of the Combine framework will provide us with the same behavior outlined above but with simpler code. To switch the DemoData class to use Observation, we need to make the following changes:

import Foundation
 
@Observable class DemoData {
    
    var playerName = ""
    var score = 0
    
    init(player: String) {
        self.playerName = player
    }
    
    func update() {
        score += 1
    }
}Code language: Swift (swift)

Instead of declaring the DemoData as a subclass of ObservableObject, we now prefix the declaration with the @ Observable macro. We also no longer need to use the @Published property wrappers because the macro handles this automatically.

 

 

You are reading a sample chapter from iOS 17 App Development Essentials.

Buy the full book now in eBook (PDF and ePub) or Print format.

The full book contains 68 chapters, over 580 pages of in-depth information, and downloadable source code.

Learn more.

Preview  Buy eBook  Buy Print

 

The code in the ContentView is also simplified by removing the @ObservedObject directive:

struct ContentView: View {
    
    var demoData : DemoData = DemoData(player: "John")
.
.Code language: Swift (swift)

Where the @StateObject property wrapper was used, this can be replaced with @State as follows:

import SwiftUI
 
struct ContentView: View {
    
    @State var demoData : DemoData = DemoData()
    
    var body: some View {
.
.
}Code language: Swift (swift)

Observation and @Bindable

Earlier in the chapter, we introduced state binding and explained how it is used to pass state properties from one view to another. Suppose that our example layout uses a separate view named ScoreView to display the score as follows:

struct ContentView: View {
    
    var demoData : DemoData = DemoData(player: "John")
    
    var body: some View {
        VStack {
            ScoreView(score: $demoData.score) // Syntax error        
            Text("\(demoData.playerName)'s Score")
            Button(action: {
                demoData.update()
            }, label: {
                Text("Update")
            })
            .padding()
        }
    }
}
 
struct ScoreView: View {
    
    @Binding var score: Int
    
    var body: some View {
        Text("\(score)")
            .font(.system(size: 150))
    }
}
Code language: Swift (swift)

The above code will report an error indicating that $demoData.score cannot be found. To correct this, we need to apply the @Bindable property wrapper to the demoData declaration. This property wrapper is used when we need to create bindings from the properties of observable objects. To resolve the problem with the above example, we need to make the following change:

@Bindable var demoData : DemoData = DemoData(player: "John")Code language: Swift (swift)

Environment Objects

Observed objects are best used when a particular state needs to be used by a few SwiftUI views within an app. When one view navigates to another view that needs access to the same observed or state object, the originating view will need to pass a reference to the observed object to the destination view during the navigation (navigation will be covered in the chapter entitled SwiftUI Lists and Navigation). Consider, for example, the following code:

 

 

You are reading a sample chapter from iOS 17 App Development Essentials.

Buy the full book now in eBook (PDF and ePub) or Print format.

The full book contains 68 chapters, over 580 pages of in-depth information, and downloadable source code.

Learn more.

Preview  Buy eBook  Buy Print

 

.
.
var demoData : DemoData = DemoData()
.
.
NavigationLink(destination: SecondView(demoData)) {
    Text("Next Screen")
}Code language: Swift (swift)

In the above declaration, a navigation link is used to navigate to another view named SecondView, passing through a reference to the demoData observed object.

While this technique is acceptable for many situations, it can become complex when many views within an app need access to the same observed object. In this situation, using an environment object may make more sense.

An environment object is declared in the same way as an observable object. The key difference, however, is that the object is stored in the environment of the view in which it is declared and, as such, can be accessed by all child views without needing to be passed from view to view.

Consider the following example observable declaration:

@Observable class SpeedSetting {
    var speed = 0.0
}Code language: Swift (swift)

Suppose that a second view also needs access to the speed data but needs to create a binding to the speed property. In this case, we need to use the @Bindable property wrapper as follows:

 

 

You are reading a sample chapter from iOS 17 App Development Essentials.

Buy the full book now in eBook (PDF and ePub) or Print format.

The full book contains 68 chapters, over 580 pages of in-depth information, and downloadable source code.

Learn more.

Preview  Buy eBook  Buy Print

 

struct SpeedControlView: View {
    @Environment(SpeedSetting.self) var speedsetting: SpeedSetting
  
    var body: some View {
        @Bindable var speedsetting = speedsetting
        Slider(value: $speedsetting.speed, in: 0...100)
    }
}Code language: Swift (swift)

At this point, we have an observable object named SpeedSetting and two views that reference an environment object of that type. Still, we have not yet initialized an instance of the observable object. The logical place to perform this task is the parent view of the above sub-views. In the following example, both views are sub-views of the main ContentView:

struct ContentView: View {
    let speedsetting = SpeedSetting()
 
    var body: some View {
        VStack {
            SpeedControlView()
            SpeedDisplayView()
        }
    }
}Code language: Swift (swift)

If the app were to run at this point, however, it would crash shortly after launching with the following diagnostics:

Thread 1: Fatal error: No ObservableObject of type SpeedSetting found. A View.environmentObject(_:) for SpeedSetting may be missing as an ancestor of this view.Code language: plaintext (plaintext)

The problem is that while we have created an instance of the observable object within ContentView, we still need to insert it into the view hierarchy environment. This is achieved using the environment() modifier, passing through the observable object instance as follows:

struct ContentView: View {
    let speedsetting = SpeedSetting()
 
    var body: some View {
        VStack {
            SpeedControlView()
            SpeedDisplayView()
        }
        .environment(speedsetting)
    }
}Code language: Swift (swift)

Once these steps have been taken, the object will behave the same way as an observed object, except that it will be accessible to all child views of the content view without being passed down through the view hierarchy. When the slider in SpeedControlView is moved, the Text view in SpeedDisplayView will update to reflect the current speed setting, thereby demonstrating that both views are accessing the same environment object:

Figure 22-1

Summary

SwiftUI provides three ways to bind data to an app’s user interface and logic. State properties store the views’ state in a user interface layout and are local to the current content view. These transient values are lost when the view goes away.

 

 

You are reading a sample chapter from iOS 17 App Development Essentials.

Buy the full book now in eBook (PDF and ePub) or Print format.

The full book contains 68 chapters, over 580 pages of in-depth information, and downloadable source code.

Learn more.

Preview  Buy eBook  Buy Print

 

The Observation framework can be used for data that is external to the user interface and is required only by a subset of the SwiftUI view structures in an app. Using this approach, the @Observable macro must be applied to the class that represents the data. To bind to an observable object property in a view declaration, the property must use the @Bindable property wrapper.

The environment object provides the best solution for data external to the user interface, but for which access is required for many views. Although declared the same way as observable objects, environment object bindings are declared in SwiftUI View files using the @Environment property wrapper. Before becoming accessible to child views, the environment object must also be initialized before being inserted into the view hierarchy using the environment() modifier.


Categories