ReSwift Tutorial: Memory Game App

In this ReSwift tutorial, you’ll learn to create a Redux-like app architecture in Swift that leverages unidirectional data flow. By Michael Ciurus.

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

Updating the State

You may have noticed a flaw with the current navigation implementation. When you tap on the New Game menu item, the navigationState of RoutingState gets changed from menu to game. But when you use the navigation controller’s back arrow to go back to the menu, nothing is updating the navigationState!

In ReSwift, it’s important to keep the state synchronized with the current UI state. It’s easy to forget about it when something is managed completely by UIKit, like the navigation back arrow or user typing something into a UITextField.

Fix this by updating the navigationState when MenuTableViewController appears.

In MenuTableViewController.swift, add this line at the bottom of viewWillAppear:

store.dispatch(RoutingAction(destination: .menu))

This updates the store manually if the navigation back arrow was used.

Run the app and test the navigation again. Aaaaand… now the navigation is completely broken. Nothing ever appears to get fully pushed on, and you may see a crash.

ReSwift tutorial

Open AppRouter.swift; you’ll recall that pushViewController is called each time a new navigationState is received. This means that you respond to the menu RoutingDestination update by…pushing the menu on again!

You have to dynamically check if the MenuViewController isn’t already visible before pushing. Replace the contents of pushViewController with:

let viewController = instantiateViewController(identifier: identifier)
let newViewControllerType = type(of: viewController)
if let currentVc = navigationController.topViewController {
  let currentViewControllerType = type(of: currentVc)
  if currentViewControllerType == newViewControllerType {
    return
  }
}

navigationController.pushViewController(viewController, animated: animated)

You call type(of:) against the current top view controller and compare it to the new one being pushed on. If they match, you return without pushing on the controller in duplicate.

Build and run, and navigation should work normally again, with the menu state being properly set when you pop the stack.

ReSwift tutorial

Updating state with UI actions and checking the current state dynamically is often complex. It’s one of the challenges you’ll have to overcome when dealing with ReSwift. Fortunately it shouldn’t happen very often.

Categories

Now you’ll go a step further and implement a more complex screen: CategoriesTableViewController. You need to allow the user to choose the category of music, so they can enjoy the game of Memory with their favorite bands. Start by adding the state in CategoriesState.swift:

import ReSwift

enum Category: String {
  case pop = "Pop"
  case electronic = "Electronic"
  case rock = "Rock"
  case metal = "Metal"
  case rap = "Rap"
}

struct CategoriesState: StateType {
  let categories: [Category]
  var currentCategorySelected: Category
  
  init(currentCategory: Category) {
    categories = [ .pop, .electronic, .rock, .metal, .rap]
    currentCategorySelected = currentCategory
  }
}

The enum defines several music categories. CategoriesState contains an array of available categories as well as the currentCategorySelected for tracking state.

In ChangeCategoryAction.swift, add the following:

import ReSwift

struct ChangeCategoryAction: Action {
  let categoryIndex: Int
}

This creates an Action that can change CategoriesState, using categoryIndex to reference music categories.

Now you need to implement a Reducer that accepts the ChangeCategoryAction and stores the updated state. Open CategoriesReducer.swift and add the following:

import ReSwift

private struct CategoriesReducerConstants {
  static let userDefaultsCategoryKey = "currentCategoryKey"
}

private typealias C = CategoriesReducerConstants

func categoriesReducer(action: Action, state: CategoriesState?) -> CategoriesState {
  var currentCategory: Category = .pop
  // 1
  if let loadedCategory = getCurrentCategoryStateFromUserDefaults() {
    currentCategory = loadedCategory
  }
  var state = state ?? CategoriesState(currentCategory: currentCategory)
  
  switch action {
  case let changeCategoryAction as ChangeCategoryAction:
    // 2
    let newCategory = state.categories[changeCategoryAction.categoryIndex]
    state.currentCategorySelected = newCategory
    saveCurrentCategoryStateToUserDefaults(category: newCategory)
    
  default: break
  }
  
  return state
}

// 3
private func getCurrentCategoryStateFromUserDefaults() -> Category? {
  let userDefaults = UserDefaults.standard
  let rawValue = userDefaults.string(forKey: C.userDefaultsCategoryKey)
  if let rawValue = rawValue {
    return Category(rawValue: rawValue)
  } else {
    return nil
  }
}

// 4
private func saveCurrentCategoryStateToUserDefaults(category: Category) {
  let userDefaults = UserDefaults.standard
  userDefaults.set(category.rawValue, forKey: C.userDefaultsCategoryKey)
  userDefaults.synchronize()
}

