Practical State Machines with GameplayKit

In this tutorial, you’ll convert an iOS app to use a state machine for navigation logic using GameplayKit’s GKStateMachine. By Keegan Rush.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Creating Each State

Now that you’ve created your state machine, it’s time to add some states. Right now, each coordinator in the app will create other coordinators as needed to navigate to different screens. So, because you want to move the decision of which screen to navigate to, you need to remove that logic from the coordinators.

Start with the ApplicationCoordinator. This class will keep the state machine, but all other coordinators will be created by one of the states in the state machine. So, delete the allKanjiListCoordinator property on the ApplicationCoordinator. You’ll recreate it later inside the AllState class. Remove this line inside init(window:) that creates the coordinator:

allKanjiListCoordinator = KanjiListCoordinator(
  presenter: rootViewController, 
  kanjiStorage: kanjiStorage,
  list: kanjiStorage.allKanji(), 
  title: "Kanji List")

As well as this method inside start() that fires the coordinator:

allKanjiListCoordinator.start()

Build and run the app. Seems like it’s lost some of its utility:

Broken Kanji List

You’ll gain it back when you start creating the states.

All State

Add a new file named AllState.swift under the State Machine group. Replace its import statement with this:

import GameplayKit.GKState

class AllState: GKState {
  // 1
  lazy var allKanjiListCoordinator = makeAllKanjiCoordinator()

  // 2
  override func didEnter(from previousState: GKState?) {
    allKanjiListCoordinator?.start()
  }

  private func makeAllKanjiCoordinator() -> KanjiListCoordinator? {
    // 3
    guard let kanjiStateMachine = stateMachine as? KanjiStateMachine else {
      return nil
    }
    
    let kanjiStorage = kanjiStateMachine.kanjiStorage

    // 4
    return KanjiListCoordinator(
      presenter: kanjiStateMachine.presenter,
      kanjiStorage: kanjiStorage,
      list: kanjiStorage.allKanji(),
      title: "Kanji List")
  }
}

Here’s what’s going on:

  1. Here, you recreate the coordinator you removed from ApplicationCoordinator.swift.
  2. didEnter(from:) fires whenever the state machine enters a new state. It’s the ideal place to fire off the allKanjiListCoordinator to navigate to the All screen.
  3. You can use the stateMachine property on a GKState to get its state machine. Here, you cast it to KanjiStateMachine to access the properties you added earlier.
  4. To build a KanjiListCoordinator, give it all the data needed to present the All screen.

Next, open ApplicationCoordinator.swift and find the line where you create the state machine inside init(window:). Create an instance of the AllState and pass it into the states array, like this:

stateMachine = KanjiStateMachine(
  presenter: rootViewController,
  kanjiStorage: kanjiStorage,
  states: [AllState()])

In start(), add the following line to the beginning of the method:

stateMachine.enter(AllState.self)

This causes the state machine to enter the AllState and triggers the allKanjiListCoordinator to navigate to the All screen. Build and run the app. Everything’s working smoothly again!

All Kanji Screen

Detail State

Open KanjiListCoordinator.swift and find kanjiListViewController(_:didSelectKanji:) in the extension at the bottom. This method creates and starts a KanjiDetailCoordinator, causing the app to navigate to the Detail screen. Remove the contents of the method, leaving it empty.

Build and run the app. It should still show the All screen. But, because you removed the navigation logic from kanjiListViewController(_:didSelectKanji:), the KanjiListCoordinator won’t create the next coordinator to move to a different screen. Tapping on a kanji does nothing.

To fix this, you need to add the code you just removed to a new state object. Add a new file named DetailState.swift under the State Machine group. Replace its import statement with this:

import GameplayKit.GKState

class DetailState: GKState {
  // 1
  var kanji: Kanji?
  var kanjiDetailCoordinator: KanjiDetailCoordinator?

  override func didEnter(from previousState: GKState?) {
    guard 
      let kanji = kanji,
      let kanjiStateMachine = (stateMachine as? KanjiStateMachine) 
      else {
        return
    }
    
    // 2
    let kanjiDetailCoordinator = KanjiDetailCoordinator(
      presenter: kanjiStateMachine.presenter,
      kanji: kanji,
      kanjiStorage: kanjiStateMachine.kanjiStorage)

    self.kanjiDetailCoordinator = kanjiDetailCoordinator
    kanjiDetailCoordinator.start()
  }
}

Here’s what’s happening:

  1. The KanjiDetailCoordinator needs a Kanji to show the Detail screen. You’ll need to set it here.
  2. Create and start the KanjiDetailCoordinator, similar to what you did previously in kanjiListViewController(_:didSelectKanji:).

Communicating to the State Machine

You need a way to communicate to the state machine that it needs to enter the DetailState. So, you’ll make use of NotificationCenter to submit a notification, and then listen for it inside ApplicationCoordinator. Back in KanjiListCoordinator.swift, add this line to kanjiListViewController(_:didSelectKanji:):

NotificationCenter.default
  .post(name: Notifications.KanjiDetail, object: selectedKanji)

