Guided Project Apple Pie

Unit 2: Introduction to UIKit|Introduction to UIKit

So far in this unit, you've learned a lot about the fundamentals of Swift. Now it's time to put your knowledge to work.

Your project is to write a game called Apple Pie. In this simple word-guessing game, each player has a limited number of turns to guess the letters in a word. Each incorrect guess results in an apple falling off the tree. The player wins by guessing the word correctly before all the apples are gone.

As a new programmer, you may find this larger project a bit intimidating. Remember, building a working app happens one step at a time. If you get stuck on a particular step, go back and review that lesson. You can do it!

Completed Apple Pie game image or video

In this particular version of Apple Pie, whenever a round is won or lost, a new game is immediately started. If there are no more words left to guess, all buttons are disabled, and the app needs to be restarted.

Part One - Build the Interface

Create a new project using the Single View App template. Name the project "Apple Pie". This game is meant to be played on the iPad, and the interface that you'll be building doesn't accommodate a small iOS device. To make the app iPad only, select the project file in the Project Navigator, then under Deployment Info, uncheck the checkbox under Device for iPhone.1

Changing app to iPad only

Layout in Storyboard

Open Main.storyboard and use the "View as" button to change the device and orientation to an iPad in Landscape mode. Since you'll be using Auto Layout to build the interface, it will work in Portrait mode as well. Look back at the image of the finished app. Using the Interface Builder tools that you've learned so far, how might you construct this interface? There is an image at the top, followed by a grid of buttons, and then two rows of labels containing text. Perhaps the simplest way to build this interface is by using a vertical stack view. Find the vertical stack view in the Object library, and drag it onto the iPad canvas.1

Drag vertical stack view onto canvas

As you've learned in earlier lessons, you can determine the position, width, and height of a stack view by adding constraints between the stack and its superview. Where does the stack start, and where does it end? The image view is near the top, and the last label goes along the bottom of the screen. The grid of buttons begins near the left edge and ends near the right edge. Therefore, the stack view can be constrained to cover the entire screen. Select the stack view, then use the Add New Constraints tool to add four constraints. Set all four fields at the top of the popover to 0. The red indicators illuminate to show the edges that are being constrained. When you're done, click "Add 4 Constraints."1

Add four constraints to vertical stack

Now you're ready to add views and controls to the stack. Search the Object library for an image view, and drag one into the stack view. Since it's the only item within the stack, it will take up the entire space of the stack. Change the "Content Mode" of the image view to "Aspect Fit" in the Attributes inspector.1 This will ensure the width and height of the image is not distorted by the proportions of the image view.

Update Content Mode to Aspect Fit

Add the apple tree images (Tree 0.pdf...Tree 7.pdf) from the student resources folder into your Assets.xcassets folder. After they've been added, select all of them and choose "Single Scale" from the Scales menu in the Attributes inspector.1

Asset Scales

These assets are vector art, which means they can dynamically scale to any size without losing quality. When possible, use PDF files with vector art so that you don’t need to supply multiple scales for various devices.

Now, you can go back to the image view in the storyboard and update the image property to use one of the tree images. Even though you’re updating the image view's image in code, this can help you visualize your interface from within the storyboard.1

Tree image in stack view

The button grid is more complex. You’re emulating the layout of a standard QWERTY keyboard, which has a unique number of keys in each row. You can imagine each row as its own horizontal stack view, and you can contain these rows in a vertical stack view. Use the Object library to add a vertical stack view below the image view.1 You can reorder the items within the stack by dragging them around in the Document Outline.

Each of the horizontal stack views (or rows) that you add will be equal in size and centered. Select the newly added vertical stack view, then open the Attributes inspector and change Alignment to Center and Distribution to Fill Equally. Also provide some spacing between rows by setting the Spacing to 5.2

Vertical stack view in Document Outline

Now add a horizontal stack view within the vertical stack.1 You want the same spacing between columns as rows, so set the Spacing to 5. Set both the Alignment and Distribution to Fill.2

Add horizontal stack in vertical stack view

Each row has a unique number of buttons with the first being 10, the second 9, and the third 7. Use the Object library to add a button to the horizontal stack view. The letter keys on a keyboard are typically square. To achieve this appearance, select the button and use the Add New Constraints button to add an Aspect Ratio constraint.1 The constraint will be added but with an unwanted ratio.