Just as with the other reducers, this implements a method to complete state updates from actions. In this case, you’re also persisting the selected category to UserDefaults. Here’s a closer look at what it does:

  1. Loads the current category from UserDefaults if available, and uses it to instantiate CategoriesState if it doesn’t already exist.
  2. Reacts to ChangeCategoryAction by updating the state and saving the new category to UserDefaults.
  3. getCurrentCategoryStateFromUserDefaults is a helper function that loads the category from UserDefaults.
  4. saveCurrentCategoryStateToUserDefaults is a helper function that saves the category to UserDefaults.

The helper functions are also pure global functions. You could put them in a class, or a structure, but they should always remain pure.

Naturally, you have to update the AppState with the new state. Open AppState.swift and add the following to the end of the struct:

let categoriesState: CategoriesState

categoriesState is now part of the AppState. You’re getting the hang of this!

Open AppReducer.swift and modify the returned value to match this:

return AppState(
  routingState: routingReducer(action: action, state: state?.routingState),
  menuState: menuReducer(action: action, state: state?.menuState),
  categoriesState: categoriesReducer(action:action, state: state?.categoriesState))

Here you’ve added categoriesState to appReducer passing the action and categoriesState.

Now you need to create the categories screen, similarly to MenuTableViewController. You’ll make it subscribe to the Store and use TableDataSource.

Open CategoriesTableViewController.swift and replace the contents with the following:

import ReSwift

final class CategoriesTableViewController: UITableViewController {
  
  var tableDataSource: TableDataSource<UITableViewCell, Category>?
  
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    // 1
    store.subscribe(self) {
      $0.select {
        $0.categoriesState
      }
    }
  }
  
  override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    store.unsubscribe(self)
  }
  
  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    // 2
    store.dispatch(ChangeCategoryAction(categoryIndex: indexPath.row))
  }
}

// MARK: - StoreSubscriber
extension CategoriesTableViewController: StoreSubscriber {
  func newState(state: CategoriesState) {
    tableDataSource = TableDataSource(cellIdentifier:"CategoryCell", models: state.categories) {cell, model in
      cell.textLabel?.text = model.rawValue
      // 3
      cell.accessoryType = (state.currentCategorySelected == model) ? .checkmark : .none
      return cell
    }
    
    self.tableView.dataSource = tableDataSource
    self.tableView.reloadData()
  }
}

This should look pretty similar to MenuTableViewController. Here are some highlights:

  1. Subscribe to categoriesState changes on viewWillAppear and unsubscribe on viewWillDisappear.
  2. Dispatch the ChangeCategoryAction when user selects a cell.
  3. On newState, mark the cell for the currently selected category with a checkmark.

Everything’s set. Now you can choose the category. Build and run the app, and select Choose Category to see for yourself.

ReSwift tutorial

Asynchronous Tasks

Asynchronous programming is hard, huh? Well, not in ReSwift.

You’ll fetch the images for Memory cards from the iTunes API. First, you’ll have to create a game state, reducer and associated action.

Open GameState.swift, and you’ll see a MemoryCard struct that represents a game card. It includes the imageUrl to be displayed on the card. isFlipped identifies if the front of the card is visible and isAlreadyGuessed indicates if the card was already matched.

You’ll add game state to this file. Start by importing ReSwift at the top:

import ReSwift

Now add the following to the bottom of the file:

struct GameState: StateType {
  var memoryCards: [MemoryCard]
  // 1
  var showLoading: Bool
  // 2
  var gameFinished: Bool
}

These define the state of the game. In addition to containing the array of available memoryCards, the properties here indicate if:

  1. the loading indicator is visible or not
  2. the game is finished

Add a game Reducer in GameReducer.swift:

import ReSwift

func gameReducer(action: Action, state: GameState?) -> GameState {
    let state = state ?? GameState(memoryCards: [], showLoading: false, gameFinished: false)

    return state
}

This currently just creates a new GameState. You’ll circle back to this later.

In AppState.swift, add gameState to the bottom of AppState:

let gameState: GameState

In AppReducer.swift, update the initializer for the last time:

return AppState(
  routingState: routingReducer(action: action, state: state?.routingState),
  menuState: menuReducer(action: action, state: state?.menuState),
  categoriesState: categoriesReducer(action:action, state: state?.categoriesState),
  gameState: gameReducer(action: action, state: state?.gameState))
Note: Notice how predictable, easy and familiar everything is after doing the Action/Reducer/State routine a few times? This programmer-friendly routine is thanks to the unidirectional nature of ReSwift and the strict constraints it sets on each module. As you’ve learned, only Reducers can change the app Store and only Actions can initiate that change. You instantly know where to look, and where to add new code.

Now define an action for updating cards by adding the following in SetCardsAction.swift:

import ReSwift

struct SetCardsAction: Action {
  let cardImageUrls: [String]
}

