📜 ⬆️ ⬇️

Using ReSwift: Writing the Memory Game Application

ReSwift

Note. This article uses Xcode 8 and Swift 3 .

As iOS applications continue to grow in size, the MVC pattern gradually loses its role as an “appropriate” architectural solution.

For iOS developers, more efficient architectural patterns are available, such as MVVM, VIPER, and Riblets . They are very different, but they have a common goal: to break the code into blocks according to the principle of common responsibility with a multidirectional data stream. In a multidirectional stream, data moves in different directions between different modules.
')
Sometimes you don’t want (or you don’t need to) use a multidirectional data stream - instead you want the data to be sent in one direction: this is a unidirectional data stream. In this article about ReSwift, you will turn off the beaten path and learn how to use the ReSwift framework to implement a unidirectional data flow when creating a Memory Game application called MemoryTunes .

But first - what is ReSwift?

Introduction to ReSwift


ReSwift is a small framework that helps you implement the Redux architecture using Swift.

ReSwift has four main components:


ReSwift has a number of interesting advantages:


Multidirectional or unidirectional flow


To clarify what I mean when I talk about data flow, I’ll give the following example. An application created using VIPER supports multidirectional data flow between modules:

VIPER

VIPER - Multidirectional Data Stream

Let's compare it with unidirectional data flow in an application built on the basis of ReSwift:
ReSwift

ReSwift - unidirectional data flow

Since data can only be transferred in one direction, it is much easier to visually follow the code and identify any problems in the application.

Getting started


Start by downloading the project , which currently contains some source code and a set of frameworks, including ReSwift, which you will learn more about as you read this article.

First, you will need to configure work with ReSwift. Start by building the application kernel: its states.

Open AppState.swift and create an AppState structure that matches the StateType:

import ReSwift struct AppState: StateType { } 

This structure defines the state of the application.

Before creating the Store , which will contain the value of AppState, you need to create a main Reducer .

Reducer

Reducer directly changes the current AppState value stored in the Store . Only Action can run a Reducer to change the current state of the application. Reducer generates the current AppState value depending on the Action it receives.

Note. There is only one Store in the application and it has only one main Reducer.

Create the main reducer function of the application in AppReducer.swift

 import ReSwift func appReducer(action: Action, state: AppState?) -> AppState { return AppState() } 

appReducer is a function that takes an Action and returns a modified AppState. The state parameter is the current state of the application. This function should change state accordingly, depending on the action received. Now just create a new AppState value - you will return to it as soon as you configure the Store.

It's time to create a Store that stores the state of the application, and the reducer, in turn, could change it.

Store

Store stores the current state of the entire application: this is the value of your AppState structure. Open AppDelegate.swift and replace the import UIKit with the following:

 import ReSwift var store = Store<AppState>(reducer: appReducer, state: nil) 

This creates a global store variable initialized by the appReducer. appReducer is the main Reducer of the Store block, which contains instructions on how the store should change when an Action is received. Since this is the initial creation, not an iterative change, you are passing an empty state.

Compile and run the application to make sure we did everything right:

image

This is not very interesting ... But at least it works:]

Interface Navigation


It's time to create the first real state of the application. You will begin by navigating the interface (routing).

Navigating to an application (or routing) is a challenge for every architecture, not just for ReSwift. You have to use a simple approach in MemoryTunes, where you define the entire list of screens in enum and the AppState will contain the current value. AppRouter will respond to changes in this value and show the current status on the screen.

Open AppRouter.swift and replace import UIKit with the following:

 import ReSwift enum RoutingDestination: String { case menu = "MenuTableViewController" case categories = "CategoriesTableViewController" case game = "GameViewController" } 

This enum defines all the controllers presented in the application.

Now you have what to store in the State application. In this case, there is only one basic state structure (AppState), but you can divide the state of the application into sub-states specified in the main state.