Add Aspect Ratio Constraint

Use the Size inspector for the button to edit the Aspect Ratio constraint, setting the multiplier to 1. This ensures that the button is square.

To make the button easier to read, use the Attributes Inspector to set the font size to 30.

Change the button's text to the letter Q, then select and copy it to your clipboard (Command-C). Paste (Command-V) a new copy of the button into the stack view, and repeat this step until the stack view contains 10 buttons. Use the Attributes inspector to update the text of each button to a single capital letter using your own keyboard as a reference. 1

Buttons added to horizontal stack view

Now select the horizontal stack view, and copy it to your clipboard. Paste two new copies of the stack view. Update each button with the appropriate letters once again using your keyboard as reference—deleting any extra button to match. You can quickly edit a button's text by double-clicking it within Interface Builder.1

All buttons added in stacks

Below the grid of buttons within the second vertical stack view, add two labels from the Object library. Use the Attributes inspector to change the font size of the first label to 30.0 and the second to 20.0. The buttons and labels all have what is referred to as intrinsic size, which the text within them determines. Auto Layout uses their intrinsic size along with the stack view attributes to appropriately size the button grid while the tree image view can shrink and expand as necessary.1

Final interface for game

Before you move on, verify that you don't have any warnings or errors in Interface Builder. You can build and run your app to ensure that the layout looks correct on different iPad simulators or by using the View As feature in Interface Builder.

Create Outlets and Actions

During gameplay, the text of the labels change whenever a letter is guessed correctly or whenever a new round begins. The image need to change whenever an incorrect letter is guessed, and the letter buttons need to be disabled whenever they're pressed and re-enabled before each round. To update these views, create outlets so that you can reference the views in code.

Open the assistant editor so that the ViewController class definition appears to the right of the storyboard. Next, select the image view on the left, Control-drag to an area within the class definition, and release the mouse button. In the pop-up menu that appears, name the outlet treeImageView. When you press the Connect button, the code for the outlet is created.

@IBOutlet var treeImageView: UIImageView!

Repeat this process for the two labels. Name the top label correctWordLabel, and the bottom label scoreLabel.

@IBOutlet var correctWordLabel: UILabel!
@IBOutlet var scoreLabel: UILabel!

It's tedious to create a separate outlet for each UIButton. Instead, create one outlet that holds the collection of buttons. Begin by selecting the first button with the letter Q as the title. Control-drag to the class definition to create an outlet, then release the mouse button. In the pop-up menu that appears, change the Connection type from Outlet to Outlet Collection. Then set the name of the outlet collection to letterButtons. When you press Connect, an outlet is created that references a collection of buttons.

@IBOutlet var letterButtons: [UIButton]!

Now click and drag from the circle next to the outlet collection to another button. A blue rectangle will appear, indicating a valid connection. Release the mouse button, then repeat this step for each button in the scene.1

Connect multiple buttons to collection

