Radu Dan
MVVM Banner

Architecture Series - Model View ViewModel (MVVM)

Motivation

Before starting to develop an iOS app, we have to think of the structure of the project. We need to consider how we add those pieces of code together so they make sense later on - when we come back and revisit a part of the app - and how to form a known “language” with the other developers.

This is the second article in the series and it is all about MVVM.
We will check build times, pros and cons of each pattern, but most important we will see the actual implementation and the source code.

If you just want to see the code, feel free to skip this post. The code is available open source here.

Why an architecture pattern for your iOS app

The most important thing to consider is to have an app that can be maintainable. You know the View goes there, this View Controller should do X and not Y. And more important the others know that too.

Here are some advantages of choosing a good architecture pattern:

  • Easier to maintain
  • Easier to test the business logic
  • Develop a common language with the other teammates
  • Separate the responsibility of your entities
  • Less bugs

Defining the requirements

Given an iOS application with 6-7 screens, we are going to develop it using the most popular architecture patterns from the iOS world: MVC, MVVM, MVP, VIPER, VIP and Coordinators.

The demo app is called Football Gather and is a simple way of friends to track score of their amateur football matches.

Main features

  • Ability to add players in the app
  • You can assign teams to the players
  • Edit players
  • Set countdown timer for matches

Screen Mockups

Football Gather Mockups

Backend

The app is powered by a web app developed in Vapor web framework. You can check the app here (Vapor 3 initial article) and here (Migrating to Vapor 4).

What is MVVM

MVVM stands for Model View ViewModel, an architecture pattern that is used naturally with RxSwift where you can bind your UI elements to the Model classes through the ViewModel.

It is a newer pattern, proposed in 2005 by John Gossman and has the role of extracting the Model from the ViewController. The interaction between the ViewController and the Model is done through a new layer, called ViewModel.

Model

  • The same layer we had in MVC, and is used to encapsulate data and the business logic.

Communication

  • When something happens in the view layer, for example when the user initiates an action, it is communicated to the model through the ViewModel.
  • When the model is changed, for example when new data is made available and we need to update the UI, the model notifies the ViewModel.

View

  • View and ViewController are the layers where the visual elements reside.
  • The View contains the UI elements, such as buttons, labels, table views and the ViewController is the owner of the View.
  • This layer is the same as in MVC, but the ViewController is now part of it and will be changed to reference the ViewModel.

Communication

  • Views can’t communicate directly with the Model, everything is done through the ViewModel.

ViewModel

  • A new layer that sits between the View/View Controller and the Model.
  • Through binding, it updates the UI elements when something has changed in the Model.
  • Is a canonical representation of the View.
  • Provides interfaces to the View.

Communication

  • Can communicate with both layers, Model and View/View Controller.
  • Via binding, ViewModels trigger changes to the data of the Model layer
  • When data changes, it makes sure those changes are communicated to the user interface, updating the View (again through a binding).

Different flavours of MVVM

The way you apply MVVM depends on how you choose to implement the binding:

  • Using a 3rd party, such as RxSwift.
  • KVO - Key Value Observing.
  • Manually.

In our demo app we will explore the manual approach.

How and when to use MVVM

When you see the ViewController does a lot of stuff and might turn to be massive, you can start looking at different patterns, such as MVVM.

Advantages:

  • Slims down the ViewController.
  • Easier to test the business logic, because you now have a dedicated layer that handles data.
  • Provides a better separation of concerns

Disadvantages:

  • Same as in MVC, if is not applied correctly and you are not careful of SRP (Single Responsibility Principle), it can turn out into a Massive ViewModel.
  • Can be overkill and too complex for small projects (for example, in a Hackathon app/prototype).
  • Adopting a 3rd party increases the app size and can impact the performance.
  • Doesn’t feel natural to iOS app development with UIKit. On the other hand, for apps developed with SwiftUI makes perfect sense.

Below you can find a collection of links that tell you more about this code architecture pattern:

Applying to our code

This is pretty straightforward. We go into each ViewController and extract the business logic into a new layer (ViewModel).

Decoupling LoginViewController from business logic

Transformations:

  • viewModel - A new layer that handles the view state and the model updates.
  • The services are now part of the ViewModel layer.

In viewDidLoad method, we call configureRememberMe() function. Here, we can observe how the View asks the ViewModel for the values of the "Remember Me" UISwitch and the username:


    private func configureRememberMe() {
          rememberMeSwitch.isOn = viewModel.rememberUsername // [1] set switch on / off based on the preferred mode of the user


          if viewModel.rememberUsername { // [2] set the stored username to the textfield
              usernameTextField.text = viewModel.username
          }
     }

For the Login and Register actions, we tell the ViewModel to handle the service requests. We use closures for updating the UI once the server API call finished.


    @IBAction func login(_ sender: Any) {
        guard let userText = usernameTextField.text, userText.isEmpty == false,
            let passwordText = passwordTextField.text, passwordText.isEmpty == false else {
                AlertHelper.present(in: self, title: "Error", message: "Both fields are mandatory.")
                return
        } // [1] Extract the username and the password from the fields


        showLoadingView() // [2] Display a loading spinner


        viewModel.performLogin(withUsername: userText, andPassword: passwordText) { [weak self] error in // [3] Tell the view model to Login
            DispatchQueue.main.async {
                self?.hideLoadingView() // [4] Service finished, update UI
                self?.handleServiceResponse(error: error)
            }
        }
    }


    private func handleServiceResponse(error: Error?) {
        if let error = error {
            AlertHelper.present(in: self, title: "Error", message: String(describing: error)) // [5] Handle the error
        } else {
            handleSuccessResponse()
        }
    }


    // [6] We navigate to the next screen, PlayerList
    private func handleSuccessResponse() {
        storeUsernameAndRememberMe()
        performSegue(withIdentifier: SegueIdentifier.playerList.rawValue, sender: nil)
    }


    // [7] Storing the details is done in the ViewModel
    private func storeUsernameAndRememberMe() {
        viewModel.setRememberUsername(rememberMeSwitch.isOn)


        if rememberMeSwitch.isOn {
            viewModel.setUsername(usernameTextField.text)
        } else {
            viewModel.setUsername(nil)
        }
    }