Since this is a good practice, you will group state variables into sub-state structures. Open RoutingState.swift and add the following sub-state structure for navigation:

 import ReSwift struct RoutingState: StateType { var navigationState: RoutingDestination init(navigationState: RoutingDestination = .menu) { self.navigationState = navigationState } } 

RoutingState contains navigationState , which represents the current destination on the screen.

Note: menu is the default value for navigationState. This value is indirectly set by default when the application is started, unless you specify another one during RoutingState initialization.

In AppState.swift, add the following inside the structure:

 let routingState: RoutingState 

AppState now contains the RoutingState sub-state.

Run the application and you will see the problem:

Oops ...

The appReducer function is no longer compiled! This is due to the fact that you added routingState to the AppState, but did not pass anything to the default initializer call. To create a RoutingState you need a reducer.

There is only one main function in Reducer, but, like the state, reducers must be divided into sub-reducers.

Sub-State and Sub-Reducers

Add the following Reducer to navigate to RoutingReducer.swift :

 import ReSwift func routingReducer(action: Action, state: RoutingState?) -> RoutingState { let state = state ?? RoutingState() return state } 

Like the main Reducer, the routingReducer changes state depending on the action it receives, and then returns it. You have no actions yet, so a new RoutingState is created if state is nil and this value is returned.

Sub-reducers are responsible for initializing the initial values ​​of the corresponding sub-states.
Return to AppReducer.swift to correct the compiler warning. Modify the appReducer function to match this:

 return AppState(routingState: routingReducer(action: action, state: state?.routingState)) 

We have added the routingState argument to the AppState initializer. action and state from the main reduser are passed to the routingReducer to determine the new state. Get used to this routine, because you have to repeat it for each sub-state and sub-reducer you create.

Subscribing / Subscription


Remember that the default menu value is set for RoutingState? In fact, this is the current state of the application! You just never subscribed to it.

Any class can subscribe to the Store, not just Views. When a class subscribes to the Store, it receives information about all changes that occur in the current state or sub-state. You need to do this in the AppRouter so that it can change the current screen for the UINavigationController when changing the routingState.

