Chapters

Hide chapters

SwiftUI by Tutorials

Third Edition · iOS 14 · Swift 5.3 · Xcode 12

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

9. State & Data Flow – Part II
Written by Antonio Bello

In the previous chapter you learned how to use @State and @Binding, and the power that they brought to you in a transparent and easy to use way.

In this chapter you’ll learn about other tools that allows you to make your own types efficiently reactive, or reactively efficient. :]

Before diving into it, while you’re still dry, a word about the project. You can use the starter project that comes with this chapter, but since it is an exact copy of the final project from the previous chapter, you can also reuse what you’ve worked on, if you prefer — no change needed.

The art of observation

So, you use a binding to pass data that a source of truth owns, and a state to additionally own the data itself. You have everything you need to create an awesome user interface, right? Wrong!

Consider that you have a model made up of several properties and you want to use it as a state variable. If you implement the model as a value type, like a struct, it works properly, but it’s not efficient.

In fact, if you have an instance of a struct and you modify one of its properties, you actually replace the entire instance by a copy of it with the updated property. In other words, the entire instance mutates.

When you change a property of your model, you’d expect that only the UI that references that property should refresh. In reality, you’ve modified the whole struct instance, so the update will trigger a refresh in all places that reference the struct.

Depending on the use case, this could have a low impact or it could affect performance considerably.

That doesn’t mean you shouldn’t use structs, just that you should avoid putting unrelated properties in the same model. This prevents cases where updating a property value triggers a UI update that doesn’t use that property.

If you implement your model as a reference type instead — that is, a class — it won’t actually work. If a property is a reference type, it mutates only if you assign a new reference. Any change made to the actual instance doesn’t change the property itself, which means it won’t trigger any UI refresh.

Making an Object Obsevable

The good news is that you have four new types that come to your rescue. Given the considerations expressed above, your custom model should:

  • Be a reference type.
  • Be able to specify which properties must trigger — or not trigger — UI updates.

You need the three new types to:

  • Declare a class observable. This enables it to be used similarly to state properties.

  • Declare a class property observable.

  • Declare a property that’s an instance of an observable class type, observed. This lets you use an observable class as an observed property in a view.

There are already two classes that you can use as observable objects: UserManager and ChallengesViewModel.

To make a class observable, make it conform to ObservableObject. The class becomes a publisher. The protocol defines one objectWillChange property only, which synthesizes automatically. That means you aren’t required to implement it — the compiler will do it for you.

Open Profile/UserManager.swift and look at the class declaration:

// 1
final class UserManager: ObservableObject {
  // 2
  @Published
  var profile: Profile = Profile()

  @Published
  var settings: Settings = Settings()

  @Published
  var isRegistered: Bool
  ...
}

Here you can see that:

  1. The class conforms to ObservableObject, which makes it a publisher.
  2. You define three properties and decorate them with the @Published attribute. These properties work as a state property does in a view.

The same considerations you made for state properties apply to published properties as well:

  • They should be value types, either basic data types or structures.
  • With structures, it’s better to limit the number of properties they contain to the minimum required, avoiding one-struct-for-all scenarios.

Once you have an observable class, using it is pretty simple — it’s just like using a state variable.

Observing an Object

As mentioned earlier, there’s another observable class in the project, in Practice/ChallengesViewModel.swift. Its purpose is to define and serve challenges, which consist of a Japanese word, its English translation and a list of potential answers. Only one answer is correct.

There’s a property that contains the currently-active challenge:

@Published var currentChallenge: ChallengeTest?

As you see, it’s a published property which is like a state property:

  • It defines a single source of truth.
  • It has a binding.
  • Whenever it’s updated, it triggers a UI refresh that references it.

The most natural place to use this property is in the challenge view. Later, you’ll realize that is not entirely true — in fact the view already contains a challenge property. But for now, just pretend it is.

Open ChallengeView.swift and add this property after the existing challengeTest:

@ObservedObject var challengesViewModel = ChallengesViewModel()

Next, replace the two occurrences of challengeTest with challengesViewModel.currentChallenge!. The first is where you use QuestionView:

QuestionView(question:
  challengesViewModel.currentChallenge!.challenge.question)

The second is a few lines below, where you use ChoicesView:

ChoicesView(
  challengeTest: challengesViewModel.currentChallenge!)

Run the app now and navigate to the challenge view. You won’t notice any difference. Tapping the upper half of the view will toggle the choices view, as before.

You can, however, temporarily apply a change to the published property. In the button’s action handler, call the view model’s method to advance to the next challenge:

Button(action: {
  self.showAnswers.toggle()
  // 1
  self.challengesViewModel.generateRandomChallenge()
}) {
  QuestionView(question:
    challengesViewModel.currentChallenge!.challenge.question
  )
    .frame(height: 300)
}

generateRandomChallenge() picks a new random challenge and puts it in currentChallenge. Since the property changes, it triggers a UI refresh. Now, when you run the app and tap the upper half of the view, it will switch to a new challenge.

Current selection
Current selection

You obtained this by adding the @ObservedObject attribute to the challengesViewModel property, instance of ChallengesViewModel. As mentioned earlier, this class defines a @Published property named currentChallenge, which you reference in the code (by passing it to QuestionView and ChoicesView).

When in the button tap handler you call generateRandomChallenge(), that property is mutated, and that causes the places where it is referenced to redraw, so both QuestionView and ChoicesView are refreshed, and that is what makes the new challenge to be displayed.

However, ChallengeView is not the right place for ChallengesViewModel to reside, so you better move it to a more appropriate place. Undo the changes made above by:

  1. Deleting challengesViewModel.
  2. Replacing the two occurrences of challengesViewModel.currentChallenge! with challengeTest.
  3. Deleting self.challengesViewModel.generateRandomChallenge() from the button’s action handler.

Alternately, press Command-Z repeatedly until you undo all the changes.

Note: You have made this to see the differences between one approach and the other. Sorry for making you go back, but this way it clarifies the next explanation.

So, where should challengesViewModel go? PracticeView references ChallengeView. It already contains two properties that are both bindings, so they reference data stored elsewhere.

The purpose of this view is to display a challenge if the user hasn’t completed them all. Otherwise, it will show a congratulations view.

WelcomeView, in turn, references PracticeView. You can see that it already contains a challengesViewModel property, an instance of ChallengesViewModel. It’s also declared as @ObservedObject, which enables its published properties to behave like state properties.

Sharing in the environment

You’ve already played with the app in this chapter, so you’ve probably noticed that the game lacks progress.

When you select the correct answer in a challenge, not much happens other than getting a confirmation alert. The app should advance to the next challenge. You’ll fix that next.

Open ChallengesViewModel.swift and you’ll find two methods to log correct and incorrect answers:

func saveCorrectAnswer(for challenge: Challenge) {
  correctAnswers.append(challenge)
}

func saveWrongAnswer(for challenge: Challenge) {
  wrongAnswers.append(challenge)
}

After saving a correct answer, you want to advance to the next challenge. There’s another method in the class, generateRandomChallenge(), which is perfect for this goal.

Now, you need to use these methods. It turns out, ChoicesView, the view where the user selects one of the options, already uses them.

Look at the view implementation, and you’ll notice that:

  • It has a challengesViewModel property, declared as @ObservedObject.
  • It invokes generateRandomChallenge() in the Alert dismiss button’s handler.
  • It invokes both saveCorrectAnswer() and saveWrongAnswer() in checkAnswer(at:).

However, the app doesn’t work as expected; when you’ve completed one challenge, it doesn’t advance to the next.

The reason is simple: You’re creating an instance of ChallengesViewModel here, but also in WelcomeView. So they’re two different instances, and any change made to one doesn’t propagate to the other.

One possible solution is to pass challengesViewModel from WelcomeView down to ChoicesView, via initializers — but that’s not elegant. Fortunately, there’s a better way.

This might be a typical case where a singleton could do the job pretty well. But, confidentially speaking, the singleton pattern is not the best pattern to use — it creates unnecessary dependencies that you can easily avoid using other patterns, such as dependency injection.

Environment and Objects

SwiftUI provides a way to achieve that. It’s not a dependency injection, just a way to put an object into something like a bag and retrieve it whenever you need it. The bag is called the environment and the object, an environment object.

This pattern uses two of the most popular SwiftUI ways to do things: a modifier and an attribute.

  • Using environmentObject(_:), you inject an object into the environment.
  • Using @EnvironmentObject, you pull an object (actually a reference to an object) out of the environment and store it in a property.

Once you inject an object into the environment, it’s accessible to the view and its subviews, but it’s not accessible from the view’s parent and above.

Just to be sure, inject it into the root view for now. Open KuchiApp.swift and, in body, locate where StarterView instantiates. You’ll find that another object is injected into the environment: an instance of UserManager.

Add the modifier to inject an instance of ChallengesViewModel:

var body: some Scene {
  WindowGroup {
    StarterView()
      .environmentObject(userManager)
      // 1
      .environmentObject(ChallengesViewModel())
  }
}
  1. Here, you’re creating an instance of ChallengesViewModel and injecting it into the environment. All the views in the StarterView’s hierarchy now have access to that instance.

Note: You’re injecting an unnamed instance into the environment. When you pull it using the @EnvironmentObject, you just specify the instance type. This is important to remember because it means that you can only inject one instance per type into the environment. If you inject another instance, it will replace the first.

Now, you have to make a change in all the places that use ChallengesViewModel. So in WelcomeView, replace this property:

@ObservedObject var challengesViewModel = ChallengesViewModel()

with:

@EnvironmentObject var challengesViewModel: ChallengesViewModel
  • You’re using the @EnvironmentObject attribute, specifying that this property must be initialized with an instance of ChallengesViewModel taken from the view’s environment.
  • You no longer need to instantiate it because the property is initialized with an existing instance.

Do the same property replacement in ChoicesView.

Now, build and run and test the app. When you provide a correct answer, it advances to the next challenge.

Challenge sequence
Challenge sequence

However, there are two issues:

  1. The answered challenges counter doesn’t update.
  2. After five correct answers, it shows the congratulations view, but you can’t get away from it. the Play Again button does nothing:

Congrats view
Congrats view

Environment and duplicates (to avoid)

So earlier you left the app with two issues that you’re going to get rid of now.

The latter (getting away from the congratulations view) is a simple fix. Open Practice/CongratulationsView.swift and locate the button at the bottom of the file. Its action handler calls self.challengesViewModel.restart(), which seems the correct way to exit the congratulations view and start over with a new challenge session.

If you look at challengesViewModel, you see that it’s an observed object instantiated inline, whereas it should be taken from the environment. Replace it, as you did with the other cases, with:

@EnvironmentObject var challengesViewModel: ChallengesViewModel

Now, build and run and go to the end of the challenge session. When the congratulations view displays, you can now tap the button to restart the session.

As for the other issue (the answered challenges counter not updating) open Practice/ScoreView.swift. numberOfAnswered is a state property, whereas, in order to function properly, it should be passed from its superview.

You could think about getting the challenge view model from the environment, but that would add an unnecessary dependency. This is a simple view that’s supposed to display a pair of numbers, so it’s better to make it as dumb as possible.

To let the parent pass the parameter, you need to change it to a binding. In numberOfAnswered, replace @State with @Binding and remove the initialization, so it looks like:

@Binding var numberOfAnswered: Int

Now that the property is a binding, you must provide it in the initializer. In fact, the preview now gives an error because of the missing argument. Just add it, passed as binding:

ScoreView(
  numberOfQuestions: 5,
  numberOfAnswered: $numberOfAnswered
)

Likewise, ChallengeView, where you use ScoreView, gives a similar error, but you don’t have any state or binding property to pass. So add a numberOfAnswered to ChallengeView, as you did before:

@Binding var numberOfAnswered: Int

and pass it to ScoreView:

ScoreView(
  numberOfQuestions: 5,
  numberOfAnswered: $numberOfAnswered
)

The preview, again, isn’t happy about these changes, so you have to add some code to make it compile. You need to pass a numberOfAnswered binding. You can add a state property for that:

@State static var numberOfAnswered: Int = 0

Next, update the line where you use ChallengeView by passing the expected parameter:

return ChallengeView(
  challengeTest: challengeTest,
  numberOfAnswered: $numberOfAnswered
)

Almost done. You use ChallengeView in PracticeView, so now the compilation error affects this view. Repeat these familiar steps for the last time — promise!

Add a binding property to PracticeView:

@Binding var numberOfAnswered: Int

Pass the binding to the ChallengeView initializer:

ChallengeView(
  challengeTest: challengeTest!,
  numberOfAnswered: $numberOfAnswered
)

Add a state property to PracticeView_Previews:

@State static var numberOfAnswered: Int = 0

Pass this new property as a binding to ChallengeView:

return PracticeView(
  challengeTest: .constant(challengeTest),
  userName: .constant("Johnny Swift"),
  numberOfAnswered: $numberOfAnswered
)

Now, WelcomeView is the last step of this recursive journey. In it, you already have the challenges view model, taken straight from the environment — you just need to add the property that needs to be passed down to ScoreView.

In ChallengesViewModel, add this computed property:

var numberOfAnswered: Int { return correctAnswers.count }