The LoginViewModel is defined by the following properties:


    struct LoginViewModel {
        private let loginService: LoginService
        private let usersService: StandardNetworkService
        private let userDefaults: FootballGatherUserDefaults
        private let keychain: FootbalGatherKeychain
    }

We have the services that were passed from LoginViewController (LoginService, StandardNetworkService used for registering the user and the Storage facilitators - UserDefaults and Keychain wrappers).
All of them are injected through the initializer:


    init(loginService: LoginService = LoginService(),
         usersService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/users"),
         userDefaults: FootballGatherUserDefaults = .shared,
         keychain: FootbalGatherKeychain = .shared) {
        self.loginService = loginService
        self.usersService = usersService
        self.userDefaults = userDefaults
        self.keychain = keychain
    }

This comes in handy for unit testing if we want to use our own Mocked services or storages.
The Public API is clean and simple:


    // [1] Checks in the UserDefaults storage if we have set Remember Me option
    var rememberUsername: Bool {
        return userDefaults.rememberUsername ?? true
    }
    
    // [2] The username that was stored in case rememberUsername is true
    var username: String? {
        return keychain.username
    }
    
    // [3] Stores the RememberMe boolen property
    func setRememberUsername(_ value: Bool) {
        userDefaults.rememberUsername = value
    }


    // [4] Store the username in the Keychain
    func setUsername(_ username: String?) {
        keychain.username = username
    }

And the two server API calls:


    func performLogin(withUsername username: String, andPassword password: String, completion: @escaping (Error?) -> ()) {
        let requestModel = UserRequestModel(username: username, password: password) // [1] Create the request model
        loginService.login(user: requestModel) { result in
            switch result {
            case .failure(let error):
                completion(error)
                
            case .success(_):
                completion(nil)
            }
        }
    }
    
    func performRegister(withUsername username: String, andPassword password: String, completion: @escaping (Error?) -> ()) {
        guard let hashedPasssword = Crypto.hash(message: password) else { // [1] Make sure we crash in case there are invalid passwords that could not be hashed
            fatalError("Unable to hash password")
        }
        
        let requestModel = UserRequestModel(username: username, password: hashedPasssword) // [2] Create the request model (same model as we have for Login)
        usersService.create(requestModel) { result in
            switch result {
            case .failure(let error):
                completion(error)
                
            case .success(let resourceId):
                print("Created user: \(resourceId)")
                completion(nil)
            }
        }
    }

As you can see, the code looks much cleaner by separating the Model from the ViewController. Now, the View / ViewController asks the ViewModel for what it needs.

PlayerListViewController is much bigger, harder to refactor and to extract the business logic than the LoginViewController.
First, we want to leave just the outlets and all UIView objects we require for this class.
In viewDidLoad, we will do the setup and configuration of the initial state of the views, setting the view model delegate and trigger the player load through the view model.

Loading players:


    private func loadPlayers() {
        view.isUserInteractionEnabled = false


        viewModel.fetchPlayers { [weak self] error in // [1] Pass the responsibility tot the ViewModel
            DispatchQueue.main.async {
                self?.view.isUserInteractionEnabled = true


                if let error = error { // [2] Handle the response
                    self?.handleServiceFailures(withError: error)
                } else {
                    self?.handleLoadPlayersSuccessfulResponse()
                }
            }
        }
    }

The response handling is similar as we have in LoginViewController:


    private func handleServiceFailures(withError error: Error) {
        AlertHelper.present(in: self, title: "Error", message: String(describing: error)) // [1] Present an alert to the user
    }


    private func handleLoadPlayersSuccessfulResponse() {
        if viewModel.playersCollectionIsEmpty {
            showEmptyView() // [2] The players array is empty
        } else {
            hideEmptyView() // [3] No need to display the emptyView
        }


        playerTableView.reloadData() // [4] Reload the players table view
    }

To display the model properties in the table view’s cell and configure it, we ask the ViewModel to give us the primitives and then we set them to the cell’s properties:


    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.numberOfRows // [1] The number of players in the array
    }


    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell: PlayerTableViewCell = tableView.dequeueReusableCell(withIdentifier: "PlayerTableViewCell") as? PlayerTableViewCell else {
            return UITableViewCell()
        }


        if viewModel.isInListViewMode { // [2] When we are in the default view mode, showing the players
            viewModel.clearSelectedPlayerIfNeeded(at: indexPath)
            cell.setupDefaultView()
        } else {
            cell.setupSelectionView() // [3] When we are in the view mode for selecting the players for the gather
        }


        // [4] Display the model properties in the cell’s properties
        cell.nameLabel.text = viewModel.playerNameDescription(at: indexPath)
        cell.positionLabel.text = viewModel.playerPositionDescription(at: indexPath)
        cell.skillLabel.text = viewModel.playerSkillDescription(at: indexPath)
        cell.playerIsSelected = viewModel.playerIsSelected(at: indexPath)


        return cell
    }

To delete a player, we do the following:


    func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        return viewModel.isInListViewMode // [1] Only in list view mode we can edit rows
    }


    // [2] Present a confirmation alert
    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        guard editingStyle == .delete else { return }


        let alertController = UIAlertController(title: "Delete player", message: "Are you sure you want to delete the selected player?", preferredStyle: .alert)
        let confirmAction = UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
            self?.handleDeletePlayerConfirmation(forRowAt: indexPath)
        }
        alertController.addAction(confirmAction)


        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
        alertController.addAction(cancelAction)


        present(alertController, animated: true, completion: nil)
    }


    private func handleDeletePlayerConfirmation(forRowAt indexPath: IndexPath) {
        requestDeletePlayer(at: indexPath) { [weak self] result in // [3] Perform the server call
            guard result, let self = self else { return }


            self.playerTableView.beginUpdates() // [4] In case the service succeeded, delete locally the player
            self.viewModel.deleteLocallyPlayer(at: indexPath)
            self.playerTableView.deleteRows(at: [indexPath], with: .fade)
            self.playerTableView.endUpdates()


            if self.viewModel.playersCollectionIsEmpty {
                self.showEmptyView() // [5] Check if we need to display the empty view in case we haven’t any players left
            }
        }
    }


    private func requestDeletePlayer(at indexPath: IndexPath, completion: @escaping (Bool) -> Void) {
        viewModel.requestDeletePlayer(at: indexPath) { [weak self] error in // [6] Tells the ViewModel to perform the API call for deleting the player
            DispatchQueue.main.async {
                self?.hideLoadingView()


                if let error = error {
                    self?.handleServiceFailures(withError: error)
                    completion(false)
                } else {
                    completion(true)
                }
            }
        }
    }