Open the AppRouter.swift file and replace the AppRouter with the following:

 final class AppRouter { let navigationController: UINavigationController init(window: UIWindow) { navigationController = UINavigationController() window.rootViewController = navigationController // 1 store.subscribe(self) { $0.select { $0.routingState } } } // 2 fileprivate func pushViewController(identifier: String, animated: Bool) { let viewController = instantiateViewController(identifier: identifier) navigationController.pushViewController(viewController, animated: animated) } private func instantiateViewController(identifier: String) -> UIViewController { let storyboard = UIStoryboard(name: "Main", bundle: nil) return storyboard.instantiateViewController(withIdentifier: identifier) } } // MARK: - StoreSubscriber // 3 extension AppRouter: StoreSubscriber { func newState(state: RoutingState) { // 4 let shouldAnimate = navigationController.topViewController != nil // 5 pushViewController(identifier: state.navigationState.rawValue, animated: shouldAnimate) } } 

In the above code, you updated the AppRouter class and added an extension. Let's take a closer look at what we did:

  1. AppState is now subscribed to the global store. In a closure expression, select indicates that you have subscribed to changes in routingState.
  2. pushViewController will be used to create an instance and add it to the navigation stack. It uses the instantiateViewController method, which loads the controller based on the passed identifier.
  3. Create an AppRouter that matches the StoreSubscriber so that newState gets callbacks as soon as the routingState changes.
  4. You do not want to activate the root view controller, so check if the current destination is the root.
  5. When the state changes, you add a new destination to the UINavigationController using rawValue for state.navigationState, which is the name of the view controller.

AppRouter will now respond to the initial menu value and display the MenuTableViewController.

Compile and run the application to make sure the error has disappeared:

image


At the moment, MenuTableViewController is displayed, which is empty. You will display a menu in it that will redirect the user to other screens.

View


View

Anything can be a StoreSubscriber, but most of the time it will be a view that responds to state changes. Your task is to make MenuTableViewControleller display two options of a submenu (or menu). It's time for the State / Reducer procedure!

Go to MenuState.swift and create a state for the menu as follows:

 import ReSwift struct MenuState: StateType { var menuTitles: [String] init() { menuTitles = ["New Game", "Choose Category"] } } 

The MenuState structure consists of a menuTitles array, which you initialize with headings that will be displayed as a table.

In MenuReducer.swift, create a Reducer for this state with the following code:

 import ReSwift func menuReducer(action: Action, state: MenuState?) -> MenuState { return MenuState() } 

Since MenuState is static, you do not need to worry about handling state changes. Thus, it simply returns the new MenuState.

Return to AppState.swift . Add a MenuState to the end of the AppState.

 let menuState: MenuState 

It will not compile because you changed the default initializer again. In AppReducer.swift, modify the AppState initializer as follows:

 return AppState( routingState: routingReducer(action: action, state: state?.routingState), menuState: menuReducer(action: action, state: state?.menuState)) 

Now that you have a MenuState, it's time to subscribe to it and use it when rendering the menu.

Open MenuTableViewController.swift and replace the code with the following:

 import ReSwift final class MenuTableViewController: UITableViewController { // 1 var tableDataSource: TableDataSource<UITableViewCell, String>? override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // 2 store.subscribe(self) { $0.select { $0.menuState } } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // 3 store.unsubscribe(self) } } // MARK: - StoreSubscriber extension MenuTableViewController: StoreSubscriber { func newState(state: MenuState) { // 4 tableDataSource = TableDataSource(cellIdentifier:"TitleCell", models: state.menuTitles) {cell, model in cell.textLabel?.text = model cell.textLabel?.textAlignment = .center return cell } tableView.dataSource = tableDataSource tableView.reloadData() } } 

The controller is now subscribed to the MenuState changes and declaratively displays the status on the user interface.

  1. TableDataSource is included in the launch system and acts as a declarative data source for the UITableView.
  2. Subscribe to the menuState in viewWillAppear. You will now receive callbacks in newState each time the menuState changes.
  3. If necessary, unsubscribe.
  4. This is the declarative part. Here you fill in the UITableView. You can clearly see in the code how the state is converted to a view.

Note. As you can see, ReSwift supports immutability - it actively uses structures (values), not objects. It also encourages you to create declarative user interface code. What for?

The newState callback, defined in StoreSubscriber, sends state changes. You may be tempted to fix the state value in a parameter, for example,

 final class MenuTableViewController: UITableViewController { var currentMenuTitlesState: [String] ... 

But writing declarative UI code, which clearly shows how a state is transformed into a view, is more comprehensible and much easier to use. The problem with this example is that UITableView does not have a declarative API. That's why I created the TableDataSource to eliminate the difference. If you are interested in the details, take a look at TableDataSource.swift .

Compile and run the application and you will see the menu:

image

Actions / Actions


Actions


Now that you have a ready-made menu, it would be great if we could go over / open new screens with it. It's time to write your first Action.

Actions trigger a change in the Store. An action is a simple structure that can contain variables: Action parameters. Reducer processes the generated Action and changes the state of the application depending on the type of action and its parameters.

Create an action in RoutingAction.swift :

 import ReSwift struct RoutingAction: Action { let destination: RoutingDestination } 

RoutingAction changes the current destination.

Now you are going to execute RoutingAction when you select a menu item.

Open MenuTableViewController.swift and add the following to MenuTableViewController:

 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { var routeDestination: RoutingDestination = .categories switch(indexPath.row) { case 0: routeDestination = .game case 1: routeDestination = .categories default: break } store.dispatch(RoutingAction(destination: routeDestination)) } 

This will set the routeDestination value based on the row you selected. Then dispatch is used to transfer the RoutingAction to the Store.

The action is ready to be executed, but is not supported by any reducer. Open RoutingReducer.swift and replace the contents of the routingReducer with the following code, which updates the status:

 var state = state ?? RoutingState() switch action { case let routingAction as RoutingAction: state.navigationState = routingAction.destination default: break } return state 

switch checks if the action passed is an RoutingAction action. If so, use your destination to change the RoutingState, which is then returned.

Compile and run the application. Now, when you select a menu item, the corresponding view controller will appear on top of the menu controller.

image

Status update


You may have noticed an error in the current navigation implementation. When you click on the New Game menu item, the navigationState in RoutingState changes in the menu to game. But when you click the return button to return to the menu, the navigationState does not update anything!

In ReSwift, it is important to keep the state synchronized with the current state of the user interface. It's easy to forget about this when something is completely controlled by UIKit, for example, navigating a return or filling a text field by a user in a UITextField.

We can fix this if we update the navigationState when the MenuTableViewController appears.

In MenuTableViewController.swift, add this line at the bottom of viewWillAppear:

 store.dispatch(RoutingAction(destination: .menu)) 

If the user pushes the user’s button, this code will update the store.

Run the application and check the navigation again. Iiiii ... now navigation is completely faulty. Nothing is displayed.

image

Open AppRouter.swift . Remember that pushViewController is called every time a new navigationState is received. This means that you are updating the RoutingDestination menu by clicking on it again!

You must perform an additional check if the MenuViewController is not displayed. 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 the type function (of :) for the last view controller and compare it with the new one that appears when you click. If they match, then return two values.

Compile and run the application and the navigation should work fine if the menu is properly configured when you change the stack.

navigation

State updates using the user interface and dynamic checking of the current state are usually complex. This is one of the problems that you have to overcome when working with ReSwift. Fortunately, this should not happen very often.

Categories


Now you take a step forward and implement a more complex screen: CategoriesTableViewController. You must allow users to select a category of music so that they can enjoy playing in Memory while listening to their favorite artists. Start by adding states to 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 } } 

enum defines several categories of music. CategoriesState contains an array of available categories, as well as currentCategorySelected for status tracking.

In ChangeCategoryAction.swift, add the following:

 import ReSwift struct ChangeCategoryAction: Action { let categoryIndex: Int } 

This triggers an action that CategoryState can modify using categoryIndex to refer to music categories.

Now you need to implement a Reducer that accepts the ChangeCategoryAction and saves 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() } 

As in the case of other reducers, a method of full state update through actions is formed. In this case, you also save the selected category in UserDefaults . For more details on how this happens:

  1. The current category is loaded from UserDefaults if it is available and used to create a CategoriesState image if it has not yet been created.
  2. Responding to a ChangeCategoryAction by updating the state and saving a new category in UserDefaults.
  3. getCurrentCategoryStateFromUserDefaults is a helper function that loads a category from UserDefaults.
  4. saveCurrentCategoryStateToUserDefaults is a helper function that saves a category in UserDefaults.

Helper functions are also pure global functions. You can put them in a class or structure, but they must always remain clean.

Naturally, you need to update the AppState with the new state. Open AppState.swift and add the following to the end of the structure:

 let categoriesState: CategoriesState 

categoryState is now part of AppState. You have already mastered it!

Open AppReducer.swift and change the return 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 added a categoryState to the appReducer, passing action and categoriesState.

Now you need to create a categories screen, similar to MenuTableViewController. You will sign it to the Store and use a 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 is quite similar to MenuTableViewController. Here are some highlights:

  1. In viewWillAppear, subscribe to the categoriesState changes and post in viewWillDisappear.
  2. Called ChangeCategoryAction when the user selects a cell.
  3. In newState, tick a box for the currently selected category.

Everything is set up. Now you can choose a category. Compile and run the application and select the Choose Category to verify that it works correctly.

image

Asynchronous tasks


Is asynchronous programming a difficult task? Yes! But not for ReSwift.

You get images for the Memory card from the iTunes API. But first you need to create a game state, a reducer and an action related to it.

Open GameState.swift, and you will see a MemoryCard structure representing the game card. It includes imageUrl, which will be displayed on the map. isFlipped indicates whether the face of the card is visible, and isAlreadyGuessed indicates whether the card has been guessed.

You will add game status to this file. Start by importing ReSwift at the top of the file:

 import ReSwift 

Now add the following code to the end of the file:

 struct GameState: StateType { var memoryCards: [MemoryCard] // 1 var showLoading: Bool // 2 var gameFinished: Bool } 

This determines the state of the game. In addition to the contents of the array of available memoryCards, specify the parameters:

  1. Load indicator, visible or not.
  2. Game over.

Add a game Reducer to GameReducer.swift :

 import ReSwift func gameReducer(action: Action, state: GameState?) -> GameState { let state = state ?? GameState(memoryCards: [], showLoading: false, gameFinished: false) return state } 

At the moment, just creating a new GameState. You will come back to this later.

In the AppState.swift file , add a gameState to the end of the 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)) 

