Data flow in SwiftUI: State, Binding, ObservedObject iOS 22.05.2021

SwiftUI provides several tools to help you manage the flow of data in your app. This approach is achieved by establishing a publisher and subscriber binding between the data and the views in the user interface.

Before we start you should be familiar with property wrappers which augment the behavior of variables. SwiftUI-specific wrappers — @State, @Binding, @ObservedObject and @EnvironmentObject — declare a view's dependency on the data represented by the variable.

Each wrapper indicates a different source of data:

  • @State variables are owned by the view. @State var allocates persistent storage, so you must initialize its value. Apple advises you to mark these private to emphasize that a @State variable is owned and managed by that view specifically. When the property value changes, the UI that uses this property automatically re-renders.
  • @Binding declares dependency on a @State var owned by another view, which uses the $ prefix to pass a binding to this state variable to another view. In the receiving view, @Binding var is a reference to the data, so it doesn't need an initial value. This reference enables the view to edit the state of any view that depends on this data. So, with @Binding, you create a property similar to a state property, but with the data stored elsewhere: in a state property or an observable object of an ancestor view.
  • @ObservedObject declares dependency on a reference type that conforms to the ObservableObject protocol. It implements an objectWillChange property to publish changes to its data. So, using @ObservedObject, you can create a property, an instance of a class conforming to ObservableObject. The class can define one or more @Published properties. These work like state variables, except you implement them in a class rather than within the view. Is useful to change state variables for multiple, but connected Views, for example, when there is a parent-child relationship. These properties must have reference semantics.
  • @EnvironmentObject declares dependency on some shared data — data that's visible to all views in the app. It's a convenient way to pass data indirectly, instead of passing data from parent view to child to grandchild, especially if the child view doesn't need it.

State

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

@State 
private var userName = ""

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.

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 {
            Toggle(isOn: $wifiEnabled) {
                Text("Enable Wi-Fi")
            }
            TextField("Enter user name", text: $userName)
            Text(userName)
            Image(systemName: wifiEnabled ? "wifi" : "wifi.slash")
        }
    }
}

More practical example

struct Todo: Identifiable, Equatable {
    let id = UUID()
    let description: String
    var done: Bool
}

private extension Array where Element == Todo {
    mutating func toggleDone(to todo: Todo) {
        guard let index = self.firstIndex(where: { $0 == todo }) else { return }
        self[index].done.toggle()
    }
}

struct ContentView: View {
    @State
    private var todos: [Todo] = [
        .init(description: "workout", done: false),
        .init(description: "wash the car", done: false),
        .init(description: "fill the car", done: false)
    ]

    var body: some View {
        NavigationView {
            List {
                ForEach(todos) { todo in
                    HStack {
                        Text(todo.description).strikethrough(todo.done)
                        Spacer()
                        Image(systemName: todo.done ? "checkmark.square" : "square")
                    }
                    .contentShape(Rectangle())
                    .onTapGesture {
                        todos.toggleDone(to: todo)
                    }
                }
            }.navigationBarTitle("TODOs")
        }
    }
}

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.

SwiftUI uses the @Binding property wrapper, which basically creates a two-way binding:

  • Any changes in the parent's variable are reflected in the child's variable.
  • Any changes in the child's variable are reflected in the parent's variable.

Consider, for example, a situation whereby the Wi-Fi Image view in the above example has been extracted into a subview:

struct WifiImageView: View {
    @Binding 
    var wifiEnabled : Bool

    var body: some View {
        Image(systemName: wifiEnabled ? "wifi" : "wifi.slash")
    }
}

struct ContentView: View {
    @State 
    private var wifiEnabled = true

    @State 
    private var userName = ""

    var body: some View {
        VStack {
            Toggle(isOn: $wifiEnabled) {
                Text("Enable Wi-Fi")
            }
            TextField("Enter user name", text: $userName)
            Text(userName)
            WifiImageView(wifiEnabled: $wifiEnabled)
        }
    }
}

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.

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

import Foundation
import Combine

class DemoData : ObservableObject {
    @Published 
    var userCount = 1

    @Published 
    var currentUser = "Demo"

    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.

import SwiftUI

struct ContentView: View {
    @ObservedObject 
    var demoData: DemoData = DemoData()

    var body: some View {
        Text("\(demoData.currentUser), you are user number \(demoData.userCount)")
    }
}

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

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.

@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()
        }.environmentObject(speedsetting)
    }
}