The navigation to Confirm / Detail and Add screens is done through performSegue. We choose PlayerListViewModel to be responsible to create the next screens view models and inject them in prepareForSegue.
This is not the best approach, because we violate the SRP principle, but we will see in the Coordinator article how we can solve this problem.


    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        switch segue.identifier {
        case SegueIdentifier.confirmPlayers.rawValue:
            if let confirmPlayersViewController = segue.destination as? ConfirmPlayersViewController {
                confirmPlayersViewController.viewModel = viewModel.makeConfirmPlayersViewModel()
            }


        case SegueIdentifier.playerDetails.rawValue:
            if let playerDetailsViewController = segue.destination as? PlayerDetailViewController, let player = viewModel.selectedPlayerForDetails {
                playerDetailsViewController.delegate = self
                playerDetailsViewController.viewModel = PlayerDetailViewModel(player: player)
            }


        case SegueIdentifier.addPlayer.rawValue:
            (segue.destination as? PlayerAddViewController)?.delegate = self


        default:
            break
        }
    }

PlayerListViewModel is rather big and contains a lot of properties and methods that are exposed to the View, all of them mandatory.
For the sake of the demo, we will leave it like it is and let the desired refactoring as an exercise to the readers. You could:

  • separate PlayerListViewController in multiple ViewControllers / ViewModels, all handled by a parent or container view controller.
  • split PlayerListViewModel in different components: by edit / list functions, service component, player selection.

The ViewState (player selection and list modes) is implemented through Factory pattern:


    final class PlayerListViewModel {
        
        private var viewState: ViewState
        private var viewStateDetails: LoginViewStateDetails {
            return ViewStateDetailsFactory.makeViewStateDetails(from: viewState)
        }


    }


    extension PlayerListViewModel {
        enum ViewState {
            case list
            case selection
            
            mutating func toggle() {
                self = self == .list ? .selection : .list
            }
        }
    }

And the concrete classes for list and selection:


    // [1] Abstractization
    protocol LoginViewStateDetails {
        var barButtonItemTitle: String { get }
        var actionButtonIsEnabled: Bool { get }
        var actionButtonTitle: String { get }
        var segueIdentifier: String { get }
    }


    fileprivate extension PlayerListViewModel {
        
        struct ListViewStateDetails: LoginViewStateDetails {
            var barButtonItemTitle: String {
                return "Select"
            }
            
            var actionButtonIsEnabled: Bool {
                return false
            }
            
            var segueIdentifier: String {
                return SegueIdentifier.addPlayer.rawValue // [2] Binded to the bottom button action
            }
            
            var actionButtonTitle: String {
                return "Add player"
            }
        }


        struct SelectionViewStateDetails: LoginViewStateDetails {
            var barButtonItemTitle: String {
                return "Cancel"
            }
            
            var actionButtonIsEnabled: Bool {
                return true
            }
            
            var segueIdentifier: String {
                return SegueIdentifier.confirmPlayers.rawValue
            }
            
            var actionButtonTitle: String {
                return "Confirm players"
            }
        }


        enum ViewStateDetailsFactory {
            static func makeViewStateDetails(from viewState: ViewState) -> LoginViewStateDetails {
                switch viewState {
                case .list:
                    return ListViewStateDetails()
                    
                case .selection:
                    return SelectionViewStateDetails()
                }
            }
        }
    }

The service methods are easy to read:


    func fetchPlayers(completion: @escaping (Error?) -> ()) {
        playersService.get { [weak self] (result: Result<[PlayerResponseModel], Error>) in
            switch result {
            case .failure(let error):
                completion(error)
                
            case .success(let players):
                self?.players = players
                completion(nil)
            }
        }
    }
    
    func requestDeletePlayer(at indexPath: IndexPath, completion: @escaping (Error?) -> Void) {
        let player = players[indexPath.row]
        var service = playersService
        
        service.delete(withID: ResourceID.integer(player.id)) { result in
            switch result {
            case .failure(let error):
                completion(error)
                
            case .success(_):
                completion(nil)
            }
        }
    }

PlayerAddViewController - defines the Add players screen.

Once a player was created, we use delegation pattern to notify Player Add screen and pop the view controller. The service call resides in the view model.


    @objc private func doneAction(sender: UIBarButtonItem) {
        guard let playerName = playerNameTextField.text else { return }


        showLoadingView() // [1] Present the loading indicator
        viewModel.requestCreatePlayer(name: playerName) { [weak self] playerWasCreated in // [2] Check if the service finished sucessfully
            DispatchQueue.main.async {
                self?.hideLoadingView()


                if !playerWasCreated {
                    self?.handleServiceFailure()
                } else {
                    self?.handleServiceSuccess()
                }
            }
        }
    }


    private func handleServiceFailure() {
        AlertHelper.present(in: self, title: "Error update", message: "Unable to create player. Please try again.")
    }


    private func handleServiceSuccess() {
        delegate?.playerWasAdded()
        navigationController?.popViewController(animated: true)
    }

The ViewModel entity is presented below:


    struct PlayerAddViewModel {
          private let service: StandardNetworkService // [1] Used to request a player creation
          
          init(service: StandardNetworkService = StandardNetworkService(resourcePath: "/api/players", authenticated: true)) {
              self.service = service
          }
          
          // [2] The title of the view controller
          var title: String {
              return "Add Player"
          }
          
          // [3] Service API call
          func requestCreatePlayer(name: String, completion: @escaping (Bool) -> Void) {
              let player = PlayerCreateModel(name: name)
              service.create(player) { result in
                  if case .success(_) = result {
                      completion(true)
                  } else {
                      completion(false)
                  }
              }
          }
          
          // [4] Defines the state of the done buttokn
          func doneButtonIsEnabled(forText text: String?) -> Bool {
              return text?.isEmpty == false
          }
    }

PlayerDetailViewController defines the Details screen

