SwiftUI Observable and Environment Objects – A Tutorial

The chapter entitled Working with SwiftUI State, Observable and Environment Objects introduced the concept of observable and environment objects and explained how these are used to implement a data driven approach to app development in SwiftUI.

This chapter will build on the knowledge from the earlier chapter by creating a simple example project that makes use of both observable and environment objects.

1.1 About the ObservableDemo Project

Observable objects are particularly powerful when used to wrap dynamic data (in other words, data values that change repeatedly). To simulate data of this type, an observable data object will be created which makes use of the Foundation framework Timer object configured to update a counter once every second. This counter will be published so that it can be observed by views within the app project.

Initially, the data will be treated as an observable object and passed from one view to another. Later in the chapter, the data will be converted to an environment object so that it can be accessed by multiple views without being passed from one to the other.

1.2 Creating the Project

Launch Xcode and select the option to create a new Single View App named ObservableDemo with the User Interface option set to SwiftUI.

1.3 Adding the Observable Object

The first step after creating the new project is to add a data class implementing the ObservableObject protocol. Within Xcode, select the File -> New -> File… menu option and, in the resulting template dialog, select the Swift File option. Click the Next button and name the file TimerData before clicking the Create button.

With the TimerData.swift file loaded into the code editor, implement the TimerData class as follows:

import Foundation
import Combine

class TimerData : ObservableObject {

    @Published var timeCount = 0
    var timer : Timer?

    init() {    
        timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(timerDidFire), userInfo: nil, repeats: true)
    }

    @objc func timerDidFire() {
        timeCount += 1
    }

    func resetCount() {
        timeCount = 0
    }
}

The class is declared as implementing the ObservableObject protocol and contains an initializer which simply configures a Timer instance to call a function named timerDidFire() once every second. The timerDidFire() function, in turn, increments the value assigned to the timeCount variable. The timeCount variable is declared using the @Published property wrapper so that it can be observed from within views elsewhere in the project. The class also includes a method named resetCount() to reset the counter to zero.

1.4 Designing the ContentView Layout

The user interface for the app will consist of two screens, the first of which will be represented by the ContentView.Swift file. Select this file to load it into the code editor and modify it so that it reads as follows:

import SwiftUI

struct ContentView: View {

    @ObservedObject var timerData: TimerData = TimerData()

    var body: some View {

        NavigationView {
            VStack {
                Text("Timer count = \(timerData.timeCount)")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                    .padding()

                Button(action: resetCount) {
                    Text("Reset Counter")
                }
            }
        }
    }

    func resetCount() {
        timerData.resetCount()
    }
}

struct ContentView_Previews: PreviewProvider {

    static var previews: some View {
        ContentView()
    }
}

With the changes made, use the Live Preview button to test the view. Once the live preview starts, the counter should begin incrementing:

Figure 23‑1

Next, click on the Reset Counter button and verify that the counter restarts counting from zero. Now that the initial implementation is working, the next step is to add a second view which will also need access to the same observable object.

1.5 Adding the Second View

Select the File -> New -> File… menu option, this time choosing the SwiftUI View template option and naming the view SecondView. Edit the SecondView declaration so that it reads as follows:

import SwiftUI

struct SecondView: View {

    @ObservedObject var timerData: TimerData

    var body: some View {
        VStack {
            Text("Second View")
                .font(.largeTitle)
            Text("Timer Count = \(timerData.timeCount)")
                .font(.headline)
        }
        .padding()
    }
}

struct SecondView_Previews: PreviewProvider {

    static var previews: some View {
        SecondView(timerData: TimerData())
    }
}

Use live preview to test that the layout matches Figure 23‑2 and that the timer begins counting.

In the live preview, the view has its own instance of TimerData which was configured in the SecondView_Previews declaration. To make sure that both ContentView and SecondView are using the same TimerData instance, the observed object needs to be passed to the SecondView when the user navigates to the second screen.

Figure 23‑2

1.6 Adding Navigation

A navigation link now needs to be added to ContentView configured to navigate to the second view. Open the ContentView.swift file in the code editor and add this link as follows:

var body: some View {

    NavigationView {
        VStack {
            Text("Timer count = \(timerData.timeCount)")
                .font(.largeTitle)
                .fontWeight(.bold)
                .padding()
          
            Button(action: resetCount) {
                Text("Reset Counter")
            }

            NavigationLink(destination: 
                       SecondView(timerData: timerData)) {
                Text("Next Screen")
            }
            .padding()
        }
    }
}

Once again using live preview, test the ContentView and check that the counter increments. Taking note of the current counter value, click on the Next Screen link to display the second view and verify that counting continues from the same number. This confirms that both views are subscribed to the same observable object instance.

1.7 Using an Environment Object

The final step in this tutorial is to convert the observable object to an environment object. This will allow both views to access the same TimerData object without the need for a reference to be passed from one view to the other.

This change does not require any modifications to the TimerData.swift class declaration and only minor changes are needed within the two SwiftUI view files. Starting with the ContentView.swift file, change the @ObservedObject property wrapper to @EnvironmentObject and modify the ContentView_Previews declaration to add an instance of the timer to the environment when previewing the layout:

import SwiftUI

struct ContentView: View {

    @EnvironmentObject var timerData: TimerData

    var body: some View {
        NavigationView {
.
.
                NavigationLink(destination: SecondView()) {
                    Text("Next Screen")
                }
                .padding()
            }
        }
    }
.
.
struct ContentView_Previews: PreviewProvider {

    static var previews: some View {
        ContentView().environmentObject(TimerData())
    }
}

Next, modify the SecondView.swift file so that it reads as follows:

import SwiftUI

struct SecondView: View {

    @EnvironmentObject var timerData: TimerData

    var body: some View {

        VStack {
            Text("Second View")
                .font(.largeTitle)

            Text("Timer Count = \(timerData.timeCount)")
                .font(.headline)
        }
        .padding()
    }
}

struct SecondView_Previews: PreviewProvider {

    static var previews: some View {
        SecondView().environmentObject(TimerData())
    }
}

Finally, modify the SceneDelegate.swift file so that a TimerData object is added to the environment when the root scene is created:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, 
             options connectionOptions: UIScene.ConnectionOptions) {

    let contentView = ContentView()
    let timerData = TimerData()

    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = 
                  UIHostingController(rootView: 
                     contentView.environmentObject(timerData))

        self.window = window
        window.makeKeyAndVisible()
    }
}

Test the project one last time, either using live preview or by running on a physical device or simulator and check that both screens are accessing the same counter data via the environment.

1.8 Summary

This chapter has worked through a tutorial that demonstrates the use of observed and environment objects to bind dynamic data to the views in a user interface, including implementing an observable object, publishing a property, subscribing to an observable object and the use of environment objects.