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

6. Getting Data Into Your App
Written by Sarah Reichelt

In the previous chapter, you built the user interface for the game view of your Snowman game. The displayed data was hard-coded into the app and users couldn’t do anything to change it.

Now, you’ll learn how to create data types for use by SwiftUI. You’ll see how SwiftUI passes data around the app, and how it keeps the data and the user interface in sync.

You’ll also encounter property wrappers, which are a way of giving properties super-powers. SwiftUI uses these extensively.

Designing the Data Model

Start by opening your project from the last chapter, or use the starter project from the downloaded materials for this chapter.

Run the app to remind yourself of the layout and what data it needs:

The starter project
The starter project

Looking at the game view, here’s what the app needs to know:

  • How many incorrect guesses the player has made.
  • What text to show in the status area.
  • The secret word.
  • The player’s guesses.
  • Whether the player has won, lost or the game is still in progress.

You’ll add all this to a new structure called Game.

In the Project navigator, select SnowmanApp.swift and choose File ▸ New ▸ File… or press Command-N to create a new file. This time, choose macOS ▸ Source ▸ Swift File. Click Next and call the file Game.swift. Then, click Create to save it.

Start the new structure by adding this under the import line:

// 1
struct Game {
  // 2
  var incorrectGuessCount = 0
  var statusText = "Enter a letter to start the game."
  var word = "SNOWMAN"
  var guesses: [String] = []
}

This is similar to code you wrote in Section 1:

  1. You define a structure with the keyword struct followed by its name.
  2. Inside the structure, you declare properties to cover most of the required data. These are all initialized to default starting values.

This doesn’t cover the state of the game. Since there are three possible states, this is a great place to use an enumeration.

Adding an Enumeration

Press Command-N to create another Swift file and call it GameStatus.swift.

Append this code to the new file:

// 1
enum GameStatus {
  // 2
  case won
  case lost
  case inProgress
}

To create an enumeration, you:

  1. Start with the enum keyword and then give it a name.
  2. Add each of the possibilities as a case.

You may think that having a separate file for these few lines is a bit wasteful, but you’ll add to this enumeration later. Also, having each structure, class or enumeration in its own file makes it easier to find them in your project.

You’ve already arranged your views into a group in the Project navigator. Now you’ll do the same with your models. Model is the term for a class or structure that defines a data type.

Select Game.swift and GameStatus.swift in the navigator. Right-click and choose New Group from Selection. Set the name of the new group to Models.

Now you can go back to Game.swift and insert the last property:

var gameStatus = GameStatus.inProgress

This adds a property with a type of GameStatus and sets it to inProgress by default.

So far, these properties are static — you don’t have any code in place that could change them. But you can still use them in the interface.

Open GameView.swift and at the top of GameView, right before body, add this:

@State var game = Game()

This looks different! You added a property initialized with an instance of Game, but what does @State mean?

Property Wrappers

When you see a property definition preceded by a word starting with @, you know that this is a property wrapper. Property wrappers are ways of enhancing properties to give them extra functionality.

It’s possible to write your own property wrappers, but in this book you’ll use built-in property wrappers designed to work with SwiftUI.

Remember back when you learned about structures, and you found that their methods can’t edit their properties by default? Well here, you have a view structure, but you need to be able to edit its data.

The @State property wrapper pulls the property out of the usual structure limitations, stores it in a protected part of memory, so that it persists even when SwiftUI recreates the containing view, and makes it mutable. Pretty cool for a single extra word!

Using the Data Model

Now you have your game property, you can start replacing the static elements in your UI with real data.

First, look at the Image. It uses a String containing the number of incorrect guesses to specify the image name.

Replace the Image line with this:

Image("\(game.incorrectGuessCount)")

This uses string interpolation to convert the integer property into a String. Press Option-Command-P to restart the preview and, since this uses the value you set in game, your snowman gets his hat back.

Click the pin at the top left of the preview. This sticks the preview in place when you open another file in the editor. Open Game.swift and test different values of incorrectGuessCount. Press Command-S to save each edit and force the pinned preview to redraw.