The view model is created and passed in the PlayerListViewController's method, prepareForSegue.
We use the same approach when navigating to PlayerEditViewController:


    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        guard segue.identifier == SegueIdentifier.editPlayer.rawValue,
            let destinationViewController = segue.destination as? PlayerEditViewController else {
                return
        }


        destinationViewController.viewModel = viewModel.makeEditViewModel()
        destinationViewController.delegate = self
    }

Displaying the player’s details is done similar as we have in PlayerList screen: the View asks the ViewModel for the properties and sets the labels’ text.


    extension PlayerDetailViewController: UITableViewDelegate, UITableViewDataSource {
        func numberOfSections(in tableView: UITableView) -> Int {
            return viewModel.numberOfSections
        }


        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return viewModel.numberOfRowsInSection(section)
        }


        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: "PlayerDetailTableViewCell") as? PlayerDetailTableViewCell else {
                return UITableViewCell()
            }


            cell.leftLabel.text = viewModel.rowTitleDescription(for: indexPath)
            cell.rightLabel.text = viewModel.rowValueDescription(for: indexPath)


            return cell
        }


        func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
            return viewModel.titleForHeaderInSection(section)
        }


        func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
            viewModel.selectPlayerRow(at: indexPath)
            performSegue(withIdentifier: SegueIdentifier.editPlayer.rawValue, sender: nil)
        }
    }

When the user finished editing a player in the presented screen, didFinishEditing is called:


    extension PlayerDetailViewController: PlayerEditViewControllerDelegate {
        func didFinishEditing(player: PlayerResponseModel) {
            setupTitle() // [1] If the player’s name changed, reload the title
            viewModel.updatePlayer(player) // [2] Update the player local model
            viewModel.reloadSections() // [3] Reconstruct the sections model
            reloadData() // [4] Reload data from table view
            delegate?.didEdit(player: player) // [5] Tell PlayerList that the player was updated
        }
    }

PlayerDetailViewModel has the following properties:


    final class PlayerDetailViewModel {
        
        // MARK: - Properties
        private(set) var player: PlayerResponseModel // [1] player that is viewable in the screen
        private lazy var sections = makeSections() // [2] all data is displayed in multiple sections
        private(set) var selectedPlayerRow: PlayerRow? // [3] used for holding the tapped player row information
        
        // MARK: - Public API
        init(player: PlayerResponseModel) {
            self.player = player
        }
    }

PlayerEditViewController

The segue to display the Edit screen is triggered from PlayerDetails screen. This is the place where you can edit the players details.
The ViewModel is passed from PlayerDetailsViewController.
Following the same approach, we moved all server API interaction, plus the model handling, in the ViewModel.

The edit text field is configured based on the ViewModel's properties:


    private func setupPlayerEditTextField() {
        playerEditTextField.placeholder = viewModel.playerRowValue
        playerEditTextField.text = viewModel.playerRowValue
        playerEditTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged)
        playerEditTextField.isHidden = viewModel.isSelectionViewType
    }

When we are done with editing the player’s information, we ask the view model to perform the server updates and after it’s done, we handle the success or failure responses.
In case we have a failure, we inform the user, and in case the server call was successful, we notify the delegate and pop this view controller from the view controllers stack.


    @objc private func doneAction(sender: UIBarButtonItem) {
        guard viewModel.shouldUpdatePlayer(inputFieldValue: playerEditTextField.text) else { return }


        showLoadingView()


        viewModel.updatePlayerBasedOnViewType(inputFieldValue: playerEditTextField.text) { [weak self] updated in
            DispatchQueue.main.async {
                self?.hideLoadingView()


                if updated {
                    self?.handleSuccessfulPlayerUpdate()
                } else {
                    self?.handleServiceError()
                }
            }
        }
    }


    private func handleSuccessfulPlayerUpdate() {
        delegate?.didFinishEditing(player: viewModel.editablePlayer)
        navigationController?.popViewController(animated: true)
    }


    private func handleServiceError() {
        AlertHelper.present(in: self, title: "Error update", message: "Unable to update player. Please try again.")
    }

PlayerEditViewModel is similar with the rest, most important methods would be the player update ones:


    // [1] Checks if the entered value in the field is different from the old value
    func shouldUpdatePlayer(inputFieldValue: String?) -> Bool {
        if isSelectionViewType {
            return newValueIsDifferentFromOldValue(newFieldValue: selectedItemValue)
        }
        
        return newValueIsDifferentFromOldValue(newFieldValue: inputFieldValue)
    }
    
    private func newValueIsDifferentFromOldValue(newFieldValue: String?) -> Bool {
        guard let newFieldValue = newFieldValue else { return false }
        
        return playerEditModel.playerRow.value.lowercased() != newFieldValue.lowercased()
    }
    
    // [2] There are two different ways to update player information.
    // One is through the input / textField where you can type, for example the name or age of the player
    // and the other one is through selection where you can choose a different option (applied to player’s position or skill).
    private var selectedItemValue: String? {
        guard let playerItemsEditModel = playerItemsEditModel else { return nil}
        
        return playerItemsEditModel.items[playerItemsEditModel.selectedItemIndex]
    }
    
    // [3] Decides what needs to be updated (if inputFieldValue is nil, than it will update the player through selection mode).
    func updatePlayerBasedOnViewType(inputFieldValue: String?, completion: @escaping (Bool) -> ()) {
        if isSelectionViewType {
            updatePlayer(newFieldValue: selectedItemValue, completion: completion)
        } else {
            updatePlayer(newFieldValue: inputFieldValue, completion: completion)
        }
    }
    
    private func updatePlayer(newFieldValue: String?, completion: @escaping (Bool) -> ()) {
        guard let newFieldValue = newFieldValue else {
            completion(false)
            return
        }
        
        playerEditModel.player.update(usingField: playerEditModel.playerRow.editableField, value: newFieldValue)
        requestUpdatePlayer(completion: completion)
    }


    // [4] Perfoms the player service update call
    private func requestUpdatePlayer(completion: @escaping (Bool) -> ()) {
        let player = playerEditModel.player
        service.update(PlayerCreateModel(player), resourceID: ResourceID.integer(player.id)) { [weak self] result in
            if case .success(let updated) = result {
                self?.playerEditModel.player = player
                completion(updated)
            } else {
                completion(false)
            }
        }
    }