As you can see, it’s a computed property and it’s read-only — will it work as a binding? Not so well. Go back to WelcomeView and pass this new property as a binding to PracticeView:

PracticeView(
  challengeTest: $challengesViewModel.currentChallenge,
  userName: $userManager.profile.name,
  // Add this
  numberOfAnswered: $challengesViewModel.numberOfAnswered
)

The compiler will inform you that it’s a read-only property so it can’t be assigned. How can you fix this?

Binding has a static method called constant() that creates a binding from an immutable value. This looks like a solution! Replace that line with:

numberOfAnswered: .constant(challengesViewModel.numberOfAnswered)

And voila, now it works!

Score Working
Score Working

Object Ownership

In the previous sections you’ve seen that there are three different ways a view can obtain an observable object:

  • By receiving in the initializer
  • By extracting from the environment
  • By creating an instance itself

In the first two cases, the object is owned by another entity, which can be a parent view or the app (KuchiApp in our case), a dependency container, or the environment.

In the latter case, the instance is owned by the view, but you must not forget that a view is a value type, and that a value type doesn’t really mutate: a new instance incorporating the mutation is created. As a direct consequence, if a view has ownership of a reference type, chances are that when the view mutates, the referenced object is recycled, and a new instance is created.

You probably haven’t noticed, but this actually happens in Kuchi, hidden by the fact that the instance is not actually created in the view, but passed to the initializer.

Before doing anything, uninstall the Kuchi app from whatever you’ve used to run in, simulator or device — you need the app to show you the registration view, but currently there’s no logout or “forget me” feature.

Now open KeyboardFollower.swift, and at the bottom of its initializer add the following print statement:

print("New KeyboardFollower instance created")

This will print a message to the console, so that you know when a new instance is created. Now run the app, from Xcode, and be sure to have the console visible (choose Activate Console from the View => Debug Area menu, or press ⇧+⌘+C).

As soon as RegisterView is displayed, you have confirmation in the console that a new instance of KeyboardFollower is created.

Keyboard follower
Keyboard follower

Now if you start typing, you notice that the console prints a message for each character you type, which means that every key press triggers the creation of a new instance. The same happens if you turn on and off the “Remember me” switch.

Keyboard other followers
Keyboard other followers

Why does this happen? The reason cannot be anything else than the KeyboardFollower instance being owned by RegisterView. You can expect it to have a property instance of KeyboardFollower, and that it is instantiated inline (or in the constructor).

Just to prove that, open RegisterView and check it — but, hold on, that’s not the case! There is such property, but it’s initialized with an instance passed to the initializer, which means that the owner isn’t RegisterView:

@ObservedObject var keyboardHandler: KeyboardFollower

init(keyboardHandler: KeyboardFollower) {
  self.keyboardHandler = keyboardHandler
}

To solve this mystery, you need to look at the code where this initializer is invoked. Open StarterView.swift, and you’ll find:

#if os(iOS)
RegisterView(keyboardHandler: KeyboardFollower())
#endif

It’s pretty obvious that the KeyboardFollower instance is created and immediately passed to the RegisterView’s initializer, so it is not referenced anywhere else — consequently, when RegisterView is reinstantiated, so is KeyboardFollower.

You could easily fix this bug by making StarterView the owner of the KeyboardFollower instance. Add to it a new property:

let keyboardFollower = KeyboardFollower()

Next, pass it to the RegisterView’s initializer:

#if os(iOS)
RegisterView(keyboardHandler: keyboardFollower)
#endif

If you run the app now, you see that KeyboardFollower is instantiated only once. Mission accomplished!

Keyboard follower single instance
Keyboard follower single instance

Now you might think that we’re done with this topic. If this book was about SwiftUI 1.0, then you would be right. But from SwiftUI 2.0 you have a new way to solve issues of this type, where a view is the owner of an observable object.

This comes from the fact that passing an observable object via the initializer is not elegant, unless really needed. If a view requires ownership of an observable object, then any place that uses that view should create the instance, keep a reference, and pass it to the initializer.

The new way is called @StateObject, and you can think of it as a @State for reference types. SwiftUI will make sure that when a view is mutated all its state object properties are retained. It’s like having a static property bound to a mutating value type — since mutation means new instance, SwiftUI takes case of transferring instances of state objects from the mutating to the mutated value type instance.

In StarterView.swift remove the property you’ve added earlier:

let keyboardFollower = KeyboardFollower()

Next, use the parameterless initializer for RegisterView:

#if os(iOS)
RegisterView()
#endif

