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 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.
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
@Observable
class AppState {
}
What does this do?
- 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.
- This model is a class called
AppStateand it conforms toObservable.
Observable
So what is the Observable protocol? A class that conforms to Observable is a class that publishes changes to its properties. This is commonly used in SwiftUI to indicate that data has changed and to trigger an update of the views.
Insert this code into the class:
// 1
var games: [Game]
// 2
var gameIndex: Int
// 3
var selectedID: Int?
// 4
init() {
// 5
let newGame = Game()
games = [newGame]
// 6
gameIndex = 0
selectedID = 1
}
This sets up the new class:
- Create a property called
gamesthat holds an array ofGameobjects. - Another property holds the index of the current game in the
gamesarray. - The final property is the ID of the game selected in the sidebar. Since it’s possible to have no game selected, this is an optional.
- Use
init()to assign the starting values for each of the properties. - Create a new
Gameand set it as the contents of thegamesarray. - 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?
-
init()now has a single integer argument, calledid. - Set the value of the structure’s
idto the supplied argument. - 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 show any errors:
Click each error in turn and replace Game() with:
Game(id: 1)
You may not see multiple 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 Property
You defined an Observable 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 is 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:
@State var appState = AppState()
This creates an instance of AppState and marks it with the @State property wrapper. When you’re initializing an Observable class, you use @State 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 @Environment.
In SnowmanApp.swift, add this modifier to ContentView:
.environment(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
@Environment(AppState.self) var appState
// 2
var game: Game {
appState.games[appState.gameIndex]
}
This has changed a few things:
-
GameViewaccesses theappStateobject from the environment by specifying its type withAppState.self. You only explicitly passed it toContentView, but theenvironmentis available to any subview. An@Environmentproperty receives notifications of any changes to its object. - 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. Here’s where things get tricky, because you can’t use an @Environment property as a binding. The solution is to add this line immediately after the body line:
@Bindable var appStateBindable = appState
This creates a Bindable copy of appState that you can now pass to GuessesView by replacing the error line with this:
GuessesView(game: $appStateBindable.games[appState.gameIndex])
This uses the long form access, the same as the computed property, but this is necessary for 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 environment modifier, so add this to GameView() in #Preview:
.environment(AppState())
This creates a new AppState instance and inserts it into the environment for the preview.
Build and run the game to make sure it works:
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 @State property 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:
- Adds a method called
startNewGame()toAppState. - Creates a new game with an
idone higher than the number of games. For the second game, there’s only one previous entry ingames, so the newidis 2. - Appends the new
gameto thegamesarray. - Sets the
selectedIDand thegameIndex.GameViewusesgameIndexto 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 changes to the AppState properties are 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:
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 @Environment object so it can use its data.
Inside #Preview, add this modifier to SidebarView:
.environment(AppState())
Next, replace the entire contents of SidebarView with:
// 1
@Environment(AppState.self) var appState
// 2
var body: some View {
// 3
List(appState.games) { game in
// 4
VStack(alignment: .leading) {
// 5
Text("Game \(game.id)")
.font(.title3)
Text(game.word)
}
// 6
.padding(.vertical)
}
}
Stepping through this:
- As with
GameView,SideBarViewgets access toappStateusing@Environment. - The
bodydefines the view. - Before, you used
ForEachto loop through subviews. This time, you’re usingList, which lets you select rows. The argument toListis thegamesarray and, each time through the loop,gameholds the current element. - Each row in the list displays several pieces of game data, wrapped in a
VStack. By default, aVStackaligns the data centrally, but thisalignmentargument sets it to align to the leading side, which is the left for left-to-right languages. - The
VStackholds 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. - You’ve used
paddingbefore, 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:
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?
-
Gamenow has a computedStringproperty calledsidebarWord. - If the game is still in progress, return “???”.
- Because the property has already returned “???” if appropriate, there’s no need to add an
elsehere. If the code reaches this point, the game must be over, so it can returnword.
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:
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 adding this import at the top:
import SwiftUI
Next, insert this in the enumeration:
// 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?
-
displayStatusreturns aTextview. That’s why you imported SwiftUI for this file. - Use
switchto step through all the possible options forGameStatus. - If the status is
inProgress, return a plainTextview with appropriate content. - If the player has lost the game, return a
Textview containing anImageview. TheImageview contains a symbol. - 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:
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:
-
The property returns a SwiftUI
Color. -
Like the previous computed property, step through the possibilities for
GameStatus. -
If the game is still in progress, return the
primarycolor, which is the default text color for the current display mode. -
If the player has won, use
Color.green. This computed property has to return aColor, so there’s no need to include theColorprefix. The shorter version is sufficient. -
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:
.foregroundStyle(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:
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:
.foregroundStyle(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:
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:
// 1
@Bindable var appStateBindable = appState
// 2
List(appState.games, selection: $appStateBindable.selectedID) { game in
Here’s what these lines do:
- As you did in
GameView, create aBindablecopy ofappState. - The new part of the
Listinitializer is theselectionargument, which binds the list selection toselectedID. This is a two-way binding, so if you setselectedID, you select an element in the list and, if you select an element, you setselectedID. This property isnilif you have no game selected.
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?
- Check to see if the supplied optional
idis anIntand return if it’snil. - Use an array method to locate the first game in the
gamesarray with thatid. - If this located a game, use that location to set
gameIndex.AppStatepublishes 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
var selectedID: Int? {
// 2
didSet {
// 3
selectGame(id: selectedID)
}
}
What are these changes?
- Declare the property as before. It’s an optional integer, and the class publishes its changes.
- Add a property observer to detect when this changes. In SwiftUI views, you’ve used
onChangefor this, but that’s specifically for view properties. Any Swift property can have adidSetproperty observer. - 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:
The only problem here is that the selection background color makes the text hard to read for completed games. These colors used to work but Liquid Glass is messing with us here. To fix this, you’ll mix some white in with the game colors.
Open GameStatus.swift and replace the statusTextColor property with:
var statusTextColor: Color {
switch self {
case .inProgress:
return .primary
case .won:
return .green.mix(with: .white, by: 0.1) // CHANGED
case .lost:
return .orange.mix(with: .white, by: 0.1) // CHANGED
}
}
For both the changed colors, you’re using the Color method mix(with:by:) method to blend a small amount of white into the color. This lets you adjust any of the named colors slightly to suit your app, without having to create colors using RGB values. Here, you’ve lightened it enough to make it readable against the selection background.
Run the app again to check that these colors are readable. Don’t forget to test in both dark and light modes:
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 does 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?
- Start with the list of names.
- Create a variable to hold the matches.
- Then, loop through the array, assigning each element to the variable
nameas it loops. - 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?
- Create a constant to hold the matching names.
filterloops likeforand again, usesnamefor each element in turn. - There’s an implicit
returnhere that sends back a Boolean. Iftrue,namebecomes part offilteredNamesand iffalse, 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:
- Write a function that takes a
Stringand returns aBool, which istrueif the string has a count of 5. - Use
filteron 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
To discover the problem, complete 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.
Open GuessesView.swift and look for:
.onChange(of: game.gameStatus) {
Change that line to:
.onChange(of: game.id) {
This solves the focus problem even when swapping between incomplete games.
Observing Properties
Earlier in this chapter, you created a @State object and used the environment modifier to pass it around. But there’s another way to use this @State object to send data to the subviews.
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:
let appState: AppState
This tells ContentView that its parent will provide appState. Even though appState changes all the time, this can be a let because the app re-creates ContentView for every change.
This makes the error disappear from SnowManApp.swift (eventually) but adds a new one to #Preview which wants a preview value for appState.
Replace ContentView() in #Preview with:
ContentView(appState: AppState())
This gives it its 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 declared using @Environment. Change this declaration to:
@Bindable var appState: AppState
In the body, delete the @Bindable line and change the List line to:
List(appState.games, selection: $appState.selectedID) { game in
Now that you’re not using @Environment, you can mark the incoming appState as Bindable directly and use it for the List selection.
And to fix the preview, replace the contents of #Preview with:
SidebarView(appState: AppState())
The last place you used @Environment was GameView.swift, where you’ll do what you did in SidebarView.
First, replace the object definition with:
@Bindable var appState: AppState
Then delete the @Bindable from body and change the GuessesView line to:
GuessesView(game: $appState.games[appState.gameIndex])
Finally, fix the preview with:
GameView(appState: AppState())
Press Command-B to build and you’ll see two remaining errors in the Issue navigator:
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 an @State property. This is always the same: The view that creates the Observable marks it as a @State.
The change is in the way you passed the @State property 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 using @Environment.
Now, you’re using arguments to send the data object to the views directly. 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.
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. @Environment 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 @Environment is easier to maintain. If every view needs the data, then passing the properties directly keeps the data flow more obvious. And as you’ve seen, working with binding properties is easier without @Environment.
You may be wondering about LettersView and GuessesView, which didn’t change during this refactoring. 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
-
@Observableis a protocol for classes that publishes changes to their properties. - The view that owns the
Observabledeclares it using@State. - Subviews can access this object using
@Environment,let,varor@Bindable. - 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.