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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
ReSwift Tutorial: Memory Game App
35 mins
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.
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.
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:
- Loads the current category from
UserDefaults
if available, and uses it to instantiateCategoriesState
if it doesn’t already exist. - Reacts to
ChangeCategoryAction
by updating thestate
and saving the new category toUserDefaults
. -
getCurrentCategoryStateFromUserDefaults
is a helper function that loads the category fromUserDefaults
. -
saveCurrentCategoryStateToUserDefaults
is a helper function that saves the category toUserDefaults
.
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:
- Subscribe to
categoriesState
changes onviewWillAppear
and unsubscribe onviewWillDisappear
. - Dispatch the
ChangeCategoryAction
when user selects a cell. - 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.
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:
- the loading indicator is visible or not
- 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))
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:
- On
FetchTunesAction
, this setsshowLoading
totrue
. - On
SetCardsAction
, this randomizes the cards and setsshowLoading
tofalse
.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:
- Uses the awesome Kingfisher library to cache images.
- 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:
- Dispatches
fetchTunes
to start fetching the images from iTunes API. - Configures cells using
CollectionDataSource
which gets the appropriatemodel
toconfigureCell
.
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:
- Updating the loading indicator status depending on the state.
- 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.