Ignore for now any compilation error that you might see.

Next, open RegisterView, and replace keyboardHandler:

@ObservedObject var keyboardHandler: KeyboardFollower

With this, which uses the new @StateObject attribute:

@StateObject var keyboardHandler = KeyboardFollower()

Last, you can get rid of its initializer, since it is no longer needed.

Remove:

init(keyboardHandler: KeyboardFollower) {
  self.keyboardHandler = keyboardHandler
}

Now if you run the app you see that KeyboardFollower is still instantiated once, which means that @StateObject is working as expected.

State object
State object

Note that @StateObject is a brand new tool, not meant to be a replacement for other tools. Use the proper tool for each problem:

  • When you want a view to own an observable object, because it conceptually belongs to it, your tool is @StateObject.
  • When an observable object is owned elsewhere, either @ObservedObject or @EnvironmentObject are your tools — choosing one or the other depends from each specific case.

Before moving on, you can safely delete the print statement you added earlier to the KeyboardFollower’s initializer.

Understanding environment properties

SwiftUI provides another interesting and useful way to put the environment to work. Earlier in this chapter, you used it to inject environmental objects that can be pulled from any view down through the view hierarchy.

SwiftUI automatically populates the same environment with system-managed environment values. The list is pretty long, and it’s available at apple.co/2yJO5C1.

For example, you’ll find a property that specifies which color scheme you’re using, dark or light. This isn’t just informative — it’s reactive, meaning that if the property value changes, it triggers a UI update wherever the property is used.

In Kuchi, you’re going to fix an issue in the challenge view: It doesn’t look good if the device is in landscape mode:

Challenge view in landscape
Challenge view in landscape

To make it look better, you want to detect when the device orientation changes and react to that change accordingly. Unfortunately, there’s no such property, at least not an explicit one.

In fact, you can use verticalSizeClass, whose type is an enum. It states whether the vertical size class of the device and orientation is .compact or .regular.

To read the property value and subscribe to changes, you have a new @Environment attribute at your disposal so you can pass the property key path to it. So go ahead and add this property to ChallengeView:

@Environment(\.verticalSizeClass) var verticalSizeClass

Although you can give the property any arbitrary name, it’s better to stick with the original name specified in the key path, to avoid confusion. You don’t need to specify the type; you already know it, since it’s an existing property.

Once you’ve done this, you can differentiate the layout depending on the value of that property. Replace the entire body implementation with:

// 1
@ViewBuilder
var body: some View {
  // 2
  if verticalSizeClass == .compact {
    // 3
    VStack {
      // 4
      HStack {
        Button(action: {
          self.showAnswers = !self.showAnswers
        }) {
          QuestionView(
            question: challengeTest.challenge.question)
        }
        if showAnswers {
          Divider()
          ChoicesView(challengeTest: challengeTest)
        }
      }
      ScoreView(
        numberOfQuestions: 5,
        numberOfAnswered: $numberOfAnswered
      )
    }
  } else {
    // 5
    VStack {
      Button(action: {
        self.showAnswers = !self.showAnswers
      }) {
        QuestionView(
          question: challengeTest.challenge.question)
          .frame(height: 300)
      }
      ScoreView(
        numberOfQuestions: 5,
        numberOfAnswered: $numberOfAnswered
      )
      if showAnswers {
        Divider()
        ChoicesView(challengeTest: challengeTest)
          .frame(height: 300)
          .padding()
      }
    }
  }
}

It seems there are a lot of changes, but really, it’s mostly duplicated code with some adjustments:

  1. You need @ViewBuilder because body can potentially return multiple views.
  2. Here, you check if the vertical class is compact. If it is, it means the device is in landscape mode.
  3. This is the view implementation for the landscape mode. You use the vertical stack to display ScoreView at the bottom.
  4. The horizontal stack just shows QuestionView and ChoicesView next to one another.
  5. This is the previous implementation, which is still good for portrait layout.

Now, build and run and go to the challenge view. When you change the device’s orientation, the layout adapts automatically. Neat!

Challenge view in landscape
Challenge view in landscape

One thing that’s worth mentioning is that at any level in the hierarchy, you can manually assign a different value to any environment property by using a view modifier: .environment(_:_:).

You can test that by setting the vertical size class in one of ChallengeView’s parents. Open WelcomeView and add this modifier to PracticeView:

PracticeView(
  challengeTest: $challengesViewModel.currentChallenge,
  userName: $userManager.profile.name,
  numberOfAnswered:
    .constant(challengesViewModel.numberOfAnswered)
)
  // Add this modifier
  .environment(\.verticalSizeClass, .compact)