This is the beauty of SwiftUI — once you connect data to a view, SwiftUI updates the view whenever you change the data.

When you’ve finished testing, set incorrectGuessCount back to 0 and return to GameView.swift.

The next property is the one that shows the status text. Scroll down to find Text("Enter a letter to guess the word.") and replace it with:

Text(game.statusText)

You don’t need to use string interpolation here because statusText is a String already.

The preview updates to show the text from game:

Updated preview
Updated preview

The next property is word, and here’s where things get a bit more complicated.

Identifying the Letters

You use a ForEach loop to display the letters, and this requires that each element in the loop have an identifier. As a temporary measure, you used the letter itself as its identifier.

When you ran the app, you may have noticed warnings in the console about using the same ID multiple times within the collection. This is because the sample word contains N twice, so repeats the same ID.

Since it’s very common to find repeated letters in a word, you need to solve this. The solution is to make a new Letter structure that holds the letter and a unique identifier for each letter. The Identifiable protocol requires conforming types to have an id property. If you use an Identifiable type in a loop, SwiftUI takes the id property as its identifier automatically.

And as a bonus, you can use this structure to hold the color for the box around each letter.

Select GameStatus.swift to make sure you’re in the Models group in the Project navigator. Make a new Swift file called Letter.swift.

Replace the contents of your new file with:

// 1
import SwiftUI

// 2
struct Letter: Identifiable {
  // 3
  let id: Int
  // 4
  let char: String
  var color = Color.blue
}

What does this give you?

  1. One of the properties is of type Color, so import SwiftUI to be able to use this type.
  2. Set up the Letter structure and mark it as conforming to the Identifiable protocol.
  3. Give the structure an id property. This is usually a number, a string, or a UUID. In this case, an Int is good.
  4. Add the other properties to hold the actual character and its color. The color property is a variable with a default value.

Now, you need to generate the list of letters in your word.

Open Game.swift and add this computed property to the structure:

// 1
var letters: [Letter] {
  // 2
  var lettersArray: [Letter] = []

  // 3
  for (index, char) in word.enumerated() {
    // 4
    let charString = String(char)
    // 5
    if guesses.contains(charString) {
      let letter = Letter(id: index, char: charString)
      lettersArray.append(letter)
    } else if gameStatus == .lost {
      // 6
      let letter = Letter(id: index, char: charString, color: .red)
      lettersArray.append(letter)
    } else {
      // 7
      let letter = Letter(id: index, char: "")
      lettersArray.append(letter)
    }
  }
  // 8
  return lettersArray
}

There’s a lot to unpack here:

  1. This defines a computed property called letters, and it’s an array of Letter instances.
  2. Start by creating an empty array.
  3. Loop through the characters in word, but you want a counter to use as the identifier. enumerated() gives two values each time it loops: the position of the element in the array and the value of the element.
  4. You’re looping over a single String, not an array of Strings. Each time through, char holds the next letter, but its type is String.Element. Since Letter uses String, you must perform the type conversion.
  5. If the user has guessed this letter, create an instance of Letter with the index and character and add to the the array. Use the default color.
  6. If the player lost the game, set the color to show the unguessed letter in a red box. You specify color in this initializer because it’s not the default blue.
  7. If neither of these are true, make an empty blue box.
  8. Return the new array.

Displaying the Letters

In the previous chapter, you separated LettersView into its own file. Open LettersView.swift and unpin the GameView preview, so that you’re seeing the preview for this file only.

Replace the word definition at the top with:

let letters: [Letter]

This tells the view that its property is an array of Letters, but it causes some errors. To fix the first one, replace the ForEach line with:

ForEach(letters) { letter in

Notice you no longer need the id argument because letters is Identifiable.

Following the trail of errors, replace the Text line with:

Text(letter.char)

Before, letter was a String. Now, it’s type Letter. As a result, you can’t use it directly in a Text view, but it has a char property that you can.

The only error left is in PreviewProvider. There’s no default value for letters, so the preview doesn’t know what to display.

Replace LettersView() with:

LettersView(letters: Game().letters)

You’re creating a new instance of Game and getting its default letters property for the preview.

All the errors have disappeared, so why is the preview still not working? There’s now an error in GameView and any error stops all previews.

Open GameView.swift and scroll to the line with the error. Change it to:

LettersView(letters: game.letters)

This passes letters from game, through to LettersView. There’s no need to send the whole game, only the part that LettersView needs.

Jump back to LettersView.swift and now, the preview is working, but since it thinks this is the start of a game, it only shows a row of empty blue boxes.

Pin the LettersView preview and open Game.swift. Edit guesses to hold some of the letters in SNOWMAN and save the file:

Previewing some letters.
Previewing some letters.

Note: To see the preview below the code like this, select Editor ▸ Layout ▸ Canvas on Bottom.

Next, change gameStatus to GameStatus.lost and save again. The preview updates to show all the letters, but none of them are in red boxes.

Back in LettersView.swift, find where you set the foregroundColor for the RoundedRectangle. Replace that line with:

.foregroundColor(letter.color)

Now, it uses the color set for each Letter, and the preview looks correct:

Previewing a lost game.
Previewing a lost game.

Pat yourself on the back. That was a complicated bit of code, layout and data flow.

Experiment with other settings until you’re sure you know what’s happening, and then reset the Game properties to:

var guesses: [String] = []
var gameStatus = GameStatus.inProgress

Styling the Button

Working down through GameView, the next item is the New Game button. It should only appear when the game is over.

An if can show the button conditionally. Don’t add this code, but take a look:

// 1
if game.gameStatus != .inProgress {
  // 2
  Button("New Game") {
    print("Starting new game.")
  }
  .keyboardShortcut(.defaultAction)
}

You haven’t used if in layout code before:

  1. First, you check if gameStatus is NOT inProgress.
  2. If this is true, draw the Button.

This works but it’s ugly. SwiftUI moves subviews around to fit, so hiding the Button like this means that everything else jumps up or down. A better technique is to display the button but set its opacity to zero, so that it’s in position, taking up its usual space, but invisible.

Open GameView.swift, find the Button code and add this line after the keyboardShortcut:

.opacity(game.gameStatus == .inProgress ? 0 : 1)

This uses an operator called the ternary operator. Most operators are binary: They work with two parts like 4 + 19 or 63 / 7. There are a few unary operators like ! or -. Note that - can be binary for subtraction or unary to negate a number.

The ternary operator has three parts: a conditional, an if-true value and and an if-false value. Here’s an example:

mood = isRaining ? "sad" : "happy"

This sets a variable called mood to “sad” if isRaining is true and “happy” if isRaining is false.

The syntax is: conditional ? if true : if false

One way to remember is by calling it the WTF operator: What ? True : False.

Effectively, this is a one line version of if...else and it’s commonly used in SwiftUI when making decisions inside modifiers.

Getting back to the button, if the game is in progress, the button’s opacity is set to 0, so it disappears. If the game is over, the opacity is 1 and the button becomes visible.

There’s one more wrinkle to this. The button is there on the screen even though you can’t see it. This means that it can still detect the Return key. You have to disable it. Guess what? There’s another modifier for that.

Searching for Modifiers

When working in SwiftUI, you’ll often think that there must be a modifier for some situation, but you don’t know what it’s called. Use Xcode’s Library to help you find it.

Place the cursor in your code where the new modifier will go: Open a new line under the opacity line and make sure the preview is open — press Option-Command-Return if it isn’t.

Next, click the + button at the right of the toolbar or press Shift-Command-L to open the Library:

The tabs allow you to search for views, modifiers, code snippets, images, colors or symbols. Click the one with the sliders icon to choose modifiers and then start typing disable.

Using the Xcode library.
Using the Xcode library.

The one you want is Other ▸ Disabled: Read its help, and then drag or double-click it to insert it into your code:

.disabled(true)

It appears with the placeholder Boolean value true. Replace this conditional so the line reads:

.disabled(game.gameStatus == .inProgress)

If the game is in progress, disabled is true, and the keyboard shortcut won’t work.

Note: Sometimes, you’ll run a SwiftUI app and nothing appears. The app hangs before it draws any windows but there’s no error message or crash report. If this happens to you, make sure you haven’t forgotten the period before a modifier. Xcode doesn’t flag this as an error, but your app won’t work.

The Guesses View

The remaining section of the UI to connect is GuessesView. The first part you’ll add is the text entry field where players enters their guesses.

Open GuessesView.swift and add this property at the top of the structure:

@State var nextGuess = ""

Since GuessesView is a structure, use the @State property wrapper to make this property mutable and persistent.

Next, find Text("Q") and replace it with:

TextField("", text: $nextGuess)

This uses another SwiftUI view called TextField, which is an editable text entry field. The first argument is a placeholder that appears in gray inside the field when it’s empty. You don’t want one for this app, so set it to an empty string.

The second argument is the interesting one: You’ve used the new property but why does it have a dollar sign before it?

The dollar sign indicates that this is a Binding. So far, your data has all flowed one way. You change incorrectGuessCount and the snowman image changes, but the image never edits incorrectGuessCount. With TextField, the data has to go both ways: typing into the field changes nextGuess and changing nextGuess shows new text in the field.

In SwiftUI, this is a Binding, and you pass a property as a binding by adding a $ prefix.

Unpin the LettersView preview and refresh GuessesView. The field is too wide considering it’s only going to take one letter at a time.

Add these modifiers to TextField:

// 1
.frame(width: 50)
// 2
.textFieldStyle(.roundedBorder)
// 3
.disabled(game.gameStatus != .inProgress)

Here’s what these three lines do:

  1. The frame modifier sets the text field to a fixed width.
  2. The textFieldStyle modifier chooses a style for the field. A lot of SwiftUI views have a ...Style modifier with preset options and this can save you a lot of time and effort.
  3. You don’t want players typing in letters if the game is over, so disable it as needed.

The last modifier gives an error because GuessesView doesn’t have a game property yet, but you’re about to fix that.

Sending the Game Data

This view needs data from the game, but it also has to edit the game when the player makes a guess.

You learned about bindings for the text field, and now you’ll use bindings to send the game data to GuessesView.

In GuessesView.swift, add this property at the top:

@Binding var game: Game

This says that the view receives data in the form of a binding that it can use and edit.

As you’d expect, this gives a missing argument error in the preview provider. Setting a binding argument takes an extra step as you first create a Game and then convert it into a binding.

In the PreviewProvider, replace GuessesView() with:

GuessesView(game: .constant(Game()))

constant is a method on Binding that takes a value and converts it into a non-editable binding. This is perfect for a preview.

Now there’s an error in GameView, so open GameView.swift and scroll to the line with the error.

Replace that line with:

GuessesView(game: $game)

This passes game into GuessesView as a binding so that GuessesView can edit it as well as display it.

With that in place, jump back to GuessesView.swift to add the last piece of game data.

Replace the line displaying the guesses with:

Text(game.guesses.joined(separator: ", "))

This uses the same joined syntax but it’s getting the letters from game instead of from its static property. Now, you can delete the guesses property at the top.

Click the Play button or press Command-R to run the app:

Running with live data.
Running with live data.

Great work. You’ve switched your UI from using static data to using data from a Game model.

Playing the Game

It all looks good, so now it’s time to start playing the game. The first step is to process what the player types in the text field.

Open Game.swift and add this method:

// 1
mutating func processGuess(letter: String) {
  // 2
  guard
    // 3
    let newGuess = letter.first?.uppercased(),
    // 4
    newGuess >= "A" && newGuess <= "Z",
    // 5
    !guesses.contains(newGuess)
  else {
    return
  }

  // 6
  if !word.contains(newGuess) && incorrectGuessCount < 7 {
    incorrectGuessCount += 1
  }
  guesses.append(newGuess)

  // 7
  // checkForGameOver()
}

Taking this bit by bit:

  1. Create a method called processGuess(letter:) that takes a String argument. It can change the structure’s properties, so make it mutating.
  2. Use guard to check three different things:
  3. Does the string passed to the function have a first letter? If so, convert it to uppercase. This uses optional chaining. first looks for the string’s first character and returns an optional. You can’t uppercase nil, but the ? only chains on the uppercased method if first works. Now, either newGuess is a non-optional string or guard immediately returns.
  4. Is newGuess a letter between A and Z? If not, the guard fails and returns.
  5. Has the player already guessed this letter? You don’t want to process the same letter twice.
  6. If you made it to here, you have a valid letter. If it’s not in the word, increment incorrectGuessCount up to its maximum. Either way, append the new letter to guesses.
  7. In a minute, you’ll write a method to check if the game is over.

Now to use this method, open GuessesView.swift.

Add this modifier to TextField:

// 1
.onChange(of: nextGuess) { newValue in
  // 2
  game.processGuess(letter: newValue)
  // 3
  nextGuess = ""
}

This is a more complicated modifier:

  1. An onChange modifier tracks changes to a property, in this case nextGuess. The code in curly braces executes whenever nextGuess changes, and newValue contains the changed data.
  2. Call game’s processGuess(letter:) sending the newly typed letter.
  3. Set nextGuess back to an empty string to clear the text field.

The game won’t ever end, but you can run it now and type some guesses:

Entering guesses
Entering guesses

The correct letters appear in the blue boxes, the snowman starts disappearing and the guesses appear at the bottom. It’s working!

Ending the Game

The last step is to work out whether the player has won or lost the game.

Open Game.swift and add this method:

// 1
mutating func checkForGameOver() {
  // 2
  let unmatchedLetters = word.filter { letter in
    !guesses.contains(String(letter))
  }

  // 3
  if unmatchedLetters.isEmpty {
    gameStatus = .won
    statusText = "HURRAY!!!! YOU WON!"
  } else if incorrectGuessCount == 7 {
    // 4
    gameStatus = .lost
    statusText = "You lost. Better luck next time."
  } else {
    // 5
    statusText = "Enter another letter to guess the word."
  }
}

There are some new features here:

  1. Declare a mutating method that has no arguments.
  2. Use filter to create an array of the letters in word that the player has not guessed. filter loops through the letters, returning the ones that match the condition in the curly braces. Each time through the loop, letter holds the next character in the word, passing it in to the conditional.
  3. If there are no unmatched letters, the player has won. Set the status and change the status text.
  4. If the player has made too many incorrect guesses, the game is lost. Set the status and text to match.
  5. If the game is still in progress, change the starting status text.

Uncomment the call to checkForGameOver in processGuess(letter:) and run the game:

Losing a game.
Losing a game.

Knowing that the word is always SNOWMAN, test out a winning game. The New Game button appears at the correct time, but doesn’t do anything yet. To play again, close the window and press Command-N to open a new one. This time, try losing the game.

The game works well, but it’s a bit boring because there’s only one word. And the New Game button doesn’t work.

Starting a New Game

You’ll start with choosing a random word. In the assets folder downloaded for this chapter, there’s a file called words.txt that lists over 30,000 words. Drag this file from assets into the Models group in the Project navigator.

Make sure the options look like this, and then click Finish:

Adding the words file.
Adding the words file.

A macOS app is actually a folder, which you can prove by Right-clicking any app and selecting Show Package Contents. This file addition tells Xcode to put your words.txt file into your application’s folder — or bundle — whenever it builds.

To use your new file, open Game.swift and add this method:

// 1
func getRandomWord() -> String {
  // 2
  guard
    // 3
    let url = Bundle.main.url(forResource: "words", withExtension: "txt"),
    // 4
    let wordsList = try? String(contentsOf: url) 
  else {
    // 5
    return "SNOWMAN"
  }

  // 6
  let words = wordsList.components(separatedBy: .newlines)

  // 7
  let word = words.randomElement() ?? "SNOWMAN"

  // 8
  print(word)
  return word.uppercased()
}

Another interesting method:

  1. This method returns a String — the random word.

  2. Again, use guard to make multiple checks.

  3. First, see if words.txt exists inside the app bundle.

  4. Next, try to read the file into a String.

  5. If either of these fail, return the default word.

  6. Use a String method to separate the text into an array of lines. separatedBy can take a String or a CharacterSet. newlines is a predefined CharacterSet containing all the possible new line characters. This is useful because you don’t have to know what operating system created the file and what sort of line feeds it uses.

  7. See if you can get a random word out of the array and use the default word if not. This uses the nil coalescing operator, which allows you to assign a default value to something that would normally return an optional. If the part before the ?? is nil, it uses the part after the ??.

  8. Print the word to help during debugging. Then, convert it to uppercase and return it.

This gets a word from the file, but Game isn’t using it yet.

To fix this, add an init to Game:

init() {
  word = getRandomWord()
}

Every time a new game starts, this calls getRandomWord().

And now to make the New Game button work. Open GameView.swift and replace the print in the button’s action with:

game = Game()

And that’s it! Your game works, you can start new games, and you get a random word each time.

Run the app and test it.

Winning a game
Winning a game

You can see the random word in the Xcode console, so make sure you test winning and losing. Does the UI do everything you expect? Fantastic work!

Tweaking the App

Playing the game, there are a few improvements to make.

It’s inconvenient that when you start a new game, the text field isn’t active, so you have to click in it or press Tab. If your first thought was to use another modifier, you’re really starting to think like a SwiftUI programmer. :]