Each button also needs to be tied to an action. Again, it’s tedious to create a separate outlet for each button. Instead, create one action that all the buttons call when pressed. Begin by selecting the first button with the letter Q as the title. Control-drag to the class definition, and release the mouse button. In the pop-up menu that appears, change the Connection type to Action. Set the name of the action to letterButtonPressed, and change the type of the argument to UIButton. When you press Connect, the method is created. Whenever a letter button is tapped, it should be disabled (a player can't select a letter more than once in the same round).

@IBAction func letterButtonPressed(_ sender: UIButton) {
  sender.isEnabled = false
}

Why change the argument type from Any to UIButton in the popup? Since there are twenty-six buttons connected to the same action, you'll need to use the sender to determine which button, specifically, triggered the method. If sender has the Any type, you can't access the title property (and we'll need that later).

Control-drag the rest of the buttons to the existing action. You'll know the connection is valid because a blue rectangle will appear as you hover the mouse near the method.1

Connect multiple buttons to action

Build and run your application to verify that tapping each button disables it. It'll become more apparent what else to do inside this method after you build other portions of the app.

Part Two–Beginning a Game

Now you're set to work on the Apple Pie game logic.

Define Words and Turns

Your first task is to supply a list of words for players to guess. At the top of ViewController, define a variable called listOfWords. Fill this array with words: food names, hobbies, animals, household objects, or whatever else. To keep things simple, use only lowercase letters.

var listOfWords = ["buccaneer", "swift", "glorious", "incandescent", "bug", "program"]

Below listOfWords, define a constant called incorrectMovesAllowed which establishes how many incorrect guesses are allowed per round. The lower the number, the harder it will be for the player to win. There are seven different images of apple trees provided, so you'll want this value to be between 1 and 7.

let incorrectMovesAllowed = 7

Define Number of Wins and Losses

After each round, the bottom label will display an updated count of the number of wins and losses. Create two variables to hold each of these values, and set the initial values to 0.

var totalWins = 0
var totalLosses = 0

Begin First Round

When the application launches, the viewDidLoad() method of ViewController is called. This is a great place to start a new round. Define a method called newRound.

override func viewDidLoad() {
    super.viewDidLoad()
    newRound()
}

func newRound() {

}

What does it mean to start a new round? Each round begins with the selection of a new word, and resetting the number of moves the player can make to incorrectMovesAllowed. It would be helpful to hold the state of the game inside of a Game struct. Create a new file in your project by selecting File -> New -> File (Command-N) from the Xcode menubar. Select "Swift File" as your template, then select Next. Name the file Game.swift.

Inside of Game.swift, define a struct called Game. For now, you know that an instance of a Game has two properties: the word, and the number of turns you have left to properly guess the word.

import Foundation

struct Game {
    var word: String
    var incorrectMovesRemaining: Int
}

Back in the newRound() method, you can create a new instance of a Game. You should create a property that holds the current game's value so that it can be updated throughout the view controller code. You can give the Game a new word in the initializer by removing the first value from the listOfWords collection, and set incorrectMovesRemaining to the number of moves you allow, stored in incorrectMovesAllowed.

var currentGame: Game!

func newRound() {
  let newWord = listOfWords.removeFirst()
  currentGame = Game(word: newWord, incorrectMovesRemaining: incorrectMovesAllowed)
}

Why does the currentGame variable have an exclamation mark at the end? For a brief moment between the app launch and the beginning of the first round, currentGame doesn’t have a value. This is a concept you’ll learn in the next unit. For now, know that the exclamation mark means that it's OK for this property not to have a value for a short period.

Now that you've started a new round, you need to update the interface to reflect the new game. Create a separate method called updateUI() that will handle the interface updates, then call it at the end of newRound. Inside of this method, there are two pieces of the interface that you can update with the code you've written thus far: the score label, and the image view. The score label uses simple string interpolation to combine totalWins and totalLosses into a single string. Since each of the tree images are named "Tree X", where X is the number of moves remaining, you can use string interpolation once more to construct the image name.

func newRound() {
    let newWord = listOfWords.removeFirst()
    currentGame = Game(word: newWord, incorrectMovesRemaining: incorrectMovesAllowed)
    updateUI()
}

func updateUI() {
    scoreLabel.text = "Wins: \(totalWins), Losses: \(totalLosses)"
    treeImageView.image = UIImage(named: "Tree \(currentGame.incorrectMovesRemaining)")
}

Build and run your application. The score label and the image view should update to reflect the beginning of a new round. Great job!

Part Three-Update Game State

So far, you've built a working interface, and you've successfully started a new round. Now you need to add some code that progresses the game further towards a win or a loss.

Extract Button Title

Currently, the letterButtonPressed(_:) method disables whichever button the player used to guess, but it doesn’t update the game state. Whenever a button is clicked, you should read the button's title, and determine if that letter is in the word the player is trying to guess. Begin by reading the title that's used when the button is in its "normal" state. Not all buttons have titles, so you'll need to add an exclamation mark to tell the Swift compiler that your button does have a title. (You’ll learn more about the exclamation mark in the next unit.) You should lowercase the letter because it's be much easier to compare everything in lowercase, and then convert it from a String to a Character.

@IBAction func letterButtonPressed(_ sender: UIButton) {
    sender.isEnabled = false
    let letterString = sender.title(for: .normal)!
    let letter = Character(letterString.lowercased())
}

Guess Letter

Now that you have the letter that the player guessed, what should you do with it? A Game manages how many more moves are remaining, but it doesn't know which letters have been selected during the round. Add a collection of characters to Game that keeps track of the selected letters, named guessedLetters. Then add a method in Game that receives a Character, adds it to the collection, and updates incorrectMovesRemaining, if necessary. (By adding the guessedLetters property, you'll need to update the initialization for currentGame.)

struct Game {
    var word: String
    var incorrectMovesRemaining: Int
    var guessedLetters: [Character]

    mutating func playerGuessed(letter: Character) {
      guessedLetters.append(letter)
      if !word.contains(letter) {
        incorrectMovesRemaining -= 1
      }
    }
}

func newRound() {
    let newWord = listOfWords.removeFirst()
    currentGame = Game(word: newWord, incorrectMovesRemaining: incorrectMovesAllowed, guessedLetters: [])
    updateUI()
}

@IBAction func letterButtonPressed(_ sender: UIButton) {
    sender.isEnabled = false
    let letterString = sender.title(for: .normal)!
    let letter = Character(letterString.lowercased())
    currentGame.playerGuessed(letter: letter)
    updateUI()
}

Build and run your application. As you press each button, the character gets added to the list of letters the player has guessed, and incorrectMovesRemaining decreases by 1 for each incorrect letter.

Part Four-Create Revealed Word

Using word and guessedLetters, you can now compute a version of the word that hides the missing letters. For example, if the word for the round is "buccaneer" and the user has guessed the letters "c", "e," "b," and "j," the player should see "bcc__ee" at the bottom of the screen.

To begin, create a computed property called formattedWord within the definition of Game. Here's one way to compute formattedWord:

  • Begin with an empty string variable.
  • Loop through each character of word.
  • If the character is in guessedLetters, convert it to a string, then append the letter onto the variable.
  • Otherwise, append _ onto the variable.
var formattedWord: String {
    var guessedWord = ""
    for letter in word {
        if guessedLetters.contains(letter) {
            guessedWord += "\(letter)"
        } else {
            guessedWord += "_"
        }
    }
    return guessedWord
}

Now that formattedWord is a property that your UI can display, try using it for the text of currentWordLabel inside of updateUI().

func updateUI() {
    correctWordLabel.text = currentGame.formattedWord
    scoreLabel.text = "Wins: \(totalWins), Losses: \(totalLosses)"
    treeImageView.image = UIImage(named: "Tree \(currentGame.incorrectMovesRemaining)")
}

Build and run your application. Letters will be added to the guessedLetters collection as they're selected, and formattedWord will be re-calculated whenever it's accessed in updateUI().

You may notice a new issue: Because multiple underscores appear as a solid line in the interface, it can be difficult to tell how many letters are in the word. A solution could be to add spaces during your computation of formattedWord. But this issue is purely about improving the interface, not meddling with the computed data. A better solution is to add the spaces when you update the text of correctWordLabel.

To properly set the text of correctWordLabel, you can use a Swift method named joined(separator:) that operates on an array of strings. This function concatenates the collection of strings into one string, separated by a given value. Here's an example:

let cast = ["Vivien", "Marlon", "Kim", "Karl"]
let list = cast.joined(separator: ", ")
print(list) // "Vivien, Marlon, Kim, Karl"

How can you imagine using the joined(separator:) method to set correctWordLabel? Start by converting the array of characters in formattedWord into an array of strings. Use a for loop to store each of the newly created strings into a [String] array. Then you can call the joined(separator:) method to join the new collection together, separated by blank spaces.

func updateUI() {
    var letters = [String]()
    for letter in currentGame.formattedWord {
        letters.append(String(letter))
    }
    let wordWithSpacing = letters.joined(separator: " ")
    correctWordLabel.text = wordWithSpacing
    scoreLabel.text = "Wins: \(totalWins), Losses: \(totalLosses)"
    treeImageView.image = UIImage(named: "Tree \(currentGame.incorrectMovesRemaining)")
}

Build and run your application to see a clear break between the letters and the underscores.

Part Five-Handle a Win or Loss

Apple Pie is starting to feel like a real game. The round is progressing along with each button pressed, but there are two obvious issues. The first is that incorrectMovesRemaining can go below 0, and the second is that the player can't win a game, even if they guess all letters correctly.

When incorrectMovesRemaining reaches 0, totalLosses should be incremented, and a new round should begin. It would be nice to have a single method that checks the game state to see if a win or loss has occurred, and if so, update totalWins and totalLosses. Create a method called updateGameState that will perform this work, and call it after each button press instead of calling updateUI().

@IBAction func letterButtonPressed(_ sender: UIButton) {
    sender.isEnabled = false
    let letterString = sender.title(for: .normal)!
    let letter = Character(letterString.lowercased())
    currentGame.playerGuessed(letter: letter)
    updateGameState()
}

func updateGameState() {

}

How do you determine if a game is won, lost, or if the player should continue playing? A game is lost if incorrectMovesRemaining reaches 0. When it does, increment totalLosses. You can determine that a game has been won if the player has not yet lost, and if the current game's word property is equal to the formattedWord (formattedWord won't have any underscore if every letter has been successfully guessed). When that happens, increment totalWins. If a game has not been won or lost yet, then the player should be allowed to continue guessing, and the interface should be updated.