ConfirmPlayersViewController

Before reaching Gather screen, we have to confirm the selected players. This screen is defined by ConfirmPlayersViewController.
In viewDidLoad we setup the UI elements, such as the table view and configure the start gather button:


    func setupViews() {
        playerTableView.isEditing = viewModel.playerTableViewIsEditing
        configureStartGatherButton()
    }

The server API call is presented below:


    @IBAction private func startGather(_ sender: Any) {
        showLoadingView()


        viewModel.startGather { [weak self] result in
            DispatchQueue.main.async {
                self?.hideLoadingView()


                if !result {
                    self?.handleServiceFailure()
                } else {
                    self?.performSegue(withIdentifier: SegueIdentifier.gather.rawValue, sender: nil)
                }
            }
        }
    }
    private func handleServiceFailure() {
        AlertHelper.present(in: self, title: "Error", message: "Unable to create gather.")
    }

And the TableView Delegate and DataSource:


    extension ConfirmPlayersViewController: UITableViewDelegate, UITableViewDataSource {
        func numberOfSections(in tableView: UITableView) -> Int {
            return viewModel.numberOfSections
        }


        func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
            return viewModel.titleForHeaderInSection(section)
        }


        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return viewModel.numberOfRowsInSection(section)
        }


        func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
            return .none
        }


        func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
            return false
        }


        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: "PlayerChooseTableViewCellId") else {
                return UITableViewCell()
            }


            cell.textLabel?.text = viewModel.rowTitle(at: indexPath)
            cell.detailTextLabel?.text = viewModel.rowDescription(at: indexPath)


            return cell
        }


        func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
            viewModel.moveRowAt(sourceIndexPath: sourceIndexPath, to: destinationIndexPath)
            configureStartGatherButton()
        }
    }

ConfirmPlayersViewModel contains the playersDictionary with the selected players and their teams, the services needed to add players to a gather and to start the gather, the gatherUUID which is defined after a gather is created on the server and a dispatchGroup to orchestrate the multiple server calls.


    final class ConfirmPlayersViewModel {
        
        // MARK: - Properties
        private var playersDictionary: [TeamSection: [PlayerResponseModel]]
        private var addPlayerService: AddPlayerToGatherService
        private let gatherService: StandardNetworkService
        
        private let dispatchGroup = DispatchGroup()
        private var gatherUUID: UUID?
        
        // MARK: - Public API
        init(playersDictionary: [TeamSection: [PlayerResponseModel]] = [:],
             addPlayerService: AddPlayerToGatherService = AddPlayerToGatherService(),
             gatherService: StandardNetworkService = StandardNetworkService(resourcePath: "/api/gathers", authenticated: true)) {
            self.playersDictionary = playersDictionary
            self.addPlayerService = addPlayerService
            self.gatherService = gatherService
        }
    }

The most complex thing from this class is the server API interaction when starting a gather:


    // [1] Main function for starting a taher
    func startGather(completion: @escaping (Bool) -> ()) {
        createGather { [weak self] uuid in
            guard let gatherUUID = uuid else {
                completion(false)
                return
            }
            
            // [2] The gather was created, now is time to put the selected players in it.
            self?.gatherUUID = gatherUUID
            self?.addPlayersToGather(havingUUID: gatherUUID, completion: completion)
        }
    }
    
    private func createGather(completion: @escaping (UUID?) -> Void) {
        gatherService.create(GatherCreateModel()) { result in
            if case let .success(ResourceID.uuid(gatherUUID)) = result {
                completion(gatherUUID)
            } else {
                completion(nil)
            }
        }
    }
    
    // [3] Use the dispatch group to add the players to the gather
    private func addPlayersToGather(havingUUID gatherUUID: UUID, completion: @escaping (Bool) -> ()) {
        var serviceFailed = false
        
        playerTeamArray.forEach { playerTeam in
            dispatchGroup.enter()
            
            self.addPlayer(playerTeam.player, toGatherHavingUUID: gatherUUID, team: playerTeam.team) { [weak self] playerWasAdded in
                if !playerWasAdded {
                    serviceFailed = true
                }
                
                self?.dispatchGroup.leave()
            }
        }
        
        // [4] The for loop finished, it’s time to call the completion closure.
        dispatchGroup.notify(queue: DispatchQueue.main) {
            completion(serviceFailed)
        }
    }
    
    // [5] Maps the players to the PlayerTeamModel
    private var playerTeamArray: [PlayerTeamModel] {
        var players: [PlayerTeamModel] = []
        players += self.playersDictionary
            .filter { $0.key == .teamA }
            .flatMap { $0.value }
            .map { PlayerTeamModel(team: .teamA, player: $0) }
        
        players += self.playersDictionary
            .filter { $0.key == .teamB }
            .flatMap { $0.value }
            .map { PlayerTeamModel(team: .teamB, player: $0) }
        
        return players
    }
    
    // [6] This is the service API call to add a player to a gather.
    private func addPlayer(_ player: PlayerResponseModel,
                           toGatherHavingUUID gatherUUID: UUID,
                           team: TeamSection,
                           completion: @escaping (Bool) -> Void) {
        addPlayerService.addPlayer(
            havingServerId: player.id,
            toGatherWithId: gatherUUID,
            team: PlayerGatherTeam(team: team.headerTitle)) { result in
                if case let .success(resultValue) = result {
                    completion(resultValue)
                } else {
                    completion(false)
                }
        }
    }

GatherViewController

Finally, we have GatherViewController, belonging to the most important screen from FootballGather.
We manage to clean the properties and left the IBOutlets, plus the loading view and the view model:


    final class GatherViewController: UIViewController, Loadable {
        // MARK: - Properties
        @IBOutlet weak var playerTableView: UITableView!
        @IBOutlet weak var scoreLabelView: ScoreLabelView!
        @IBOutlet weak var scoreStepper: ScoreStepper!
        @IBOutlet weak var timerLabel: UILabel!
        @IBOutlet weak var timerView: UIView!
        @IBOutlet weak var timePickerView: UIPickerView!
        @IBOutlet weak var actionTimerButton: UIButton!


        lazy var loadingView = LoadingView.initToView(self.view)


        var viewModel: GatherViewModel!
    }

