Working with SwiftUI State, Observable 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 three options for implementing this behavior in the form of state properties, observable 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 three 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.

1.1 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 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 re-rendered 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.

Note that the Image view in the previous example uses systemName image references. This provides access to the built-in library of SF Symbol drawings. SF Symbols is a collection of 1500 scalable vector drawings available for use when developing apps for Apple platforms and designed to complement Apple’s San Francisco system font.

The full set of symbols can be searched and browsed by installing the SF Symbols macOS app available from the following URL:

https://developer.apple.com/design/downloads/SF-Symbols.dmg

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

1.3 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 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 the @ObservedObject 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()
    }
}

As the published data changes, SwiftUI will automatically re-render the view layout to reflect the new state.

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

.
.
@ObservedObject 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 SwiftUI environment and can be accessed by all views without needing to be passed from view to view.

Objects needing to subscribe to an environment object simply reference the object using the @EnvironmentObject property wrapper instead of the @ObservedObject wrapper:

@EnvironmentObject var demoData: DemoData

Environment objects cannot be initialized within an observer so must be configured during the setup of the scene in which the accessing views reside. This involves making some changes to the willConnectTo method of the project’s SceneDelegate.swift file. By default, this method will contain code that reads as follows:

let contentView = ContentView()

if let windowScene = scene as? UIWindowScene {

    let window = UIWindow(windowScene: windowScene)

    window.rootViewController = 
           UIHostingController(rootView: contentView)

    self.window = window
    window.makeKeyAndVisible()
}

To store an instance of our DemoData object in the environment, the above code will need to be changed as follows:

let contentView = ContentView()
let demoData = DemoData()

if let windowScene = scene as? UIWindowScene {
  
    let window = UIWindow(windowScene: windowScene)

    window.rootViewController = 
            UIHostingController(rootView:     
                 contentView.environmentObject(demoData))

    self.window = window
    window.makeKeyAndVisible()
}

To make use of an environment object in the SwiftUI preview canvas, the preview provider declaration also needs to be modified:

struct ContentView_Previews: PreviewProvider {
  
    static var previews: some View {
      
        ContentView().environmentObject(DemoData())

    }
} 

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 layout views.

1.5 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 values are transient and 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 property wrapper.

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. The environment object must also be initialized when the view scene is added to the app via code within the scene delegate class.