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!
Is there a mobile software engineer who doesn’t quake in fear at the mention of app state management? For mobile apps, managing state is a significantly hairy problem. Redux is a JavaScript library for predictably managing state, most commonly used in React. Applying Redux concepts to SwiftUI is a viable solution for state management in your next iOS app — and definitely worth a moment of your time to explore.
In this tutorial, you’ll complete a fun card-matching game by implementing the state management in Redux concepts. You’ll learn:
- About immutable states.
- How to take advantage of them in a mobile app.
- To think in terms of actions.
- About using actions to change the app state.
- How to classify certain behaviors as side effects of actions.
- To apply side effects in a predictable way.
Getting Started
Download the starter project using the Download Materials button at the top or bottom of this tutorial.
Three Ducks is your new game: a challenging game of animal card matching. At the moment, it doesn’t do anything because you haven’t written any state code.
No use ducking for cover, it’s time to dive into the amazing world of Redux! You’ll soon see that SwiftUI and Redux work so well together that they’re like siblings hatched from the same nest.
Now, open the starter project and see what you have so far.
Exploring Three Ducks
Build and run the app, and you’ll see the title screen. You can also see the SwiftUI previews if you look through all the views.
Three Ducks is a card-matching game with three screens:
- The title screen shows a play button.
- The game screen displays a grid of cards, a moves counter and an exit button.
- Finally, the win screen gives you a congratulatory message.
The cards have a front view and a back view. The back view is identical for all cards, but the front view shows an image of the animal represented by the card.
You’re missing a lot of code. You’ll need a way to start the game, a way to flip the cards and some game logic. It’s time to stop splish-splashing about, roll up your sleeves and get to work!
SwiftUI, Meet Redux
The official definition of Redux is “a predictable state container for JavaScript apps”. However, instead of asking, “What is Redux?”, you should ask, “What does Three Ducks need to know to start?”. In the lexicon of Redux, that would be the state. State is an object — traditionally, an immutable object.
What happens when you tap New Game? That’s an example of an action. Every time the user performs an action, the state of the app needs to change. How? That’s the job of the reducer. The reducer understands the action, updates the state, if needed, and returns a new state.
All of this is wrapped up in the store: the place where everything happens.
The philosophy of SwiftUI matches Redux nicely. Views have state and only update when the state changes, much like Redux. In fact, the internals of SwiftUI feel like they’re a kind of reducer.
If you want to catch the vibe and explore the theory, go to the source: redux.js.org. Redux Essentials is a great place to start.
Creating the App State
So, what does the app need to know to start? The first step is to make an object to represent the state of the app. The best approach is to always start simple, so make it a Swift struct
.
Add a new top-level group in Xcode called State; this is where all your state management code will live.
Next, add a new file named State.swift to this group. Finally, add a new struct
called ThreeDucksState
in State.swift:
struct ThreeDucksState {
}
You’ll use ThreeDucksState
to track your app state. The first thing the app needs to know is when to start the game. Add a property to ThreeDucksState
named gameState
:
var gameState: GameState = .title
GameState
is already defined under the Model group. There are three possible cases: title
, started
and won
. Each case represents a screen to display. The default value is title
. When the game starts, it should be set to started
. won
is for when the game is won.
Creating the Store
Of course, the app needs to know where the state is, so that value can be read. Add a new file under the State group named Store.swift. Add the following code in that file:
class Store<State>: ObservableObject {
@Published private(set) var state: State
init(initial: State) {
self.state = initial
}
}
You’ve added a touch of Swift generics here, which will be useful in the future. Store
is initialized with an initial state of a generic type State
and stores that in a published property. The object implements ObservableObject
so your views can observe it for changes.
Still in Store.swift, add this above the Store
type definition:
typealias ThreeDucksStore = Store<ThreeDucksState>
You need a concrete type for your Three Ducks app, so typealias
will be handy. With this type alias, you have a Store that’s tied to ThreeDucksState
.
Observing State Changes
Open AppMain.swift and add the following modifier to ContentView()
:
.environmentObject(ThreeDucksStore(initial: ThreeDucksState()))
At app launch, you initialize the store with a default state and pass it to your ContentView
as an environment variable.
Next, open ContentView.swift and add a new property to ContentView
for the store as an environment object:
@EnvironmentObject var store: ThreeDucksStore
Now, remove TitleScreenView()
from the body
of ContentView
and replace it with the following switch
statement:
switch store.state.gameState {
case .title:
TitleScreenView()
case .started:
GameScreenView()
case .won:
GameWinScreenView()
}
Now the app will show a specific view for each game state. You can check your handiwork as long as your preview has its own environment object. Still in ContentView.swift, find ContentView_Previews
and add the following to the end of body
:
.environmentObject(ThreeDucksStore(initial: ThreeDucksState()))
Click Resume on the preview canvas window, and you’ll see the title screen.
You can change the default value of gameState
in ThreeDucksState
to .started
. If you do so and come back to ContentView.swift and refresh the preview, you’ll see the game screen.
You can try this with .won
too. Now that you’ve confirmed observing the store object is working, set the default gameState
back to .title
.
SwiftUI already has easy-to-implement object observing features, which is one of the big reasons Redux and SwiftUI get along so well. Using @EnvironmentObject
makes it even easier, because when you inject a view with an environment object using .environmentObject(_:)
, it’ll automatically share that object with any child views.
Creating Actions
Your next task is to update gameState
when the player taps New Game on the title screen. This requires the next major Redux component: an action.
Anything can be an action, as long as it has an identity and room for some extra properties, if required. In Swift, an enumeration is the perfect solution. So many problems can be solved with Swift enumerations, they’re like a superpower.
Add a new file under the State group named Actions.swift. Then, create a new enumeration called ThreeDucksAction
with a case named startGame
:
enum ThreeDucksAction {
case startGame
}
You’ll use the startGame
action to start the game.
Your store needs to know what type of actions it should manage. Find Store.swift, and update Store
so it matches:
class Store<State, Action>: ObservableObject {
With this change, you require callers of the store to provide both the current state and an action they wish to perform.
You’ll also need to update ThreeDucksStore
to include your new action type:
typealias ThreeDucksStore = Store<ThreeDucksState, ThreeDucksAction>
The type-alias ThreeDucksStore
now corresponds to a specific store that manages both ThreeDucksState
and ThreeDucksAction
.
Making a Reducer
Your store doesn’t do anything yet. This is where you’ll add a reducer. You can think of it as a function that returns a new state based on the current state and a given action.
You’ll implement reducers as Swift closures. Including all the necessary logic within the closure will be a challenge, but worthwhile to your store code, as you’ll see.
Create a new file called Reducer.swift under the State group and add the following typealias
:
typealias Reducer<State, Action> = (State, Action) -> State
You’ve just defined a closure that takes two arguments, one of type Action
and one of type State
, and returns a State
value. Next, create the reducer for Three Ducks:
let threeDucksReducer: Reducer<ThreeDucksState, ThreeDucksAction>
= { state, action in
return state
}
Your reducer just returns the state value it receives, for now. Next, open Store.swift and add the following to Store
, below state
:
private let reducer: Reducer<State, Action>
You’ll also need to update init(initial:)
to include this new property:
init(
initial: State,
reducer: @escaping Reducer<State, Action>
) {
self.state = initial
self.reducer = reducer
}
Congratulations! Your store is now able to apply actions to a state. Finally, you’ll need to update the code in AppMain
and ContentView_Previews
where you created an environment object. Open AppMain.swift and ContentView.swift and update the line where you create ThreeDucksStore
to match:
.environmentObject(ThreeDucksStore(
initial: ThreeDucksState(),
reducer: threeDucksReducer))
Dispatching Actions
You already have a lot of the pieces of your store put together. You just need to connect them all by adding a way for you to ask the store to execute an action. Traditionally, this is known as the dispatch function. To maintain the Redux vibe, you need to make sure the only way to update the state is to call the dispatch function of your store and pass an action to it.
First, open Store.swift and add the following under the reducer
property in Store
:
private let queue = DispatchQueue(
label: "com.raywenderlich.ThreeDucks.store",
qos: .userInitiated)
Note that it’s a serial queue and the quality of service is set to .userInitiated
. Next, add dispatch(_:)
at the end:
func dispatch(_ action: Action) {
queue.sync {
// ...
}
}
This method accepts an action and submits a closure to your queue synchronously. This ensures that the actions are executed in the order they arrive and that the state is up to date for each action when it’s dispatched. The actual work you’ll perform is in a private method you add next. Add the following below dispatch(_:)
:
private func dispatch(_ currentState: State, _ action: Action) {
let newState = reducer(currentState, action)
state = newState
}
This private method takes a state and an action, passes both to the reducer and accepts the returned state value. Finally, it updates the store’s state property with the new state.
Finally, add a call to the private method from inside the queue.sync
closure in dispatch(_:)
:
self.dispatch(self.state, action)
Putting Your Reducer to Work
Now, turn your attention to the reducer, because that’s where the magic happens. Replace the body of the reducer closure in Reducer.swift with the following:
// 1
var mutatingState = state
// 2
switch action {
case .startGame:
// 3
mutatingState.gameState = .started
}
// 4
return mutatingState
Here’s what’s happening:
- First, you create a mutable copy of the state value, so it can be updated.
- Pat yourself on the back for using an enumeration for actions. As you add more actions, this switch statement will grow.
- If the action is
.startGame
, you change thegameState
value to.started
. - The last job is to return the new state.
Now, you’ll wire up that New Game button. Open TitleScreenView.swift and add the store environment object before body
:
@EnvironmentObject var store: ThreeDucksStore
Next, find the button for New Game and add this as the action
:
store.dispatch(.startGame)
Build and run your app. It will switch to the game screen when you tap the button.
Now that you’re on the game screen, there’s no way to go back. The Give Up button does nothing. You’ll fix that next. Open Actions.swift and add a new action:
case endGame
Then, open Reducer.swift and add another case
:
case .endGame:
mutatingState.gameState = .title
Next, open GameScreenView.swift and add the ThreeDucksStore
environment object before body
:
@EnvironmentObject var store: ThreeDucksStore
Then, add the dispatch call in the button action in the body
:
store.dispatch(.endGame)
Build and run the app. Just like that, you’ve created a transition between two screens in both directions.
The diagram below shows the flow of action from app views, when the user taps New Game, to Reducer
where the state changes and causes an update in app views.
Notice there’s no animation when you switch between screens. But that’s easy to fix in SwiftUI. Open TitleScreenView.swift and replace the call to store.dispatch(.startGame)
with:
withAnimation {
store.dispatch(.startGame)
}
Next, open GameScreenView.swift and replace store.dispatch(.endGame)
with:
withAnimation {
store.dispatch(.endGame)
}
Build and run one more time and notice the cross-fade animation when switching between screens.
Take a moment to appreciate what you’ve just achieved. Usually, state management code feels like soggy bread held together with duct tape. Instead, you’ve implemented an architectural marvel, created a single source of truth and disturbed the view code very little. Don’t stop now!
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
.
Adding Middleware to the Store
Now, you'll add your middleware to your store. Add the following code to Store
after the queue
property in Store.swift:
private let middlewares: [Middleware<State, Action>]
Then, update init(initial:reducer:)
so it matches the following:
init(
initial: State,
reducer: @escaping Reducer<State, Action>,
middlewares: [Middleware<State, Action>] = []
) {
self.state = initial
self.reducer = reducer
self.middlewares = middlewares
}
In AppMain.swift and ContentView.swift, replace the environmentObject
modifier with the following:
.environmentObject(ThreeDucksStore(
initial: ThreeDucksState(),
reducer: threeDucksReducer,
middlewares: [gameLogic]))
So, how do you call the middleware closure when dispatching an action? First, open Store.swift and add the following at the top of the file:
import Combine
Then, add the following code after middlewares
property to save publisher subscriptions:
private var subscriptions: Set<AnyCancellable> = []
In your private dispatch method, add the following before the last line:
// 1
middlewares.forEach { middleware in
// 2
let publisher = middleware(newState, action)
publisher
// 3
.receive(on: DispatchQueue.main)
.sink(receiveValue: dispatch)
.store(in: &subscriptions)
}
Here, you:
- Loop through all of the store's middlewares.
- Then, call the middleware closure to obtain the returned publisher.
- Make sure to receive the output on the main queue and send the actions to
dispatch(_:)
.
Build and run your app!
If you get a match, you'll see the flipped cards stay flipped. Unfortunately, you'll also notice that if they don't match, they unflip so fast you don't get to see the second card. That's a quick and easy fix.
In your gameLogic
middleware where you return Just(.unFlipSelectedCards)
, add a delay for a second like this:
return Just(.unFlipSelectedCards)
.delay(for: 1, scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
Build and run your app again. You should be able to flip all the cards once you find all the matches.
Winning the Game
The reveals are the next problem — how do you win the game? You should be used to this workflow by now! Add a new action to ThreeDucksAction
in Actions.swift:
case winGame
Next, handle it with Reducer.swift by adding a winGame
case to the switch
statement:
case .winGame:
mutatingState.gameState = .won
If you recall, when gameState
is set to .won
, it displays GameWinScreenView
.
Now, you need to dispatch the action. Your gameLogic
middleware is up to the job. At the top of the flipCard
case statement, add the following:
// 1
let flippedCards = state.cards.filter { $0.isFlipped }
// 2
if flippedCards.count == state.cards.count {
// 3
return Just(.winGame)
.delay(for: 1, scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
}
Here's what's going on:
- You create an array of all flipped cards by filtering
cards
. - Then, check if
flippedCards.count
equalscards.count
. - If that's true, it's a win, and you return
Just(.winGame)
.
Next, open GameWinScreenView.swift and add the environment variable for store before body
:
@EnvironmentObject var store: ThreeDucksStore
Finally, add the following code in the action for the Go Again button:
store.dispatch(.endGame)
Build and run the app now. You should be able to match all the cards, see the winning screen and tap Go Again to return to the title screen.
Where to Go From Here?
You can download the completed project using the Download Materials button at the top or bottom of this tutorial.
Well done! Your game is working great, and you've managed to get all your ducks in a row where your app state management code is concerned. No soggy bread held together by duct tape here!
If you're looking for an extra challenge, here are a few. You can also see a solution to each one in the final project if you need a hand.
Game Difficulty
On the title screen, there's a difficulty selector. Add a state value for the difficulty. Then, in your gameLogic
middleware, implement a code that sets the initial card array based on the difficulty. Fewer cards for easy
, more cards for hard
.
High Score
Create a new middleware for storing the high score. Retrieve the high score on the app launch and display it on the title screen. When a game is won, check the score to see if it's a new high score.
Quack!
For a bit of fun, if the player flips a purple three ducks card, make the app play a duck quack sound. Implement a new middleware for this.
We hope you enjoyed this tutorial about adopting Redux concepts in SwiftUI. If you have any questions or comments, please join the forum discussion below.