Chapters

Hide chapters

macOS Apprentice

First Edition · macOS 13 · Swift 5.7 · Xcode 14.2

Section II: Building With SwiftUI

Section 2: 6 chapters
Show chapters Hide chapters

Section III: Building With AppKit

Section 3: 6 chapters
Show chapters Hide chapters

7. Data Flow in SwiftUI
Written by Sarah Reichelt

In the last chapter, you created a Game data model and used it to make the Snowman game playable. You imported a data file for generating random words, and you connected the data to all the components of the game view.

In this chapter, you’ll extend your data to include the entire app. This uses a new data object that holds a list of games as well as other data your app needs.

You’ll learn how do set up data classes and their properties and how to pass this data around to the views.

This involves several more property wrappers, so hover your finger over the @ key and get ready.

Creating an App Model

Start Xcode and open your project from the previous chapter, or use the starter project from the downloaded materials for this chapter. Press Command-R to run the app to remind yourself of where you ended:

The starter app
The starter app

The game view is complete, the game is playable and the Game model is functional. The sidebar still only displays placeholder text, so that’s what you’ll add next.

You’ll create a new model class to hold a list of games and the data needed to swap between them.

Select the Models folder in the Project navigator and press Command-N to make a new file. Choose macOS ▸ Source ▸ Swift File and name it AppState.swift.

Right-click the Models group and choose Sort by Name. This isn’t always the most logical sorting, but in this case, it matches the hierarchy of data.

Your previous models have been structures or enumerations, but this one has to be a class. When you learned about classes and structures, you found that classes are reference types and structures are value types. SwiftUI has definite rules about places where you must use reference types and you’re about to encounter one.

Replace the contents of the new file with:

// 1
import SwiftUI

// 2
class AppState: ObservableObject {
}

What does this do?

  1. Start by importing the SwiftUI library. You don’t need it yet, but you will. Importing SwiftUI automatically imports Foundation, which is why you can replace the default import.
  2. This model is a class called AppState and it conforms to the ObservableObject protocol.

Observable Objects

So what is ObservableObject? An ObservableObject is a class that can publish changes to its properties. This is commonly used in SwiftUI to indicate that data has changed and to trigger an update of the views.

Property changes are only published if the property has a special property wrapper.

Insert this code into the class:

// 1
@Published var games: [Game]
// 2
@Published var gameIndex: Int
// 3
@Published var selectedID: Int?

// 4
init() {
  // 5
  let newGame = Game()
  games = [newGame]

  // 6
  gameIndex = 0
  selectedID = 1
}

This sets up the new class:

  1. Create a property called games to holds an array of Game objects. Mark it with the @Published property wrapper so the class publishes any changes.
  2. Another @Published property holds the index of the current game in the games array.
  3. The final @Published property is the ID of the game selected in the sidebar. Since it’s possible to have no game selected, this is an optional.
  4. Use init() to assign the starting values for each of the properties.
  5. Create a new Game and set it as the contents of the games array.
  6. Set the two index properties.

Identifying the Game

When you defined the Letter structure, you made it Identifiable so SwiftUI could loop through it using ForEach with a way of distinguishing each letter.

Your sidebar has to loop through each entry in the games array, so now you’ll make Game conform to Identifiable.

Open Game.swift and add the following property:

let id: Int

This adds the id property required by the Identifiable protocol.

Next, change the definition line to:

struct Game: Identifiable {

init() shows two errors, but it’s really only one. You can’t finish initializing a structure before defining all the properties.

To fix this, replace init() with:

// 1
init(id: Int) {
  // 2
  self.id = id
  // 2
  word = getRandomWord()
}

What changes has this made?

