Getting a Redux Vibe Into SwiftUI

Learn how to implement Redux concepts to manage the state of your SwiftUI app in a more predictable way by implementing a matching-pairs card game. By Andrew Tetlaw.

4.7 (19) · 2 Reviews

Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Vibing out to Redux

When vibing out to Redux, you’ll get used to that workflow: Create a new action, handle it in the reducer and add an appropriate call to store.dispatch(_:).

Now, you’ve made it to the game screen. Examine GameScreenView.swift, and you’ll discover a local let cards: [Card]. Remove that property from GameScreenView and add the following to ThreeDucksState in State.swift:

var cards: [Card] = []

Update GameScreenView so CardGridView uses the store value:

CardGridView(cards: store.state.cards)

Where should you set up the card array for the game screen? The answer for those kinds of questions is almost always the reducer. Add the following to the end of case .startGame: in Reducer.swift:

mutatingState.cards = [
  Card(animal: .bat),
  Card(animal: .bat),
  Card(animal: .ducks),
  Card(animal: .ducks),
  Card(animal: .bear),
  Card(animal: .bear),
  Card(animal: .pelican),
  Card(animal: .pelican),
  Card(animal: .horse),
  Card(animal: .horse),
  Card(animal: .elephant),
  Card(animal: .elephant)
].shuffled()

Build and run and make sure your app still looks the same and behaves the same. The difference now is that your game screen reads its card array from the store, which is set up by the reducer when it receives the .startGame action.

Flipping the Cards

Of course, the cards should flip when you tap them. Each flip counts as one move for the move tally. You also need to make sure that no more than two cards are flipped at any time.

Open State and add these properties to the end of ThreeDucksState:

var selectedCards: [Card] = []
var moves: Int = 0

Next, add a new action to ThreeDucksAction in Actions.swift:

case flipCard(UUID)

When the player taps a card, your app will dispatch the flipCard action with the card’s id. The reducer also needs to be updated to handle this new action. isFlipped in each Card indicates the card’s flip state. You need to update this field for each card while maintaining cards order. To achieve this, open Reducer.swift and add this code to the end of the switch in threeDucksReducer:

case .flipCard(let id):
  // 1
  guard mutatingState.selectedCards.count < 2 else {
    break
  }
  // 2
  guard !mutatingState.selectedCards.contains(where: { $0.id == id }) else {
    break
  }
  // 3
  var cards = mutatingState.cards
  // 4
  guard let selectedIndex = cards.firstIndex(where: { $0.id == id }) else {
    break
  }

  // 5
  let selectedCard = cards[selectedIndex]
  let flippedCard = Card(
    id: selectedCard.id,
    animal: selectedCard.animal,
    isFlipped: true)
  // 6 
  cards[selectedIndex] = flippedCard

  // 7
  mutatingState.selectedCards.append(selectedCard)
  mutatingState.cards = cards
  // 8
  mutatingState.moves += 1

There's a lot of logic here to unpack. Here, you:

  1. First check the selectedCards count to make sure no more than two are selected. If two cards are already selected, break, which will return the state unchanged.
  2. Also check that the selected cards aren't already in selectedCards. This is to prevent counting multiple taps on the same card.
  3. Then, make a mutable copy of cards.
  4. Make sure you can find the index of the flipping card.
  5. Make a new Card, copying the properties from the selected one, making sure isFlipped is true.
  6. Insert the now-flipped card into cards at the correct index.
  7. Append the flipped card to selectedCards and set cards on the new state to the updated array.
  8. Finally, increment the moves tally.

CardView already supports showing flipped cards, you just need to dispatch the action. Open CardGridView.swift and add the following code before body:

@EnvironmentObject var store: ThreeDucksStore

Now that CardGridView has access to the store, update ForEach by adding the following gesture after .frame(width: nil, height: 80):

.onTapGesture {
  store.dispatch(.flipCard(card.id))
}