You’re now forcing the vertical size class to be compact for PracticeView and all its subviews down in the hierarchy. It takes the key path of the property to modify and the new value — pretty intuitive. :]

Now, just build and run and you’ll have the proof: However you rotate the device, ChallengeView always shows its landscape layout!

Fixed orientation
Fixed orientation

Remove that modifier once you’re done.

Creating custom environment properties

Environment properties are so useful and versatile that it would be great if you could create your own. Well, as it turns out, you can!

Creating a custom environment property is a two-step process:

  1. You have to create a struct type that you’ll use as the property key, conforming to EnvironmentKey.
  2. You add the newly-computed property in an EnvironmentValues extension, using the subscript operator to read and set values.

Some code is worth more than words. ScoreView has an immutable numberOfQuestions property, which defines the number of challenges per session.

If you look at ChallengeView, you can see that it passes a constant instead of the actual number defined in ChallengesViewModel. This is a good candidate to demonstrate how to create and use a custom environment property.

Go to ChallengesViewModel and, at the beginning of the file, add this struct:

struct QuestionsPerSessionKey: EnvironmentKey {
  static var defaultValue: Int = 5
}

This defines:

  • The key to use with the subscript operator.
  • The default value assigned to the property, if it’s not explicitly initialized elsewhere.

Next, you define the actual property. Add this code after the struct:

// 1
extension EnvironmentValues {
  // 2
  var questionsPerSession: Int {
    // 3
    get { self[QuestionsPerSessionKey.self] }
    set { self[QuestionsPerSessionKey.self] = newValue }
  }
}

So, to create the new property, you have to:

  1. Create an EnvironmentValues extension.
  2. Add a questionsPerSession computed property.
  3. Use the QuestionsPerSessionKey type to access the property for both reading and writing.

Now, add a property to ChallengesViewModel that defines the number of questions. It’s better to make it read-only, so it can’t be changed from outside the class:

private(set) var numberOfQuestions = 6

In generateRandomChallenge(), it’s also better to replace the 5 constant with the value of this property:

func generateRandomChallenge() {
  if correctAnswers.count < numberOfQuestions {
    currentChallenge = getRandomChallenge()
  } else {
    currentChallenge = nil
  }
}

This method generates a new challenge if the number of correct answers is less than the number of questions. Otherwise, it sets currentChallenge to nil, indicating the session is over.

In WelcomeView, add this new environment property to the PracticeView’s environment so it will be available to PracticeView and all its subviews:

PracticeView(
  challengeTest: $challengesViewModel.currentChallenge,
  userName: $userManager.profile.name,
  numberOfAnswered:
    .constant(challengesViewModel.numberOfAnswered)
)
  // Add this
  .environment(
    \.questionsPerSession,
    challengesViewModel.numberOfQuestions
  )

Now, you’re ready to use the new property. Go to ChallengeView and add this property:

@Environment(\.questionsPerSession) var questionsPerSession

This pulls questionsPerSession from the environment. Compare it with the other environment variable declared in the same file, verticalSizeClass. The only difference is the name.

Finally, in the two places that reference ScoreView, replace 5 with the new variable, questionsPerSession:

ScoreView(
  numberOfQuestions: questionsPerSession,
  numberOfAnswered: $numberOfAnswered
)

Build and run; now, ScoreView reports the new number of questions.

Custom environment property
Custom environment property

Key points

This was another intense chapter. But in the end, as with the previous one, concepts are simple, once you understand how they work.

To summarize what you’ve learned:

  • 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.
  • You use @EnvironmentObject as a bag where you can inject observable objects. You can then pull them from the view you injected them into and all its descendants.
  • @Environment lets you access a system environment value, such as colorScheme or locale. You can create an environment property, which has all the advantages of a binding, including reactivity.
  • You can also use @Environment to create your own custom environment properties.

Where to go from here?

This chapter completes the state and data flow topic — whereas in the previous chapter you learned how to use observable properties in your views, and how to pass them around, in this chapter you looked at defining and using your own observable types, as well as getting your hands on environment properties.

Suggestions for what to read next are the same as the previous chapter, since, as just said, they both are about the same macro-topic.

Being familiar with Combine can also be beneficial, so check out the documentation at apple.co/2L7kWTy. You probably already know, but in case you don’t, this book has a brother, Combine: Asynchronous Programming with Swift, which you can find at https://bit.ly/3qTFPnG.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.