func updateGameState() {
  if currentGame.incorrectMovesRemaining == 0 {
    totalLosses += 1
  } else if currentGame.word == currentGame.formattedWord {
    totalWins += 1
  } else {
    updateUI()
  }
}

Build and run your application. Is the game functioning properly? It's really close, but a new round does not begin after a win or loss. Whenever totalWins or totalLosses changes, a new round can be started, so this is a great time to add didSet property observers to totalWins and totalLosses.

var totalWins = 0 {
    didSet {
        newRound()
    }
}
var totalLosses = 0 {
    didSet {
        newRound()
    }
}

Now try running your application, verifying that both wins and losses are tallied up accordingly, and that a new round begins.

Re-enable Buttons and Fix Crash

It looks like you're approaching the finish line! You're successfully able to complete a round of Apple Pie, but a new round doesn't re-enable the letter buttons. Also, if you try to start a new round and there's no more words to choose from, the game will crash.

The newRound logic needs to be a little smarter. If listOfWords isn't empty, then you should perform the same work that you did previously, but also re-enable all of the buttons. If there are no more words to play with, disable all of the buttons so that the player cannot continue playing the game.

func newRound() {
    if !listOfWords.isEmpty {
        let newWord = listOfWords.removeFirst()
        currentGame = Game(word: newWord, incorrectMovesRemaining: incorrectMovesAllowed, guessedLetters: [])
        enableLetterButtons(true)
        updateUI()
    } else {
        enableLetterButtons(false)
    }
}