You're adding a tap gesture to every card that dispatches the action with the card's id. Add an animation modifier to the end of LazyGrid so the flip is animated:

.animation(.default)

Finally, make sure to update the Moves label value in the GameScreenView.swift file to match:

Text("Moves: \(store.state.moves)")

Build and run your game.

Full set of cards with two flipped

One of the first issues you'll discover is that you can only select two cards, ever — even if you tap Give Up and start a new game. Also, the Moves counter never resets. The fix is simple. Open Reducer.swift and update the .startGame case by adding this to the end:

mutatingState.selectedCards = []
mutatingState.moves = 0

That'll fix the issue for a new game, but you can still only flip two cards per game. What you need is some game logic.

Adding Your First Middleware

If a reducer that respects the Redux vibe shouldn't allow side effects, randomness or calls to external functions, what do you do when you need to cause side effects, add randomness or call external functions? In the Redux world, you add a middleware. First, you need to define what a middleware is. Add a new file named Middleware.swift under the State group with the following code:

import Combine

typealias Middleware<State, Action> = 
  (State, Action) -> AnyPublisher<Action, Never>

A middleware is a closure that takes a state value and an action, then returns a Combine publisher that will output an action. Are you intrigued?

Middleware needs to be flexible. Sometimes you need middleware to perform a task but return nothing, and sometimes it must perform a task asynchronously and return eventually. Using a Combine publisher takes care of all that, as you'll see.

If your middleware needs to cause an effect on the state, it should return an action you can dispatch to the store.

Chart showing middleware interaction in app

Your first middleware will implement some game logic. Add a new file named GameLogicMiddleware.swift to State with the following code:

import Combine

let gameLogic: Middleware<ThreeDucksState, ThreeDucksAction> = 
{ state, action in
  return Empty().eraseToAnyPublisher()
}

At the moment, it returns an empty publisher. This is how you implement returning nothing as a publisher.

Unflipping Cards

So, the task at hand is to implement game logic that detects if two cards are flipped. If they're a match, leave them flipped. If not, unflip them. For these outcomes, add two new actions to ThreeDucksAction in Actions.swift:

  case clearSelectedCards
  case unFlipSelectedCards

Open Reducer.swift and add the case statements to handle them:

// 1
case .unFlipSelectedCards:
  let selectedIDs = mutatingState.selectedCards.map { $0.id }
  
  // 2
  let cards: [Card] = mutatingState.cards.map { card in
    guard selectedIDs.contains(card.id) else {
      return card
    }
    return Card(id: card.id, animal: card.animal, isFlipped: false)
  }

  mutatingState.selectedCards = []
  mutatingState.cards = cards

// 3
case .clearSelectedCards:
  mutatingState.selectedCards = []

Here's what this code does:

  1. First, create the case for the unFlipSelectedCards action.
  2. This involves remapping cards, so the selected cards have isFlipped set to false, while leaving the other cards untouched.
  3. Finally, the clearSelectedCards action simply resets selectedCards to an empty array.

The body of your middleware will closely resemble the reducer, that being a switch statement. Open GameLogicMiddleware.swift, and add the following to gameLogic, above return Empty().eraseToAnyPublisher():

switch action {
// 1
case .flipCard:
  let selectedCards = state.selectedCards
  // 2
  if selectedCards.count == 2 {
    if selectedCards[0].animal == selectedCards[1].animal {
      // 3
      return Just(.clearSelectedCards)
        .eraseToAnyPublisher()
    } else {
      // 4
      return Just(.unFlipSelectedCards)
        .eraseToAnyPublisher()
    }
  }

default:
  break
}

In this code:

  1. You intercept every flipCard action to check for a match.
  2. You begin by checking that the number of selected cards is 2.
  3. If the two cards match, you return a Just publisher that sends one action, clearSelectedCards.
  4. If there's no match, you return a Just publisher that sends unFlipSelectedCards.