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.
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
Getting a Redux Vibe Into SwiftUI
30 mins
- Getting Started
- Exploring Three Ducks
- SwiftUI, Meet Redux
- Creating the App State
- Creating the Store
- Observing State Changes
- Creating Actions
- Making a Reducer
- Dispatching Actions
- Putting Your Reducer to Work
- Vibing out to Redux
- Flipping the Cards
- Adding Your First Middleware
- Unflipping Cards
- Adding Middleware to the Store
- Winning the Game
- Where to Go From Here?
- Game Difficulty
- High Score
- Quack!
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:
- 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. - Also check that the selected cards aren't already in
selectedCards
. This is to prevent counting multiple taps on the same card. - Then, make a mutable copy of
cards
. - Make sure you can find the index of the flipping card.
- Make a new
Card
, copying the properties from the selected one, making sureisFlipped
istrue
. - Insert the now-flipped card into
cards
at the correct index. - Append the flipped card to
selectedCards
and setcards
on the new state to the updated array. - 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.
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.
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:
- First, create the case for the
unFlipSelectedCards
action. - This involves remapping
cards
, so the selected cards haveisFlipped
set tofalse
, while leaving the other cards untouched. - Finally, the
clearSelectedCards
action simply resetsselectedCards
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:
- You intercept every
flipCard
action to check for a match. - You begin by checking that the number of selected cards is
2
. - If the two cards match, you return a
Just
publisher that sends one action,clearSelectedCards
. - If there's no match, you return a
Just
publisher that sendsunFlipSelectedCards
.