The enableLetterButtons(_:) method is fairly straightforward. It takes a Bool as an argument, and it uses the parameter to enable or disable the collection of buttons by looping through them.

func enableLetterButtons(_ enable: Bool) {
  for button in letterButtons {
    button.isEnabled = enable
  }
}

If you build and run the application, you should be able to get through all of the Apple Pie rounds until the end is reached and the buttons are disabled.

Wrap-Up

Congratulations on building your first mobile game with Swift!

By successfully completing Apple Pie, you've demonstrated an understanding of the building blocks of the Swift language. This project isn't easy, and you should be proud of what you've accomplished. If you struggled to follow along with any of the steps, set aside some time to rebuild Apple Pie on your own, without this guide. A second run-through will point out areas that you may not have understood—and you can look to the guide and earlier lessons to reinforce your knowledge.

Stretch Goals

If you'd like to continue working on Apple Pie, go ahead and try adding some features to the game. Here are a few ideas to play with. You can build most of these features using your existing knowledge of Swift, but a few may require you to use the Xcode documentation. Good luck!

Challenge yourself by adding these features to Apple Pie:

  • Learn about the map method, and use it in place of the loop that converts the array of characters to an array of strings in updateUI().
  • Add a scoring feature that awards points for each correct guess and additional points for each successful word completion.
  • Allow multiple players to play, switching turns after each incorrect guess.
  • Allow the player to guess the full word using the keyboard instead of guessing one letter at a time using the interface buttons.
  • Support letters with special characters. For example, the E button could check for "e" and "é" within a word.
  • The keyboard layout doesn’t work well when the app is in one-third Split View mode on iPad—the buttons get flattened. To resolve this issue, use trait variations to adjust the layout when in compact width.