  1. init() now has a single integer argument, called id.
  2. Set the value of the structure’s id to the supplied argument.
  3. Then, assign the random word as before.

As you can imagine, your app now has some problems because you’ve already initialized Game objects without passing in an argument.

Press Command-B to build and then open the Issue navigator to see where the errors are:

Build errors
Build errors

Click each error in turn and replace Game() with:

Game(id: 1)

You may not see three errors at first — in fact there are five. Xcode sometimes can’t get far enough through the build process to catch everything. Keep pressing Command-B and adding the id argument until the app builds successfully.

For the previews, a static id of 1 is fine. In AppState, the first game should have an id of 1. You’re about to change how GameView gets its data, so it can use 1 temporarily.

Adding a State Object

You defined an ObservableObject class, but you haven’t used it yet. This class defines app-wide settings, so you’ll add it to the app Itself.

Open SnowmanApp.swift. In a SwiftUI app, this file defines the starting point. It’s a SwiftUI structure with a body, but this body returns a Scene instead of a View. This Scene contains a single View — a WindowGroup, which defines a window that you can open multiple times. The content of WindowGroup is the view that fills each window.

When you wrote a command-line app in Section 1, main.swift was the entry point for the app. In the same way here, @main marks this file as the starting point for your SwiftUI app.

Now you know a bit about how SwiftUI defines its app and windows, add this property to SnowmanApp, before body:

@StateObject var appState = AppState()

This creates an instance of AppState and marks it with the @StateObject property wrapper. When you’re initializing an ObservableObject, you use @StateObject to indicate that this structure owns the observable object.

Environment Objects

Now that your app has its appState, you can pass it around to the other views. There are two ways to do this, and you’ll learn both. The first one uses @EnvironmentObject.

In SnowmanApp.swift, add this modifier to ContentView:

.environmentObject(appState)

Now ContentView, and all of its subviews, can access appState. To start using it, open GameView.swift.

Replace the @State var game line with:

// 1
@EnvironmentObject var appState: AppState

// 2
var game: Game {
  appState.games[appState.gameIndex]
}

This has changed a few things:

  1. GameView accesses the appState object. You only explicitly passed it to ContentView, but an @EnvironmentObject is available to any subview. An @EnvironmentObject receives notifications of any changes to its object.
  2. Getting the current game is a bit wordy, so this computed property makes it easier. And as a bonus, it uses the same property name you had before so almost everything works.

Scrolling down the page, there’s an error with the binding argument for GuessesView. You can’t use a computed property as a binding, so replace that line with:

GuessesView(game: $appState.games[appState.gameIndex])

This uses the long form access, the same as the computed property, but this way, it works as a binding.

Now the New Game button gives an error. You haven’t written a new game method for AppState yet, so comment out this line.

And finally, for the preview to work, it requires an environmentObject modifier, so add this to GameView() in GameView_Previews:

.environmentObject(AppState())

This creates a new AppState instance and assigns it to the preview as its @EnvironmentObject.

Build and run the game to make sure it works:

Running the game with the EnvironmentObject.
Running the game with the EnvironmentObject.

The New Game button doesn’t work yet, but you can play one game. Earlier, you triggered a new game by opening a new window. Try that now by pressing Command-N or choosing File ▸ New Window. This time, you get the same game back again. This is because you created the AppState object at the app level, so its data applies to all windows.

Close the second window before quitting the app.

If you’d created the @StateObject in ContentView each window would have its own data. For an app that needs to show different information in each window, that would be a good plan, but for this app, a single window is sufficient. You’ll see later how to stop it creating multiple windows.

Starting a New Game

Next, you need to give AppState a way to create a new game.

Open AppState.swift and add this method:

// 1
func startNewGame() {
  // 2
  let newGame = Game(id: games.count + 1)
  // 3
  games.append(newGame)

  // 4
  selectedID = newGame.id
  gameIndex = games.count - 1
}

This code does the following:

  1. Adds a method called startNewGame() to AppState.
  2. Creates a new game with an id one higher than the number of games. For the second game, there’s only one previous entry in games, so the new id is 2.
  3. Appends the new game to the games array.
  4. Sets the selectedID and the gameIndex. GameView uses gameIndex to access the active game.

To use this method, open GameView.swift and replace the Button action with:

appState.startNewGame()

This calls the method to create a new game. Because you set the properties as @Published, appState announces the changes to the subviews and the new game data appears.

Run the app, finish one game and then click New Game to test:

New game
New game

So you’ve refactored the data and the game works as it did before, but now you’re in a position to show some data in the sidebar.

Populating the Sidebar

Finally, you’re ready to start work on the sidebar, so open SidebarView.swift. As with GameView, you need to give the preview access to an @EnvironmentObject so it can use its data.

Inside SidebarView_Previews, add this modifier to SidebarView:

.environmentObject(AppState())

Next, replace the entire contents of SidebarView with:

// 1
@EnvironmentObject var appState: AppState

// 2
var body: some View {
  // 3
  List(appState.games) { game in
    // 4
    VStack(alignment: .leading) {
      // 4
      Text("Game \(game.id)")
        .font(.title3)
      Text(game.word)
    }
    // 5
    .padding(.vertical)
  }
}

Stepping through this:

  1. As with GameView, SideBarView gets access to the @EnvironmentObject.
  2. The body defines the view.
  3. Before, you used ForEach to loop through subviews. This time, you’re using List which lets you select rows. The argument to List is the games array and each time through the loop, game holds the current element.
  4. Each row in the list displays several pieces of game data, wrapped in a VStack. By default, a VStack aligns the data centrally, but this alignment argument sets it to align to the leading side, which is the left for left-to-right languages.
  5. The VStack holds two text views: The first one shows the game and its number. The second one shows its word, which makes the game a bit too easy but is good for testing.
  6. You’ve used padding before, but padding has several optional arguments. This one tells it to pad the top and bottom, but not the sides.

Run the app now and play a few games. For the first time, you can see data in the sidebar:

Testing the sidebar.
Testing the sidebar.

You’re making progress, even though the game is no longer challenging. Now, you’ll customize the display of each row.

Getting Data for the Sidebar

Right now, the sidebar shows the game header and the word, but you only want the word to appear if the game is over.

Open Game.swift and add this computed property:

// 1
var sidebarWord: String {
  // 2
  if gameStatus == .inProgress {
    return "???"
  }
  // 3
  return word
}

What does this give you?

  1. Game now has a computed String property called sidebarWord.
  2. If the game is still in progress, return “???”.
  3. Because the property has already returned “???” if appropriate, there’s no need to add an else here. If the code reaches this point, the game must be over, so it can return word.

To use this in the sidebar, go back to SidebarView.swift and replace Text(game.word) with:

Text(game.sidebarWord)

Run the game again to find it a bit more of a challenge:

Hiding the current word in the sidebar.
Hiding the current word in the sidebar.

The next thing to add is an indication of whether the player won or lost the previous games. Since this information is logically part of GameStatus, you’ll add it to that enumeration.

Computing Properties

Open GameStatus.swift and start by changing the import at the top to:

import SwiftUI

Next, insert this:

// 1
var displayStatus: Text {
  // 2
  switch self {
  case .inProgress:
    // 3
    return Text("In progress…")
  case .lost:
    // 4
    let img = Image(systemName: "person.fill.turn.down")
    return Text("You lost \(img)")
  case .won:
    // 5
    let img = Image(systemName: "heart.circle")
    return Text("You won! \(img)")
  }
}

Here’s another computed property, but what does it do?

  1. displayStatus returns a Text view. That’s why you imported SwiftUI for this file.
  2. Use switch to step through all the possible options for GameStatus.
  3. If the status is inProgress, return a plain Text view with appropriate content.
  4. If the player has lost the game, return a Text view containing an Image view. The Image view contains a symbol.
  5. A winning game uses a different image and text.

These symbols come from SF Symbols, a library of scalable images provided by Apple for use in our apps. Download the SF Symbols app from Apple, or search the Xcode library by pressing Shift-Command-L and selecting the Symbols tab:

Searching the symbols library.
Searching the symbols library.

You can use any named symbol in an Image by supplying its name as the systemName argument.

Back in SidebarView.swift, add this below the other two Text views:

game.gameStatus.displayStatus

Remember, this is already a Text view, so you don’t need to wrap it in a view.

One final tweak would be to color-code the sidebar entries.

Adding Color

Again, GameStatus is the place to do this, so open GameStatus.swift and add this:

var statusTextColor: Color {
  switch self {
  case .inProgress:
    return .primary
  case .won:
    return .green
  case .lost:
    return .orange
  }
}

Another computed property:

  1. The property returns a SwiftUI Color.
  2. Like the previous computed property, step through the possibilities for GameStatus.
  3. If the game is still in progress, return the primary color, which is the default text color for the current display mode.
  4. If the player has won, use Color.green. This computed property has to return a Color, so there’s no need to include the Color prefix. The shorter version is sufficient.
  5. If the player has lost the game, use an orange color. The named SwiftUI colors vary slightly to suit dark and light modes.

To apply this new property, open SidebarView.swift.

Add this modifier after the padding modifier:

.foregroundColor(game.gameStatus.statusTextColor)

This applies the foreground color to every element inside the VStack, as you’ll see when you run and play the app:

Coloring the sidebar
Coloring the sidebar

While you’re setting colors, it would look good if the status text used these colors too, so open GameView.swift. Find the Text(game.statusText) line and give it the same foreground modifier:

.foregroundColor(game.gameStatus.statusTextColor)

Whenever you change colors, it’s important to confirm that they look good in dark and light modes.

Run the app, then go back to Xcode and click Environment Overrides in the button bar under your code. Turn on Appearance and you can swap your app between the two modes without changing the rest of your system:

Environment Overrides
Environment Overrides

The sidebar displays the games and gives useful information, but you can’t click a game to reload it.

Making the Sidebar Live

A List can have a selection parameter. This is an optional value that changes when the user selects or deselects a list item. You already created the optional selectedID property in AppState for this purpose.

To apply this to the sidebar list, open SidebarView.swift and replace the List line with:

List(appState.games, selection: $appState.selectedID) { game in

The new section here is the selection argument, which binds the list selection to appState.selectedID. This is a two-way binding, so if you set appState.selectedID, you select an element in the list and if you select an element, you set appState.selectedID. This property is nil if you have no game selected.

You need to add one more modifier to each row in the list. The selection uses the game id, so you’ll set the tag for each row to this id to connect the row to its game.

Add this after the foregroundColor modifier:

.tag(game.id)

Now the sidebar has an active list, so your app can respond to selections and display the chosen game. You’ve used onChange to track changes to properties in SwiftUI views, but AppState isn’t a view and can’t use that modifier. Instead, it uses didSet.

Open AppState.swift and start by adding this method:

func selectGame(id: Int?) {
  // 1
  guard let id else {
    return
  }

  // 2
  let gameLocation = games.firstIndex { game in
    game.id == id
  }
  if let gameLocation {
    gameIndex = gameLocation
  }
}

What does this method do?

  1. Check to see if the supplied optional id is an Int and return if it’s nil.
  2. Use an array method to locate the first game in the games array with that id.
  3. If this located a game, use that location to set gameIndex. AppState publishes the changes to update any views that subscribed to it.

Next, you’ll call this method whenever selectedID changes. Still in AppState.swift, replace the property declaration for selectedID with:

// 1
@Published var selectedID: Int? {
  // 2
  didSet {
    // 3
    selectGame(id: selectedID)
  }
}

What are these changes?

  1. Declare the property as before. It’s an optional integer and the class publishes its changes.
  2. Add a property observer to detect when this changes. In SwiftUI views, you’ve used onChange for this, but that’s specifically for view properties. Any Swift property can have a didSet property observer.
  3. Call the new method with the changed value.

Build and run the app again now. Play a few games so they appear in the sidebar, then click the sidebar entries:

Selecting games from the sidebar.
Selecting games from the sidebar.

Now the interface for your game is complete, but it’s time for some more advanced Swift.

Using Array Methods

You’ve seen several uses of array methods like filter and firstIndex. They loop through arrays, but the way they operate can be confusing.

There’s no need to add any code from this section into your project, but it’s all in a playground in the assets folder for this chapter. Run the code and check the results.

Imagine you start with a list of names and you want a new array of the ones with five letters. This code would do the job:

// 1
let names = [ "Alice", "Ben", "Celine", "Danny", "Edith" ]

// 2
var fiveLetterNames: [String] = []
// 3
for name in names {
  // 4
  if name.count == 5 {
    fiveLetterNames.append(name)
  }
}

What does it do?

  1. Start with the list of names.
  2. Create a variable to hold the matches.
  3. Then, loop through the array, assigning each element to the variable name as it loops.
  4. Test the element, appending it to the variable array if it matches.

What’s wrong with this? It works! Yes, but it gives you a variable instead of a constant, which isn’t as safe or memory-efficient.

What about this?

// 1
let filteredNames = names.filter { name in
  // 2
  name.count == 5
}

You’re getting the same result with half the code, but how?

  1. Create a constant to hold the matching names. filter loops like for and again, uses name for each element in turn.
  2. There’s an implicit return here that sends back a Boolean. If true, name becomes part of filteredNames and if false, it’s ignored.

This is neater, but how does it work?

These array methods like filter and firstIndex take a function as their argument. That’s one of the neat things about Swift — functions can be used as arguments. For filter, the argument function takes an element of the array and returns a Boolean.

You can use filter like this:

// 1
func countEqualsFive(string: String) -> Bool {
  string.count == 5
}
// 2
let filteredNames2 = names.filter(countEqualsFive)

This separates out the validation function:

  1. Write a function that takes a String and returns a Bool, which is true if the string has a count of 5.
  2. Use filter on the array, but passing this function as the argument.

This does exactly the same thing but with two separate sections to make it clearer what filter does. Writing this yet again:

let filteredNames3 = names.filter({ name in
  name.count == 5
})

This time, the contents of the function are directly inside the argument parentheses. It uses in to pass the internal function its argument. A function embedded inside the arguments like this is a closure.

Since this is a common use case, the Swift team devised a cleaner syntax for trailing closures. If the closure is the last argument, you can eliminate the parentheses and only use the curly braces. And this gets back to the initial filter example, but hopefully you can now see what each of the parts does.

Before leaving this topic, there’s one tweak that you’ll see used frequently. You don’t have to give the closure argument a name, you can use a shorthand version:

let filteredNames4 = names.filter {
  $0.count == 5
}

This version removes name in and uses $0 to read the first argument. If the function had a second argument, you’d access it using $1 but with more than one, it’s better to use names for improved readability.

There’s a series of methods that operate like this, but if you understand filter, you’ll understand them all.

Now, return to your project where there’s a bug to fix. The text entry field isn’t always active when you need it.

Fixing the Focus

There are two problems to solve. The first is that the entry field isn’t selected when the app starts. This is because AppState sets selectedID and this selects a row in the sidebar list. That gives the row focus and not the text field. You’ll fix this with another modifier for the field.

Open GuessesView.swift and add this after the last onChange modifier:

// 1
.onAppear {
  // 2
  entryFieldHasFocus = true
}

Another new modifier:

  1. The onAppear modifier is similar to onChange. It contains code that runs when triggered. In this case, it’s triggered when the view first appears.
  2. It sets the @FocusState property, which forces the focus into the text field.

Run the app now and confirm that the text field has focus on startup:

Setting focus on start.
Setting focus on start.

That’s one problem solved. To discover the next one, play one game and start a new one. Next, use the sidebar to get back to your first game and click the New Game button. Your sidebar shows two games in progress.

Select one of these and the text field has focus, but if you select one after another, the focus gets lost. This is because you set focus based on gameStatus. When you navigate from a completed game to a game in progress, the status changes. When you select a completed game, it doesn’t matter because that disables the field. The problem only occurs when you swap from one active game to another.

When you added the onChange modifier, you observed gameStatus. But now, every game has a unique id which would be a better property to watch.

Still in GuessesView.swift, look for:

.onChange(of: game.gameStatus) { _ in

Change that line to:

.onChange(of: game.id) { _ in

This solves the focus problem even when swapping between incomplete games.

Observing Objects

Earlier in this chapter, you created a @StateObject and used the environment modifier to pass it around. But there’s another way to use this @StateObject.

These changes will cause a lot of errors, but keep going and the red will disappear. :]

Start in SnowmanApp.swift and replace the contents of WindowGroup with:

ContentView(appState: appState)

You’ve removed the environment modifier and added an argument to ContentView. Ignoring the error, move to ContentView.swift.

Add this new property at the top, before body:

@ObservedObject var appState: AppState

Another property wrapper! This one accepts the @StateObject created by SnowmanApp. The @ObservedObject property wrapper subscribes this property to any changes published by the @StateObject.

This makes the error disappear from SnowManApp.swift (eventually) but adds a new one to ContentView_Previews which wants a preview value for appState.

Replace ContentView() in the PreviewProvider with:

ContentView(appState: AppState())

This gives it it’s own instance of AppState for previewing and gets rid of the error, but don’t try resuming the preview yet. There’s more work to do.

Next is SidebarView.swift which has an appState property defined as an @EnvironmentObject. Change this declaration to:

@ObservedObject var appState: AppState

And to fix the preview, replace the contents of previews with:

SidebarView(appState: AppState())

The last place you used @EnvironmentObject was GameView.swift.

Replace the object definition with:

@ObservedObject var appState: AppState

And the preview with:

GameView(appState: AppState())

Press Command-B to build and you’ll see two remaining errors in the Issue navigator:

Observed objects build errors
Observed objects build errors

Both of these take you to ContentView.swift where you’ll add the appState arguments. Edit NavigationSplitView so it looks like:

NavigationSplitView {
  SidebarView(appState: appState)
} detail: {
  GameView(appState: appState)
}

Press Command-B again to build without errors. So what have these changes done?

In both cases, you started by creating a @StateObject. This is always the same: the view that creates the ObservableObject marks it as a @StateObject.

The change is in the way you passed the @StateObject down through the views. Originally, you assigned appState to ContentView’s environment. This effectively made it accessible to the entire app. ContentView never needed this data directly, but its subviews did. Once it was in the environment of the view hierarchy, they could access it by declaring it as an @EnvironmentObject.

Now, you’re using arguments to send the data object to the views as @ObservedObjects. One big difference is that there has to be a continuous data trail. You can’t skip views that don’t need the data. In this case, ContentView doesn’t need it (yet), but you have to pass it to ContentView so that ContentView can pass it to SidebarView and GameView.

Both @EnvironmentObject and @ObservedObject are subscribers that receive notifications of any changes to the published properties.

Which option is better? This is a big question. Programmers learn that using global variables is a bad idea as it’s too easy for unexpected side-effects to occur. @EnvironmentObject feels like a global, so that worries some people. Apple says there are no real performance differences between the two, so it comes down to a matter of personal style.

If you have lots of subviews, some of which need data and some don’t, then @EnvironmentObject is easier to maintain. If every view needs the data, then @ObservedObject keeps the data flow more obvious.

You may be wondering about LettersView and GuessesView which didn’t change during this re-factoring. They never had full access to AppState. Their parent views passed them the limited data they needed. This is always a good idea. Don’t give a subview more data than it needs and it’s easier to maintain and more reusable.

You’ve finished the data flow for your app. This is a big topic, so you should be proud of yourself.

Key Points

  • ObservableObjects are classes that can publish changes to their properties.
  • The view that owns the ObservableObject declares it as a @StateObject.
  • You can pass this object as an @EnvironmentObject or an @ObservedObject.
  • Lists can display a selectable array of SwiftUI views.
  • Understanding data flow is crucial to working in SwiftUI.

Where to Go From Here

You’ve learned a common SwiftUI pattern with a structure for individual data elements and a class to collect them together and pass them round the app.

In the next chapter, you’ll learn about more about windows, which are an important part of a Mac app. You’ll add a settings window for some user customizations and a secondary window to show a new view.

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.