In viewDidLoad, we setup and configure the views:

    
    override func viewDidLoad() {
        super.viewDidLoad()


        setupViewModel()
        setupTitle()
        configureSelectedTime()
        hideTimerView()
        configureTimePickerView()
        configureActionTimerButton()
        setupScoreStepper()
        reloadData()
    }


    private func setupTitle() {
        title = viewModel.title
    }


    private func setupViewModel() {
        viewModel.delegate = self
    }


    private func configureSelectedTime() {
        timerLabel?.text = viewModel.formattedCountdownTimerLabelText
    }


    private func configureActionTimerButton() {
        actionTimerButton.setTitle(viewModel.formattedActionTitleText, for: .normal)
    }


    private func hideTimerView() {
        timerView.isHidden = true
    }


    private func showTimerView() {
        timerView.isHidden = false
    }


    private func setupScoreStepper() {
        scoreStepper.delegate = self
    }


    private func reloadData() {
        timePickerView.reloadAllComponents()
        playerTableView.reloadData()
    }

The timer related functions are looking neat:


    @IBAction private func setTimer(_ sender: Any) {
        configureTimePickerView()
        showTimerView()
    }


    @IBAction private func cancelTimer(_ sender: Any) {
        viewModel.stopTimer()
        viewModel.resetTimer()
        configureSelectedTime()
        configureActionTimerButton()
        hideTimerView()
    }


    @IBAction private func actionTimer(_ sender: Any) {
        viewModel.toggleTimer()
        configureActionTimerButton()
    }


    @IBAction private func timerCancel(_ sender: Any) {
        hideTimerView()
    }


    @IBAction private func timerDone(_ sender: Any) {
        viewModel.stopTimer()
        viewModel.setTimerMinutes(selectedMinutesRow)
        viewModel.setTimerSeconds(selectedSecondsRow)
        configureSelectedTime()
        configureActionTimerButton()
        hideTimerView()
    }


    private var selectedMinutesRow: Int { timePickerView.selectedRow(inComponent: viewModel.minutesComponent) }
    private var selectedSecondsRow: Int { timePickerView.selectedRow(inComponent: viewModel.secondsComponent) }

And the endGather API interaction:


    @IBAction private func endGather(_ sender: Any) {
        let alertController = UIAlertController(title: "End Gather", message: "Are you sure you want to end the gather?", preferredStyle: .alert)
        let confirmAction = UIAlertAction(title: "Yes", style: .default) { [weak self] _ in
            self?.endGather()
        }
        alertController.addAction(confirmAction)


        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
        alertController.addAction(cancelAction)


        present(alertController, animated: true, completion: nil)
    }


    private func endGather() {
        guard let scoreTeamAString = scoreLabelView.teamAScoreLabel.text,
            let scoreTeamBString = scoreLabelView.teamBScoreLabel.text else {
                return
        }


        showLoadingView()


        viewModel.endGather(teamAScoreLabelText: scoreTeamAString, teamBScoreLabelText: scoreTeamBString) { [weak self] updated in
            DispatchQueue.main.async {
                self?.hideLoadingView()


                if !updated {
                    self?.handleServiceFailure()
                } else {
                    self?.handleServiceSuccess()
                }
            }
        }
    }
    
    private func handleServiceFailure() {
        AlertHelper.present(in: self, title: "Error update", message: "Unable to update gather. Please try again.")
    }


    private func handleServiceSuccess() {
        guard let playerListTogglable = navigationController?.viewControllers.first(where: { $0 is PlayerListTogglable }) as? PlayerListTogglable else {
            return
        }


        playerListTogglable.toggleViewState()


        if let playerListViewController = playerListTogglable as? UIViewController {
            navigationController?.popToViewController(playerListViewController, animated: true)
        }
    }

The table view's DataSource and Delegate are looking great as well, clean and simple:


    // MARK: - UITableViewDelegate | UITableViewDataSource
    extension GatherViewController: UITableViewDelegate, UITableViewDataSource {
        func numberOfSections(in tableView: UITableView) -> Int {
            viewModel.numberOfSections
        }


        func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
            viewModel.titleForHeaderInSection(section)
        }


        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            viewModel.numberOfRowsInSection(section)
        }


        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            guard let cell = tableView.dequeueReusableCell(withIdentifier: "GatherCellId") else {
                return UITableViewCell()
            }


            let rowDescription = viewModel.rowDescription(at: indexPath)


            cell.textLabel?.text = rowDescription.title
            cell.detailTextLabel?.text = rowDescription.details


            return cell
        }
    }

And the rest of the methods:


    // MARK: - ScoreStepperDelegate
    extension GatherViewController: ScoreStepperDelegate {
        func stepper(_ stepper: UIStepper, didChangeValueForTeam team: TeamSection, newValue: Double) {
            if viewModel.shouldUpdateTeamALabel(section: team) {
                scoreLabelView.teamAScoreLabel.text = viewModel.formatStepperValue(newValue)
            } else if viewModel.shouldUpdateTeamBLabel(section: team) {
                scoreLabelView.teamBScoreLabel.text = viewModel.formatStepperValue(newValue)
            }
        }
    }


    // MARK: - UIPickerViewDataSource
    extension GatherViewController: UIPickerViewDataSource, UIPickerViewDelegate {
        func numberOfComponents(in pickerView: UIPickerView) -> Int {
            viewModel.numberOfPickerComponents
        }


        func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
            viewModel.numberOfRowsInPickerComponent(component)
        }


        func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
            viewModel.titleForPickerRow(row, forComponent: component)
        }
    }


    // MARK: - GatherViewModelDelegate
    extension GatherViewController: GatherViewModelDelegate {
        func didUpdateGatherTime() {
            configureSelectedTime()
        }
    }

Cleaning the ViewController came with some downsides in the ViewModel class. It has a lot of methods and the class is big (around 200 lines of code).