This Action sets the image URLs for cards in the GameState

Now you’re ready to create your first asynchronous action. In FetchTunesAction.swift, add the following:

import ReSwift

func fetchTunes(state: AppState, store: Store<AppState>) -> FetchTunesAction {
  
  iTunesAPI.searchFor(category: state.categoriesState.currentCategorySelected.rawValue) { imageUrls in
    store.dispatch(SetCardsAction(cardImageUrls: imageUrls))
  }
  
  return FetchTunesAction()
}

struct FetchTunesAction: Action {
}

fetchTunes fetches the images using iTunesAPI (included with the starter). In the closure you’re dispatching a SetCardsAction with the result. Asynchronous tasks in ReSwift are that simple: just dispatch an action later in time, when complete. That’s it.

fetchTunes returns FetchTunesAction which will be used to signify the fetch has kicked off.

Open GameReducer.swift and add support for the two new actions. Replace the contents of gameReducer with the following:

var state = state ?? GameState(memoryCards: [], showLoading: false, gameFinished: false)

switch(action) {
// 1
case _ as FetchTunesAction:
  state = GameState(memoryCards: [], showLoading: true, gameFinished: false)
// 2
case let setCardsAction as SetCardsAction:
  state.memoryCards = generateNewCards(with: setCardsAction.cardImageUrls)
  state.showLoading = false
default: break
}

return state

You changed state to be a constant, and then implemented an action switch that does the following:

  1. On FetchTunesAction, this sets showLoading to true.
  2. On SetCardsAction, this randomizes the cards and sets showLoading to false. generateNewCards can be found in MemoryGameLogic.swift, which is included with the starter.

It’s time to draw the cards in the GameViewController. Start with setting up the cell.

Open CardCollectionViewCell.swift and add the following method to the bottom of CardCollectionViewCell:

func configureCell(with cardState: MemoryCard) {
  let url = URL(string: cardState.imageUrl)
  // 1
  cardImageView.kf.setImage(with: url)
  // 2
  cardImageView.alpha = cardState.isAlreadyGuessed || cardState.isFlipped ? 1 : 0
}

configureCell does the following:

  1. Uses the awesome Kingfisher library to cache images.
  2. Shows the card image when a card is already guessed or the card is flipped.

Next you will implement the collection view that displays the cards. Just as there is for table views, there is a declarative wrapper for UICollectionView named CollectionDataSource included in the starter that you’ll leverage.

Open GameViewController.swift and first replace the UIKit import with:

import ReSwift 

In GameViewController, add the following just above showGameFinishedAlert:

var collectionDataSource: CollectionDataSource<CardCollectionViewCell, MemoryCard>?

override func viewWillAppear(_ animated: Bool) {
  super.viewWillAppear(animated)
  store.subscribe(self) {
    $0.select {
      $0.gameState
    }
  }
}

override func viewWillDisappear(_ animated: Bool) {
  super.viewWillDisappear(animated)
  store.unsubscribe(self)
}

override func viewDidLoad() {
  // 1
  store.dispatch(fetchTunes)
  collectionView.delegate = self
  loadingIndicator.hidesWhenStopped = true
  
  // 2
  collectionDataSource = CollectionDataSource(cellIdentifier: "CardCell", models: [], configureCell: { (cell, model) -> CardCollectionViewCell in
    cell.configureCell(with: model)
    return cell
  })
  collectionView.dataSource = collectionDataSource
}

Note this will result in a few compiler warnings until you adopt StoreSubscriber in a moment. The view subscribes to gameState on viewWillAppear and unsubscribes on viewWillDisappear. In viewDidLoad it does the following:

  1. Dispatches fetchTunes to start fetching the images from iTunes API.
  2. Configures cells using CollectionDataSource which gets the appropriate model to configureCell.

Now you need to add an extension to adhere to StoreSubscriber. Add the following to the bottom of the file:

// MARK: - StoreSubscriber
extension GameViewController: StoreSubscriber {
  func newState(state: GameState) {
    
    collectionDataSource?.models = state.memoryCards
    collectionView.reloadData()
    
    // 1
    state.showLoading ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating()
    
    // 2
    if state.gameFinished {
      showGameFinishedAlert()
      store.dispatch(fetchTunes)
    }
  }
}

This implements newState to handle state changes. It updates the datasource as well as:

  1. Updating the loading indicator status depending on the state.
  2. Restarting the game and showing an alert when the game has been finished.

Build and run the game, select New Game, and you’ll now be able to see the memory cards.

ReSwift tutorial

Michael Ciurus

Contributors

Michael Ciurus

Author

Jeff Rames

Tech Editor

Chris Belanger

Editor

Andy Obusek

Final Pass Editor and Team Lead

Over 300 content creators. Join our team.