There’s a focused modifier that uses another property wrapper. Open GuessesView.swift and insert this property at the top:

@FocusState var entryFieldHasFocus: Bool

This is a property with a wrapper that you link to a field. It updates when the field gets or loses focus, and can be set to change the focus.

To apply it, add this modifier to TextField:

.focused($entryFieldHasFocus)

This binds the new property to the focus state of the field. Setting it to true places the cursor in the text field. You can use another onChange modifier to track gameStatus for when to set it.

Time for YAM (yet another modifier) for TextField:

.onChange(of: game.gameStatus) { _ in
  entryFieldHasFocus = true
}

This tracks changes to gameStatus. It doesn’t care what the status changes to, but it sets the focus on every change.

Run the app, play a game and then click New Game or press Return. The cursor is in the text field ready for your input:

Setting focus
Setting focus

The other main issue is that the words can have too few or too many letters. Too few and it’s almost impossible to guess. Too many and the window isn’t wide enough.

Start by making the window wider. In ContentView.swift, change the frame modifier to:

.frame(minWidth: 1100, minHeight: 500)

Now to set some letter count limits. Open Game.swift and find getRandomWord():

Locate this line:

let words = wordsList.components(separatedBy: .newlines)

And replace it with:

// 1
let words = wordsList
  .components(separatedBy: .newlines)
  // 2
  .filter { word in
    // 3
    word.count >= 4 && word.count <= 10
  }

What does this do?

  1. The first chunk is the same as before, but spread over two lines.
  2. Next, you use filter to loop over the words. Each time through the loop, the word variable contains the next word.
  3. Only return a word from the loop if it has between four and ten letters.

Run the app again and check that you don’t get any words outside these limits:

Tweaked GameView
Tweaked GameView

And if you get a ten letter word, it still fits across the window. With these changes, your app has become a lot more usable.

Key Points

  • SwiftUI uses property wrappers to assign behaviors to properties of its views.
  • @State allows a structure to have persistent, mutable properties.
  • @Binding sends data to a view and allows that view to send any changes back.
  • You can include data files in your Swift apps and read them from the app bundle.

Where to Go From Here

Your game now works perfectly, but the sidebar is still showing the placeholder text. In the next chapter, you’ll create a new model to hold app-wide properties, including an array of games for listing in and selecting from the sidebar.

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.