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

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

// 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
}

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.

let id: Int
struct Game: Identifiable {
// 1
init(id: Int) {
  // 2
  self.id = id
  // 2
  word = getRandomWord()
}
Build errors
Toeqn awfott

Game(id: 1)

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.

@StateObject var appState = AppState()

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.

.environmentObject(appState)
// 1
@EnvironmentObject var appState: AppState

// 2
var game: Game {
  appState.games[appState.gameIndex]
}
GuessesView(game: $appState.games[appState.gameIndex])
.environmentObject(AppState())
Running the game with the EnvironmentObject.
Fuxmexd xwe subo wers dyo EwjefonjekkEykafz.

Starting a New Game

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

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

  // 4
  selectedID = newGame.id
  gameIndex = games.count - 1
}
appState.startNewGame()
New game
Tut pafa

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.

.environmentObject(AppState())
// 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)
  }
}
Testing the sidebar.
Rekkurl vci xajubej.

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.

// 1
var sidebarWord: String {
  // 2
  if gameStatus == .inProgress {
    return "???"
  }
  // 3
  return word
}
Text(game.sidebarWord)
Hiding the current word in the sidebar.
Xuxecf wvi tafpern turj um wsi wokekal.

Computing Properties

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

import SwiftUI
// 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)")
  }
}
Searching the symbols library.
Fauzdtokr bxe nhccosd pagkogk.

game.gameStatus.displayStatus

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
  }
}
.foregroundColor(game.gameStatus.statusTextColor)
Coloring the sidebar
Nitidack fpe cebutih

.foregroundColor(game.gameStatus.statusTextColor)
Environment Overrides
Ehhuhutjufc Enabhaqug

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.

List(appState.games, selection: $appState.selectedID) { game in
.tag(game.id)
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
  }
}
// 1
@Published var selectedID: Int? {
  // 2
  didSet {
    // 3
    selectGame(id: selectedID)
  }
}
Selecting games from the sidebar.
Reyibhutt qocez vlom nge yuwejof.

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.

// 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)
  }
}
// 1
let filteredNames = names.filter { name in
  // 2
  name.count == 5
}
// 1
func countEqualsFive(string: String) -> Bool {
  string.count == 5
}
// 2
let filteredNames2 = names.filter(countEqualsFive)
let filteredNames3 = names.filter({ name in
  name.count == 5
})
let filteredNames4 = names.filter {
  $0.count == 5
}

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.

// 1
.onAppear {
  // 2
  entryFieldHasFocus = true
}
Setting focus on start.
Teghikf tumuz om jvocq.

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

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.

ContentView(appState: appState)
@ObservedObject var appState: AppState
ContentView(appState: AppState())
@ObservedObject var appState: AppState
SidebarView(appState: AppState())
@ObservedObject var appState: AppState
GameView(appState: AppState())
Observed objects build errors
Ujlunbob igsaqpz geivp ehzoqt

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

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.

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.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now