Notifications.KanjiDetail is just an NSNotification.Name object that was created for you ahead of time. This posts a notification, passing the selectedKanji that’s needed to show the Detail screen.

Open ApplicationCoordinator.swift again. Go to the line where you create the state machine inside init(window:). Create an instance of the DetailState and pass it into the states array, like you did for the AllState earlier:

stateMachine = KanjiStateMachine(
  presenter: rootViewController,
  kanjiStorage: kanjiStorage,
  states: [AllState(), DetailState()])

Next, add this method:

@objc func receivedKanjiDetailNotification(notification: NSNotification) {
  // 1
  guard 
    let kanji = notification.object as? Kanji,
    // 2
    let detailState = stateMachine.state(forClass: DetailState.self) 
    else {
      return
  }

  // 3
  detailState.kanji = kanji

  // 4
  stateMachine.enter(DetailState.self)
}

Here’s what’s going on:

  1. Get the Kanji object passed with the notification
  2. GKStateMachine.state(forClass:) returns the instance of a state that you passed into the state machine’s initializer. Get that instance here.
  3. Store the kanji for the DetailState to use when creating its KanjiDetailCoordinator.
  4. Finally, enter the DetailState, which will create and start the KanjiDetailCoordinator.

You still need to subscribe to the KanjiDetail notification, so add this to subscribeToNotifications():

NotificationCenter.default.addObserver(
  self, selector: #selector(receivedKanjiDetailNotification),
  name: Notifications.KanjiDetail, object: nil)

Build and run the app. You should be able to tap on a Kanji and reach the Detail screen again.

List State

The process for implementing the ListState will be similar to what you’ve seen before. You’ll remove navigation logic from a coordinator, move it to a new GKState class and communicate to the stateMachine that it should enter the new state.

To start off, open KanjiDetailCoordinator.swift. kanjiDetailViewController(_:didSelectWord:) fires when the user taps on a word on the Detail screen. It then creates and starts a KanjiListCoordinator to show the List screen for all the kanji in that word.

Remove the contents of kanjiDetailViewController(_:didSelectWord:) and replace it with this:

NotificationCenter.default.post(name: Notifications.KanjiList, object: word)

Back in ApplicationCoordinator.swift, create a new empty method to receive the notification:

@objc func receivedKanjiListNotification(notification: NSNotification) {
}

Then, add the following code to subscribe to the notification in subscribeToNotifications().

NotificationCenter.default.addObserver(
  self, selector: #selector(receivedKanjiListNotification),
  name: Notifications.KanjiList, object: nil)

Under the State Machine group, create a new file named ListState.swift. Replace its import statement with this:

import GameplayKit.GKState

class ListState: GKState {
  // 1
  var word: String?
  var kanjiListCoordinator: KanjiListCoordinator?

  override func didEnter(from previousState: GKState?) {
    guard 
      let word = word,
      let kanjiStateMachine = (stateMachine as? KanjiStateMachine) 
      else {
        return
    }
    
    let kanjiStorage = kanjiStateMachine.kanjiStorage

    // 2
    let kanjiForWord = kanjiStorage.kanjiForWord(word)

    // 3
    let kanjiListCoordinator = KanjiListCoordinator(
      presenter: kanjiStateMachine.presenter, kanjiStorage: kanjiStorage,
      list: kanjiForWord, title: word)

    self.kanjiListCoordinator = kanjiListCoordinator
    kanjiListCoordinator.start()
  }
}

It’s the same pattern that you used for the DetailState, but here’s what’s going on:

  1. The List screen shows all kanji in a word. So, store that word here for yourself to get the kanji from.
  2. Use the KanjiStorage object to get a list of kanji from the word.
  3. Pass all the necessary data into the initializer for KanjiListCoordinator and call start() to navigate to the List screen.

Now that you have a ListState, you can pass it into the state machine and enter the state when needed. Back in ApplicationCoordinator.swift, pass an instance of the ListState to the KanjiStateMachine‘s initializer in init(window:):

stateMachine = KanjiStateMachine(
  presenter: rootViewController,
  kanjiStorage: kanjiStorage,
  states: [AllState(), DetailState(), ListState()])

Add the following to receivedKanjiListNotification(notification:) to configure and enter the ListState:

// 1
guard 
  let word = notification.object as? String,
  let listState = stateMachine.state(forClass: ListState.self) 
  else {
    return
}

// 2
listState.word = word

// 3
stateMachine.enter(ListState.self)

Here’s the breakdown:

  1. Get the word from the notification and the ListState instance from the state machine.
  2. Set the word on the ListState for the state to configure the KanjiListCoordinator.
  3. Enter the ListState, causing the KanjiListCoordinator to start the navigation to the List Screen.

Build and run the app. Everything should be working smoothly, all neatly managed by the GKStateMachine :]

Kanji List Functionality Restored

Keegan Rush

Contributors

Keegan Rush

Author

Scott McAlister

Tech Editor

Tyler Bos

Editor

Aleksandra Kizevska

Illustrator

Kelvin Lau

Final Pass Editor

Richard Critz

Team Lead

Over 300 content creators. Join our team.