Flutter Fall Event

Join 20,000+ other developers enjoying 3 months of free access to Flutter Apprentice, courtesy of the Flutter team at Google !

Home iOS & Swift Tutorials

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.

4.7/5 12 Ratings

Version

  • Swift 5, iOS 14, Xcode 12

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.
Note: This intermediate-level tutorial assumes you’re comfortable building an iOS app using Xcode and writing Swift. You should already have used Combine and be comfortable with its concepts. The example also uses SwiftUI.

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.

Three Ducks main three screens

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.

Two cards for Three Ducks, one with three ducks and one with a horse

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.

It’s the vibe: Rather than being a set of inflexible rules, Redux has a lot of flexibility built-in. Instead of thinking of it like a brutalist concrete structure that dominates your code, try thinking of it more like a hand-built wooden dining-room table — an artful piece of technology you can eat at, but how you cook your roast is up to you.

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.

Three Ducks title screen preview

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.

Three Ducks game screen preview

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.

Swifty as a superhero

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.

Make it pure: In theory, anything can be a reducer, but if you want to match the vibe of Redux, you’ll make it a pure function. If you haven’t heard that term before, the simplest definition is that a pure function is 100% predictable: It always returns the same output for the same input, it has no state, no side effects, no randomness and no calls to external functions.

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:

  1. First, you create a mutable copy of the state value, so it can be updated.
  2. Pat yourself on the back for using an enumeration for actions. As you add more actions, this switch statement will grow.
  3. If the action is .startGame, you change the gameState value to .started.
  4. 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.

Three Ducks state the game.

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.

Three Ducks state the game.

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.

Chart of app function

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:

  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.

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:

  1. Loop through all of the store's middlewares.
  2. Then, call the middleware closure to obtain the returned publisher.
  3. Make sure to receive the output on the main queue and send the actions to dispatch(_:).

Build and run your app!

Three Ducks un-flip cards

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.

All cards are flipped

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:

  1. You create an array of all flipped cards by filtering cards.
  2. Then, check if flippedCards.count equals cards.count.
  3. 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.

Three Ducks game win 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.

Average Rating

4.7/5

Add a rating for this content

12 ratings

More like this

Contributors

Comments