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 binding between the data and the views in the user interface.

SwiftUI offers four options for implementing this behavior in the form of state properties, observable objects, state objects 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 that demonstrates 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 {
.
.

Note that since state values are local to the enclosing view they should be declared as private properties.

Every change to a state property value is a signal 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. This, in turn, has the effect of ensuring 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 that reference 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. Whenever the user switches the toggle, SwiftUI will automatically update the state property to match the new toggle setting.

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)
        }
    }
}

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 change of state 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 being typed. This can be achieved by declaring the userName state property value as the content for a Text view:

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

As the user types, the Text view will automatically update to reflect the user’s input. Note that in this case the userName property is declared without the ‘$’ prefix. 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")
    }
}

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

State Binding

A state property is local to the view in which it is declared and any child views. Situations may occur, however, where a view contains one or more subviews which may also need access to the same state properties. Consider, for example, a situation whereby the Wi-Fi 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")
    }
}

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.

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")
    }
}

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

WifiImageView(wifiEnabled: $wifiEnabled)

Observable Objects

State properties provide a way to locally store the state of a view, 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. Observable objects, on the other hand are used to represent persistent data that is both external and accessible to multiple views.

An Observable object takes the form of a class or structure 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 that are known to change over time. Observable objects can also be used to handle events such as timers and notifications.

The observable object publishes the data values for which it is responsible as published properties. Observer objects then subscribe to the publisher and receive updates whenever changes to the published properties occur. 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.

Observable objects are part of the Combine framework, which was first introduced with iOS 13 to make it easier to establish relationships between publishers and subscribers.

The Combine framework provides a platform for building custom publishers for performing a variety of tasks from the merging of 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 resulting subscriber. That being said, one of the built-in publisher types will typically be all that is needed for most requirements. In fact, the easiest way to implement a published property within an observable object is to simply use the @Published property wrapper when declaring a property. This wrapper simply sends updates to all subscribers each time the wrapped property value changes.

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

import Foundation
import Combine
 
class DemoData : ObservableObject {
    
    @Published var userCount = 0
    @Published var currentUser = ""
    
    init() {
        // Code here to initialize data
        updateData()
    }
    
    func updateData() {
        // Code here to keep data up to date 
    }
}

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:

import SwiftUI
 
struct ContentView: View {
    
    @ObservedObject var demoData : DemoData = DemoData()
    
    var body: some View {
        Text("\(demoData.currentUser), you are user number \(demoData.userCount)")
    }
}
 
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

State Objects

Introduced in iOS 14, the State Object property wrapper (@StateObject) is 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.

As a general rule, unless there is a specific need to use @ObservedObject, the recommendation is to use a State Object to subscribe to observable objects. In terms of syntax, the two are entirely interchangeable:

import SwiftUI
 
struct ContentView: View {
    
    @StateObject var demoData : DemoData = DemoData()
    
    var body: some View {
        Text("\(demoData.currentUser), you are user number \(demoData.userCount)")
    }
}
.
.

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 which 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:

.
.
@StateObject var demoData : DemoData = DemoData()
.
.
NavigationLink(destination: SecondView(demoData)) {
    Text("Next Screen")
}

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, it may make more sense to use an environment object.

An environment object is declared in the same way as an observable object (in that it must conform to the ObservableObject protocol and appropriate properties must be published). 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 object declaration:

class SpeedSetting: ObservableObject {
    @Published var speed = 0.0
}

Views needing to subscribe to an environment object simply reference the object using the @EnvironmentObject property wrapper instead of the @StateObject or @ObservedObject wrapper. For example, the following views both need access to the same SpeedSetting data:

struct SpeedControlView: View {
    @EnvironmentObject var speedsetting: SpeedSetting
 
    var body: some View {
        Slider(value: $speedsetting.speed, in: 0...100)
    }
}
 
struct SpeedDisplayView: View {
    @EnvironmentObject var speedsetting: SpeedSetting
 
    var body: some View {
        Text("Speed = \(speedsetting.speed)")
    }
}

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

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

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.

The problem here is that while we have created an instance of the observable object within the ContentView declaration, we have not yet inserted it into the view hierarchy. This is achieved using the environmentObject() modifier, passing through the observable object instance as follows:

struct ContentView: View {
    let speedsetting = SpeedSetting()
 
    var body: some View {
        VStack {
            SpeedControlView()
            SpeedDisplayView()
        }
        .environmentObject(speedsetting)
    }
}

Once these steps have been taken the object will behave in the same way as an observed object, except that it will be accessible to all child views of the content view without having to be 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 the user interface and logic of an app. State properties are used to store the state of the views in a user interface layout and are local to the current content view. These transient values are lost when the view goes away.

For data that is external to the user interface and is required only by a subset of the SwiftUI view structures in an app, the observable object protocol should be used. Using this approach, the class or structure which represents the data must conform to the ObservableObject protocol and any properties to which views will bind must be declared using the @Published property wrapper. To bind to an observable object property in a view declaration the property must use the @ObservedObject or @StateObject property wrapper (@StateObject being the preferred option in the majority of cases).

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