We decided to move out the Timer interaction into a new struct, called GatherTimeHandler.
In this struct, we expose selectedTime which is set from outside of the class, and has two more variables: the timer and a state variable (can be stopped, running or paused).
The public API has methods such as stop, reset and toggle timer, as well as decrementTime:


    mutating func decrementTime() {
        if selectedTime.seconds == 0 {
            decrementMinutes()
        } else {
            decrementSeconds()
        }
        
        if selectedTimeIsZero {
            stopTimer()
        }
    }
    
    private mutating func decrementMinutes() {
        selectedTime.minutes -= 1
        selectedTime.seconds = 59
    }
    
    private mutating func decrementSeconds() {
        selectedTime.seconds -= 1
    }


    private var selectedTimeIsZero: Bool {
        return selectedTime.seconds == 0 && selectedTime.minutes == 0
    }

Overall, this is looking much better from the first iteration where we implemented the app via MVC.

Testing our business logic

The most important part is the ViewModel. Here, we have implemented the business logic.

Testing the title:


    func testTitle_whenViewModelIsAllocated_isNotEmpty() {
        // given
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let sut = GatherViewModel(gatherModel: mockGatherModel)
        
        // when
        let title = sut.title
        
        // then
        XCTAssertFalse(title.isEmpty)
    }

Testing the formatted countdown timer label text:


    func testFormattedCountdownTimerLabelText_whenViewModelIsAllocated_returnsDefaultTime() {
        // given
        let gatherTime = GatherTime.defaultTime
        // Define the expected values, format should be 00:00.
        let expectedFormattedMinutes = gatherTime.minutes < 10 ? "0\(gatherTime.minutes)" : "\(gatherTime.minutes)"
        let expectedFormattedSeconds = gatherTime.seconds < 10 ? "0\(gatherTime.seconds)" : "\(gatherTime.seconds)"
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let sut = GatherViewModel(gatherModel: mockGatherModel)
        
        // when
        let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
        
        // then
        XCTAssertEqual(formattedCountdownTimerLabelText, "\(expectedFormattedMinutes):\(expectedFormattedSeconds)")
    }
    
    func testFormattedCountdownTimerLabelText_whenTimeIsZero_returnsZeroSecondsZeroMinutes() {
        // given
        let mockGatherTime = GatherTime(minutes: 0, seconds: 0)
        let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
        
        // when
        let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
        
        // then
        XCTAssertEqual(formattedCountdownTimerLabelText, "00:00")
    }
    
    func testFormattedCountdownTimerLabelText_whenTimeHasMinutesAndZeroSeconds_returnsMinutesAndZeroSeconds() {
        // given
        let mockGatherTime = GatherTime(minutes: 10, seconds: 0)
        let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
        
        // when
        let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
        
        // then
        XCTAssertEqual(formattedCountdownTimerLabelText, "10:00")
    }
    
    func testFormattedCountdownTimerLabelText_whenTimeHasSecondsAndZeroMinutes_returnsSecondsAndZeroMinutes() {
        // given
        let mockGatherTime = GatherTime(minutes: 0, seconds: 10)
        let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
        
        // when
        let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText
        
        // then
        XCTAssertEqual(formattedCountdownTimerLabelText, "00:10")
    }

Testing the action title text, that should be Start, Resume or Pause.


    // We set the state to be initially .paused
    func testFormattedActionTitleText_whenStateIsPaused_returnsResume() {
        // given
        let mockGatherTimeHandler = GatherTimeHandler(state: .paused)
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
        
        // when
        let formattedActionTitleText = sut.formattedActionTitleText
        
        // then
        XCTAssertEqual(formattedActionTitleText, "Resume")
    }

We follow the same approach for Pause and Start:


    func testFormattedActionTitleText_whenStateIsRunning_returnsPause() {
        // given
        let mockGatherTimeHandler = GatherTimeHandler(state: .running)
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
        
        // when
        let formattedActionTitleText = sut.formattedActionTitleText
        
        // then
        XCTAssertEqual(formattedActionTitleText, "Pause")
    }
    
    func testFormattedActionTitleText_whenStateIsStopped_returnsStart() {
        // given
        let mockGatherTimeHandler = GatherTimeHandler(state: .stopped)
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
        
        // when
        let formattedActionTitleText = sut.formattedActionTitleText
        
        // then
        XCTAssertEqual(formattedActionTitleText, "Start")
    }

For testing the stopTimer function, we mock the system to be in a running state


    func testStopTimer_whenStateIsRunning_updatesStateToStopped() {
        // given
        let mockGatherTimeHandler = GatherTimeHandler(state: .running)
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
        
        // when
        sut.stopTimer()
        
        // then
        let formattedActionTitleText = sut.formattedActionTitleText
        XCTAssertEqual(formattedActionTitleText, "Start")
    }

Same for resetTimer:


    func testResetTimer_whenTimeIsSet_returnsDefaultTime() {
        // given
        let mockMinutes = 12
        let mockSeconds = 13
        let mockGatherTime = GatherTime(minutes: mockMinutes, seconds: mockSeconds)
        let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
        
        // when
        sut.resetTimer()
        
        // then
        XCTAssertNotEqual(sut.selectedMinutes, mockMinutes)
        XCTAssertNotEqual(sut.selectedSeconds, mockSeconds)
        XCTAssertEqual(sut.selectedMinutes, GatherTime.defaultTime.minutes)
        XCTAssertEqual(sut.selectedSeconds, GatherTime.defaultTime.seconds)
    }

The delegates of the pickerView and tableView are very easy to test. We exemplify some unit tests below:


    func testNumberOfRowsInSection_whenViewModelHasPlayers_returnsCorrectNumberOfPlayers() {
        // given
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 5)
        let teamAPlayersCount = mockGatherModel.players.filter { $0.team == .teamA}.count
        let teamBPlayersCount = mockGatherModel.players.filter { $0.team == .teamB}.count
        let sut = GatherViewModel(gatherModel: mockGatherModel)
        
        // when
        let numberOfRowsInSection0 = sut.numberOfRowsInSection(0)
        let numberOfRowsInSection1 = sut.numberOfRowsInSection(1)
        
        // then
        XCTAssertEqual(numberOfRowsInSection0, teamAPlayersCount)
        XCTAssertEqual(numberOfRowsInSection1, teamBPlayersCount)
    }


    func testNumberOfRowsInPickerComponent_whenComponentIsMinutes_returns60() {
        // given
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let sut = GatherViewModel(gatherModel: mockGatherModel)
        
        // when
        let numberOfRowsInPickerComponent = sut.numberOfRowsInPickerComponent(GatherTimeHandler.Component.minutes.rawValue)
        
        // then
        XCTAssertEqual(numberOfRowsInPickerComponent, 60)
    }