Note. , Action / Reducer / State. , ReSwift , . Reducers Store Actions . , .

, SetCardsAction.swift :

 import ReSwift struct SetCardsAction: Action { let cardImageUrls: [String] } 

Action URL GameState.

. FetchTunesAction.swift :

 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 iTunesAPI ( ). SetCardsAction . ReSwift : , . That's all.

fetchTunes FetchTunesAction, .

GameReducer.swift . gameReducer :

 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 

state , , :

  1. FetchTunesAction showLoading true.
  2. In SetCardsAction, cards are randomly selected and showLoading is set to false. generateNewCards can be found in the file MemoryGameLogic.swift - the file that is included in the starting project.

It's time to lay out the cards in GameViewController. Start by creating a cell.

Open the CardCollectionViewCell.swift file and add the following method to the end of the 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 } 

The configureCell method performs the following actions:
  1. Uses the excellent Kingfisher library for caching images.
  2. Shows the map if it is guessed or flipped.

. , , UICollectionView CollectionDataSource , .

GameViewController.swift UIKit :

 import ReSwift 

GameViewController 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 } 

, , StoreSubscriber. gameState viewWillAppear viewWillDisappear. viewDidLoad :

  1. fetchTunes iTunes API.
  2. Cells are configured using the CollectionDataSource, which gets the appropriate model for configureCell.

Now you need to add an extension to execute 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 activates newState to handle state changes. The data source is updated as well:

  1. The status of the load indicator is updated depending on the state.
  2. Restart the game and display a warning when the game is over.

Compile and run the game, select New Game and now you can see the maps.

Play


. , , , . — .

. FlipCardAction.swift :

 import ReSwift struct FlipCardAction: Action { let cardIndexToFlip: Int } 

FlipCardAction cardIndexToFlip GameState .

gameReducer FlipCardAction . GameReducer.swift default:

 case let flipCardAction as FlipCardAction: state.memoryCards = flipCard(index: flipCardAction.cardIndexToFlip, memoryCards: state.memoryCards) state.gameFinished = hasFinishedGame(cards: state.memoryCards) 

FlipCardAction, flipCard Memory cardIndexToFlip . hasFinishedGame , , . MemoryGameLogic.swift .

— . .

GameViewController.swift UICollectionViewDelegate. collectionView (_:didSelectItemAt:):

 store.dispatch(FlipCardAction(cardIndexToFlip: indexPath.row)) 

indexPath.row FlipCardAction.

Run the game. Now you can play. ! :]


What's next?


You can download the final version of the MemoryTunes application here .
There is still much to learn about ReSwift.


, ReSwift talk — (Benjamin Encz), ReSwift

ReSwift's . , , Christian Tietze's blog ReSwift.

- , , !

Source: https://habr.com/ru/post/349714/


All Articles