For ending a gather we use the mocked endpoint and models. We verify if the received response is true:


    func testEndGather_whenScoreIsSet_updatesGather() {
        // given
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        let mockEndpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: resourcePath)
        let mockService = StandardNetworkService(session: session, urlRequest: AuthURLRequestFactory(endpoint: mockEndpoint, keychain: appKeychain))
        let sut = GatherViewModel(gatherModel: mockGatherModel, updateGatherService: mockService)
        let exp = expectation(description: "Update gather expectation")
        
        // when
        sut.endGather(teamAScoreLabelText: "1", teamBScoreLabelText: "1") { gatherUpdated in
            XCTAssertTrue(gatherUpdated)
            exp.fulfill()
        }
        
        // then
        waitForExpectations(timeout: 5, handler: nil)
    }

To check if the timer is toggled, we use a MockViewModelDelegate:


    private extension GatherViewModelTests {
        final class MockViewModelDelegate: GatherViewModelDelegate {
            // [1] Used to check if the delegate was called (didUpdateGatherTime())
            private(set) var gatherTimeWasUpdated = false

            // [2] Is fulfilled when the numberOfUpdateCalls is equal to actualUpdateCalls.
            // This means that the selector for the timer was called as many times as we wanted.
            weak var expectation: XCTestExpectation? = nil
            var numberOfUpdateCalls = 1
            private(set) var actualUpdateCalls = 0
            
            func didUpdateGatherTime() {
                gatherTimeWasUpdated = true
                actualUpdateCalls += 1 // [3] Increment the number of calls to this method
                
                if expectation != nil && numberOfUpdateCalls == actualUpdateCalls {
                    expectation?.fulfill()
                }
            }
        }
    }

And the unit test:


      func testToggleTimer_whenSelectedTimeIsValid_updatesTime() {
        // given
        let numberOfUpdateCalls = 2
        let mockGatherTime = GatherTime(minutes: 0, seconds: numberOfUpdateCalls)
        let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
        let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
        
        let exp = expectation(description: "Waiting timer expectation")
        let mockDelegate = MockViewModelDelegate()
        mockDelegate.numberOfUpdateCalls = numberOfUpdateCalls
        mockDelegate.expectation = exp


        let sut = GatherViewModel(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)
        
        // when
        sut.delegate = mockDelegate
        sut.toggleTimer()
        
        // then
        waitForExpectations(timeout: 5) { _ in
            XCTAssertTrue(mockDelegate.gatherTimeWasUpdated)
            XCTAssertEqual(mockDelegate.actualUpdateCalls, numberOfUpdateCalls)
            sut.stopTimer()
        }
    }

Compared with testing the ViewController in the MVC architecture, life becomes easier when testing the ViewModel layer. The unit tests are easy to write, easier to understand and much simpler.

Key Metrics

Lines of code - View Controllers

File Number of lines of code MVC - Lines of code
PlayerAddViewController 77 79 (-2)
PlayerListViewController 296 387 (-91)
PlayerDetailViewController 96 204 (-108)
LoginViewController 96 126 (-30)
PlayerEditViewController 124 212 (-88)
GatherViewController 227 359 (-132)
ConfirmPlayersViewController 104 260 (-156)
TOTAL 1020 1627 (-607)

Lines of code - View Models

File Number of lines of code
LoginViewModel 75
PlayerListViewModel 258
PlayerEditViewModel 155
PlayerAddViewModel 37
PlayerDetailViewModel 178
GatherViewModel 204
ConfirmPlayersViewModel 206
TOTAL 1113

Unit Tests

Topic Data MVC Data
Number of key classes (ViewControllers and ViewModels) 14 7 +7
Key Class GatherViewModel GatherViewController
Number of Unit Tests 34 30 +4
Code Coverage of Gathers feature 97.3% 95.7% +1.6
How hard to write unit tests 3/5 5/5 -2

Build Times

Build Time (sec)* MVC Time (sec)*
Average Build Time (after clean Derived Data & Clean Build) 9.65 9.78 -13
Average Build Time 0.1 0.1
Average Unit Test Execution Time (after clean Derived Data & Clean Build) 17.88 12.78 -5.1

* tests were run on an 8-Core Intel Core i9, MacBook Pro, 2019

Conclusion

Our application has now been transformed from MVC to MVVM. We added a new layer to handle the business logic and decouple it from the View Controller, separating better the responsibilities.

MVVM is a good pattern and worked great to reduce the complexity of our View Controllers, sliming the implementation down. The unit tests covering the business logic, were also easier to write.

However, when working with UIKit in your projects, MVVM is unnatural and hard to apply.

Looking at the key metrics, we can note down the following observations:

  • we reduced considerably the number of lines of codes in the view controllers by 607 lines of code
  • on the other hand, the view models took us 1113 lines of code to write
  • in total, we added 506 lines of code and 7 files to our app
  • a slightly negative impact was on the average unit test execution time, being increased with 5.1 seconds
  • the code coverage applied to the Gathers feature, increased with 1.6%, solidifying a total of 97.3%, giving more confidence when adopting changes and refactoring parts of the app, without breaking the existing logic
  • comparing to the MVC, the unit tests covering the business logic were much easier to write

In conclusion, MVVM was a fun exercise; we are now having a much cleaner application and we can even say it's less error prone.

Thanks for staying until the end! We have some useful links below.

Useful Links

Item Series Links
The iOS App - Football Gather GitHub Repo Link
The web server application made in Vapor GitHub Repo Link
Vapor 3 - Backend APIs article link
Migrating to Vapor 4 article link
Model View Controller (MVC) GitHub Repo Link
Article Link
Model View ViewModel (MVVM) GitHub Repo Link
Article Link
Model View Presenter (MVP) GitHub Repo Link
Coordinator Pattern - MVP with Coordinators (MVP-C) GitHub Repo Link
View Interactor Presenter Entity Router (VIPER) GitHub Repo Link
View Interactor Presenter (VIP) GitHub Repo Link