Radu Dan
MVVM Banner

Architecture Series - Model View ViewModel (MVVM)

Motivation

Before diving into iOS app development, it's crucial to carefully consider the project's architecture. We need to thoughtfully plan how different pieces of code will fit together, ensuring they remain comprehensible not just today, but months or years later when we need to revisit and modify the codebase. Moreover, a well-structured project helps establish a shared technical vocabulary among team members, making collaboration more efficient.

This article kicks off an exciting series where we'll explore different architectural approaches by building the same application using various patterns. Throughout the series, we'll analyze practical aspects like build times and implementation complexity, weigh the pros and cons of each pattern, and most importantly, examine real, production-ready code implementations. This hands-on approach will help you make informed decisions about which architecture best suits your project needs.

Architecture Series Articles

If you're eager to explore the implementation details directly, you can find the complete source code in our open-source repository here.

Why Your iOS App Needs a Solid Architecture Pattern

The cornerstone of any successful iOS application is maintainability. A well-architected app clearly defines boundaries - you know exactly where view logic belongs, what responsibilities each view controller has, and which components handle business logic. This clarity isn't just for you; it's essential for your entire development team to understand and maintain these boundaries consistently.

Here are the key benefits of implementing a robust architecture pattern:

  • Maintainability: Makes code easier to update and modify over time
  • Testability: Facilitates comprehensive testing of business logic through clear separation of concerns
  • Team Collaboration: Creates a shared technical vocabulary and understanding among team members
  • Clean Separation: Ensures each component has clear, single responsibilities
  • Bug Reduction: Minimizes errors through better organization and clearer interfaces between components

Project Requirements Overview

Given a medium-sized iOS application consisting of 6-7 screens, we'll demonstrate how to implement it using the most popular architectural patterns in the iOS ecosystem: MVC (Model-View-Controller), MVVM (Model-View-ViewModel), MVP (Model-View-Presenter), VIPER (View-Interactor-Presenter-Entity-Router), VIP (Clean Swift), and the Coordinator pattern. Each implementation will showcase the pattern's strengths and potential challenges.

Our demo application, Football Gather, is designed to help friends organize and track their casual football matches. It's complex enough to demonstrate real-world architectural challenges while remaining simple enough to clearly illustrate different patterns.

Core Features and Functionality

  • Player Management: Add and maintain a roster of players in the application
  • Team Assignment: Flexibly organize players into different teams for each match
  • Player Customization: Edit player details and preferences
  • Match Management: Set and control countdown timers for match duration

Screen Mockups

Football Gather Mockups

Backend

The application is supported by a web backend developed using the Vapor framework, a popular choice for building modern REST APIs in Swift. You can explore the details of this implementation in our article here, which covers the fundamentals of building REST APIs with Vapor and Fluent in Swift. Additionally, we have documented the transition from Vapor 3 to Vapor 4, highlighting the enhancements and new features in our article here.

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:

/// Configures the "Remember Me" functionality by setting up the switch state and username field
private func configureRememberMe() {
    // Set the switch state based on user's saved preference
    rememberMeSwitch.isOn = viewModel.rememberUsername
    
    // If "Remember Me" was enabled, populate the username field
    // with the previously stored username
    if viewModel.rememberUsername {
        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.

/// Handles the login button action
@IBAction func login(_ sender: Any) {
    // Validate that both username and password fields are filled
    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
    }
    
    // Show loading indicator while performing login
    showLoadingView()
    
    // Attempt to login using the ViewModel
    viewModel.performLogin(withUsername: userText, andPassword: passwordText) { [weak self] error in
        DispatchQueue.main.async {
            self?.hideLoadingView()
            self?.handleServiceResponse(error: error)
        }
    }
}

/// Handles the API response from login attempt
private func handleServiceResponse(error: Error?) {
    if let error = error {
        // Show error alert if login failed
        AlertHelper.present(in: self, title: "Error", message: String(describing: error))
    } else {
        handleSuccessResponse()
    }
}

/// Handles successful login by storing credentials and navigating to PlayerList
private func handleSuccessResponse() {
    storeUsernameAndRememberMe()
    performSegue(withIdentifier: SegueIdentifier.playerList.rawValue, sender: nil)
}

/// Stores the username and remember me preference in the ViewModel
private func storeUsernameAndRememberMe() {
    // Update remember me preference
    viewModel.setRememberUsername(rememberMeSwitch.isOn)
    
    // Store or clear username based on remember me switch
    if rememberMeSwitch.isOn {
        viewModel.setUsername(usernameTextField.text)
    } else {
        viewModel.setUsername(nil)
    }
}

The LoginViewModel is defined by the following properties:

/// ViewModel responsible for handling login-related business logic and data management
struct LoginViewModel {
    // MARK: - Dependencies
    
    /// Service handling login authentication
    private let loginService: LoginService
    
    /// Service handling user-related network operations
    private let usersService: StandardNetworkService
    
    /// Persistent storage for user preferences
    private let userDefaults: FootballGatherUserDefaults
    
    /// Secure storage for sensitive user data
    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:

/// Initializes the LoginViewModel with customizable dependencies
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:

// MARK: - User Preferences

/// Returns whether the "Remember Username" feature is enabled
/// Defaults to true if not previously set
var rememberUsername: Bool {
    return userDefaults.rememberUsername ?? true
}

/// Returns the stored username if "Remember Username" is enabled
/// Returns nil if no username is stored or feature is disabled
var username: String? {
    return keychain.username
}

/// Updates the "Remember Username" preference
/// - Parameter value: Boolean indicating if username should be remembered
func setRememberUsername(_ value: Bool) {
    userDefaults.rememberUsername = value
}

/// Stores or clears the username in secure storage
/// - Parameter username: The username to store, or nil to clear
func setUsername(_ username: String?) {
    keychain.username = username
}

And the two server API calls:

/// Attempts to log in a user with the provided credentials
/// - Parameters:
///   - username: The user's username
///   - password: The user's password
///   - completion: Callback with optional error if login fails
func performLogin(
    withUsername username: String,
    andPassword password: String,
    completion: @escaping (Error?) -> ()
) {
    // Create request model with user credentials
    let requestModel = UserRequestModel(username: username, password: password)
    
    // Attempt login with service
    loginService.login(user: requestModel) { result in
        switch result {
        case .failure(let error):
            completion(error)
            
        case .success(_):
            completion(nil)
        }
    }
}

/// Registers a new user with the provided credentials
/// - Parameters:
///   - username: The desired username
///   - password: The user's password (will be hashed)
///   - completion: Callback with optional error if registration fails
func performRegister(
    withUsername username: String,
    andPassword password: String,
    completion: @escaping (Error?) -> ()
) {
    // Hash the password for security
    guard let hashedPasssword = Crypto.hash(message: password) else {
        fatalError("Unable to hash password")
    }
    
    // Create request model with hashed credentials
    let requestModel = UserRequestModel(
        username: username,
        password: hashedPasssword
    )
    
    // Attempt to create user with service
    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:

/// Loads players from the server and updates the UI accordingly
private func loadPlayers() {
    // Disable user interaction while loading
    view.isUserInteractionEnabled = false
    
    // Request players from ViewModel
    viewModel.fetchPlayers { [weak self] error in
        DispatchQueue.main.async {
            // Re-enable user interaction
            self?.view.isUserInteractionEnabled = true
            
            // Handle the response
            if let error = error {
                self?.handleServiceFailures(withError: error)
            } else {
                self?.handleLoadPlayersSuccessfulResponse()
            }
        }
    }
}

The response handling is similar as we have in LoginViewController:

/// Handles service failure by presenting an error alert to the user
private func handleServiceFailures(withError error: Error) {
    AlertHelper.present(in: self, title: "Error", message: String(describing: error))
}

/// Handles successful loading of players by updating the UI
private func handleLoadPlayersSuccessfulResponse() {
    if viewModel.playersCollectionIsEmpty {
        // The players array is empty, show the empty view
        showEmptyView()
    } else {
        // Players are available, hide the empty view
        hideEmptyView()
    }

    // Reload the players table view to reflect the latest data
    playerTableView.reloadData()
}

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:

/// Returns the number of rows in the section, which is the number of players
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return viewModel.numberOfRows
}

/// Configures and returns the cell for the given index path
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: "PlayerTableViewCell") as? PlayerTableViewCell else {
        return UITableViewCell()
    }

    if viewModel.isInListViewMode {
        // Default view mode, showing the players
        viewModel.clearSelectedPlayerIfNeeded(at: indexPath)
        cell.setupDefaultView()
    } else {
        // Selection view mode for gathering players
        cell.setupSelectionView()
    }

    // 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:

/// Determines if a row can be edited, only in list view mode
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
    return viewModel.isInListViewMode
}

/// Handles the commit editing style for deleting a player
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
    guard editingStyle == .delete else { return }

    // Present a confirmation alert
    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)
}

/// Handles the confirmation of player deletion
private func handleDeletePlayerConfirmation(forRowAt indexPath: IndexPath) {
    requestDeletePlayer(at: indexPath) { [weak self] result in
        guard result, let self = self else { return }

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

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

/// Requests the deletion of a player from the server
private func requestDeletePlayer(at indexPath: IndexPath, completion: @escaping (Bool) -> Void) {
    viewModel.requestDeletePlayer(at: indexPath) { [weak self] error in
        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.

/// Prepares for navigation by configuring the destination view controller
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    switch segue.identifier {
    case SegueIdentifier.confirmPlayers.rawValue:
        if let confirmPlayersViewController = segue.destination as? ConfirmPlayersViewController {
            // Configure confirm players screen with its view model
            confirmPlayersViewController.viewModel = viewModel.makeConfirmPlayersViewModel()
        }

    case SegueIdentifier.playerDetails.rawValue:
        if let playerDetailsViewController = segue.destination as? PlayerDetailViewController,
            let player = viewModel.selectedPlayerForDetails {
            // Configure player details screen with delegate and view model
            playerDetailsViewController.delegate = self
            playerDetailsViewController.viewModel = PlayerDetailViewModel(player: player)
        }

    case SegueIdentifier.addPlayer.rawValue:
        // Configure add player screen with delegate
        (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:

/// ViewModel responsible for managing player list state and interactions
final class PlayerListViewModel {
    
    // MARK: - Properties
    
    /// Current view state (list or selection mode)
    private var viewState: ViewState
    
    /// Details for the current view state, created on-demand
    private var viewStateDetails: LoginViewStateDetails {
        return ViewStateDetailsFactory.makeViewStateDetails(from: viewState)
    }
}

// MARK: - ViewState Definition
extension PlayerListViewModel {
    /// Represents the possible view states for the player list
    enum ViewState {
        /// Default state showing the list of players
        case list
        /// State for selecting players for a gather
        case selection
        
        /// Toggles between list and selection states
        mutating func toggle() {
            self = self == .list ? .selection : .list
        }
    }
}

And the concrete classes for list and selection:

/// Protocol defining the interface for view state details
protocol LoginViewStateDetails {
    /// Title for the navigation bar button
    var barButtonItemTitle: String { get }
    /// Whether the action button should be enabled
    var actionButtonIsEnabled: Bool { get }
    /// Title for the action button
    var actionButtonTitle: String { get }
    /// Identifier for the segue to be performed
    var segueIdentifier: String { get }
}

// MARK: - View State Implementations
fileprivate extension PlayerListViewModel {
    
    /// Details for list view mode
    struct ListViewStateDetails: LoginViewStateDetails {
        var barButtonItemTitle: String {
            return "Select"
        }
        
        var actionButtonIsEnabled: Bool {
            return false
        }
        
        var segueIdentifier: String {
            // Bound to the add player action
            return SegueIdentifier.addPlayer.rawValue
        }
        
        var actionButtonTitle: String {
            return "Add player"
        }
    }

    /// Details for selection view mode
    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"
        }
    }

    /// Factory for creating view state details
    enum ViewStateDetailsFactory {
        /// Creates the appropriate view state details based on the current view state
        static func makeViewStateDetails(from viewState: ViewState) -> LoginViewStateDetails {
            switch viewState {
            case .list:
                return ListViewStateDetails()
                
            case .selection:
                return SelectionViewStateDetails()
            }
        }
    }
}

The service methods are easy to read:

/// Fetches players from the service and updates the local collection
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)
        }
    }
}

/// Requests deletion of a player at the specified index path
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.

/// Handles the done button action for creating a new player
@objc private func doneAction(sender: UIBarButtonItem) {
    guard let playerName = playerNameTextField.text else { return }

    // Show loading indicator while creating player
    showLoadingView()
    viewModel.requestCreatePlayer(name: playerName) { [weak self] playerWasCreated in
        DispatchQueue.main.async {
            self?.hideLoadingView()

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

/// Handles service failure by showing an error alert
private func handleServiceFailure() {
    AlertHelper.present(in: self, title: "Error update", message: "Unable to create player. Please try again.")
}

/// Handles service success by notifying delegate and dismissing view
private func handleServiceSuccess() {
    delegate?.playerWasAdded()
    navigationController?.popViewController(animated: true)
}

// MARK: - ViewModel

/// ViewModel responsible for managing player creation
struct PlayerAddViewModel {
    /// Service used for player creation requests
    private let service: StandardNetworkService
    
    /// Initializes the view model with a network service
    init(service: StandardNetworkService = StandardNetworkService(resourcePath: "/api/players", authenticated: true)) {
        self.service = service
    }
    
    /// The title displayed in the navigation bar
    var title: String {
        return "Add Player"
    }
    
    /// Requests the creation of a new player
    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)
            }
        }
    }
    
    /// Determines if the done button should be enabled based on text input
    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:

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

    // Configure the edit player screen with its view model and delegate
    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.

// MARK: - UITableView DataSource & Delegate
extension PlayerDetailViewController: UITableViewDelegate, UITableViewDataSource {
    /// Returns the number of sections in the table view
    func numberOfSections(in tableView: UITableView) -> Int {
        return viewModel.numberOfSections
    }

    /// Returns the number of rows in the specified section
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.numberOfRowsInSection(section)
    }

    /// Configures and returns a cell for the specified index path
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "PlayerDetailTableViewCell") as? PlayerDetailTableViewCell else {
            return UITableViewCell()
        }

        // Configure cell with view model data
        cell.leftLabel.text = viewModel.rowTitleDescription(for: indexPath)
        cell.rightLabel.text = viewModel.rowValueDescription(for: indexPath)

        return cell
    }

    /// Returns the header title for the specified section
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return viewModel.titleForHeaderInSection(section)
    }

    /// Handles row selection by navigating to edit screen
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        viewModel.selectPlayerRow(at: indexPath)
        performSegue(withIdentifier: SegueIdentifier.editPlayer.rawValue, sender: nil)
    }
}

// MARK: - PlayerEditViewController Delegate
extension PlayerDetailViewController: PlayerEditViewControllerDelegate {
    /// Handles completion of player editing
    func didFinishEditing(player: PlayerResponseModel) {
        setupTitle()                    // Update title if player name changed
        viewModel.updatePlayer(player)  // Update local player model
        viewModel.reloadSections()      // Rebuild sections data
        reloadData()                    // Refresh table view
        delegate?.didEdit(player: player) // Notify parent of update
    }
}

PlayerDetailViewModel has the following properties:

/// ViewModel responsible for managing player details display
final class PlayerDetailViewModel {
    
    // MARK: - Properties
    
    /// The player model being displayed in the screen
    private(set) var player: PlayerResponseModel
    
    /// Sections containing organized player data, created lazily
    private lazy var sections = makeSections()
    
    /// Currently selected player row information
    private(set) var selectedPlayerRow: PlayerRow?
    
    // MARK: - Initialization
    
    /// Initializes the view model with a player model
    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:

/// Configures the player edit text field with initial values and behavior
private func setupPlayerEditTextField() {
    // Set initial text values
    playerEditTextField.placeholder = viewModel.playerRowValue
    playerEditTextField.text = viewModel.playerRowValue
    
    // Configure editing behavior
    playerEditTextField.addTarget(
        self, 
        action: #selector(textFieldDidChange), 
        for: .editingChanged
    )
    
    // Hide field if in selection mode
    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.

/// Handles the done button action for updating player information
@objc private func doneAction(sender: UIBarButtonItem) {
    guard viewModel.shouldUpdatePlayer(inputFieldValue: playerEditTextField.text) else { return }

    // Show loading indicator while updating
    showLoadingView()

    // Attempt to update player with current field value
    viewModel.updatePlayerBasedOnViewType(inputFieldValue: playerEditTextField.text) { [weak self] updated in
        DispatchQueue.main.async {
            self?.hideLoadingView()

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

/// Handles successful player update by notifying delegate and dismissing view
private func handleSuccessfulPlayerUpdate() {
    delegate?.didFinishEditing(player: viewModel.editablePlayer)
    navigationController?.popViewController(animated: true)
}

/// Handles update failure by showing an error alert
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:

/// 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()
}

/// 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]
}

/// Decides what needs to be updated (if inputFieldValue is nil, then 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)
}

/// Performs 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:

/// Configures initial view states and appearance
func setupViews() {
    // Configure table view editing state
    playerTableView.isEditing = viewModel.playerTableViewIsEditing
    
    // Setup gather button configuration
    configureStartGatherButton()
}

The server API call is presented below:

/// Handles the action to start a new gather
@IBAction private func startGather(_ sender: Any) {
    // Show loading indicator while creating gather
    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
                )
            }
        }
    }
}

/// Handles gather creation failure by showing an error alert
private func handleServiceFailure() {
    AlertHelper.present(
        in: self,
        title: "Error",
        message: "Unable to create gather."
    )
}

And the TableView Delegate and DataSource:

// MARK: - UITableViewDelegate & UITableViewDataSource
extension ConfirmPlayersViewController: UITableViewDelegate, UITableViewDataSource {
    /// Returns the number of sections in the table view
    func numberOfSections(in tableView: UITableView) -> Int {
        return viewModel.numberOfSections
    }

    /// Returns the header title for the specified section
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return viewModel.titleForHeaderInSection(section)
    }

    /// Returns the number of rows in the specified section
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.numberOfRowsInSection(section)
    }

    /// Returns the editing style for a row
    func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
        return .none
    }

    /// Determines if a row should be indented while editing
    func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
        return false
    }

    /// Configures and returns a cell for the specified index path
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "PlayerChooseTableViewCellId") else {
            return UITableViewCell()
        }

        // Configure cell with view model data
        cell.textLabel?.text = viewModel.rowTitle(at: indexPath)
        cell.detailTextLabel?.text = viewModel.rowDescription(at: indexPath)

        return cell
    }

    /// Handles row movement within the table view
    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.

/// ViewModel responsible for managing player confirmation and gather creation
final class ConfirmPlayersViewModel {
    
    // MARK: - Properties
    
    /// Dictionary mapping teams to their selected players
    private var playersDictionary: [TeamSection: [PlayerResponseModel]]
    
    /// Service for adding players to a gather
    private var addPlayerService: AddPlayerToGatherService
    
    /// Service for gather-related network operations
    private let gatherService: StandardNetworkService
    
    /// Group for coordinating multiple player additions
    private let dispatchGroup = DispatchGroup()
    
    /// UUID of the created gather
    private var gatherUUID: UUID?
    
    // MARK: - Initialization
    
    /// Initializes the view model with players and services
    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:

/// Initiates the gather creation process and adds selected players
func startGather(completion: @escaping (Bool) -> ()) {
    createGather { [weak self] uuid in
        guard let gatherUUID = uuid else {
            completion(false)
            return
        }
        
        // Store UUID and add players to the created gather
        self?.gatherUUID = gatherUUID
        self?.addPlayersToGather(havingUUID: gatherUUID, completion: completion)
    }
}

/// Creates a new gather on the server
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)
        }
    }
}

/// Adds all selected players to the gather using a dispatch group for coordination
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()
        }
    }
    
    // Wait for all player additions to complete
    dispatchGroup.notify(queue: DispatchQueue.main) {
        completion(serviceFailed)
    }
}

/// Converts the players dictionary into an array of player-team pairs
private var playerTeamArray: [PlayerTeamModel] {
    var players: [PlayerTeamModel] = []
    
    // Add Team A players
    players += self.playersDictionary
        .filter { $0.key == .teamA }
        .flatMap { $0.value }
        .map { PlayerTeamModel(team: .teamA, player: $0) }
    
    // Add Team B players
    players += self.playersDictionary
        .filter { $0.key == .teamB }
        .flatMap { $0.value }
        .map { PlayerTeamModel(team: .teamB, player: $0) }
    
    return players
}

/// Adds a single player to the gather with their team assignment
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:

/// View controller responsible for managing the gather screen and its interactions
final class GatherViewController: UIViewController, Loadable {
    // MARK: - IBOutlets
    
    /// Table view displaying player information
    @IBOutlet weak var playerTableView: UITableView!
    
    /// View displaying the current score
    @IBOutlet weak var scoreLabelView: ScoreLabelView!
    
    /// Stepper control for adjusting scores
    @IBOutlet weak var scoreStepper: ScoreStepper!
    
    /// Label displaying the current timer value
    @IBOutlet weak var timerLabel: UILabel!
    
    /// Container view for timer-related controls
    @IBOutlet weak var timerView: UIView!
    
    /// Picker view for selecting timer duration
    @IBOutlet weak var timePickerView: UIPickerView!
    
    /// Button for controlling timer actions
    @IBOutlet weak var actionTimerButton: UIButton!
    
    // MARK: - Properties
    
    /// Loading indicator view
    lazy var loadingView = LoadingView.initToView(self.view)
    
    /// View model managing gather business logic
    var viewModel: GatherViewModel!
}

In viewDidLoad, we setup and configure the views:

override func viewDidLoad() {
    super.viewDidLoad()

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

/// Sets the view controller's title from the view model
private func setupTitle() {
    title = viewModel.title
}

/// Configures the view model delegate
private func setupViewModel() {
    viewModel.delegate = self
}

/// Configures the timer label with formatted text
private func configureSelectedTime() {
    timerLabel?.text = viewModel.formattedCountdownTimerLabelText
}

/// Configures the action timer button with formatted text
private func configureActionTimerButton() {
    actionTimerButton.setTitle(viewModel.formattedActionTitleText, for: .normal)
}

/// Hides the timer view
private func hideTimerView() {
    timerView.isHidden = true
}

/// Shows the timer view
private func showTimerView() {
    timerView.isHidden = false
}

/// Sets up the score stepper delegate
private func setupScoreStepper() {
    scoreStepper.delegate = self
}

/// Reloads data for the time picker and player table views
private func reloadData() {
    timePickerView.reloadAllComponents()
    playerTableView.reloadData()
}

The timer related functions are looking neat:

// MARK: - Timer Actions

/// Shows the timer picker view
@IBAction private func setTimer(_ sender: Any) {
    configureTimePickerView()
    showTimerView()
}

/// Cancels and resets the current timer
@IBAction private func cancelTimer(_ sender: Any) {
    viewModel.stopTimer()
    viewModel.resetTimer()
    configureSelectedTime()
    configureActionTimerButton()
    hideTimerView()
}

/// Toggles the timer between running and paused states
@IBAction private func actionTimer(_ sender: Any) {
    viewModel.toggleTimer()
    configureActionTimerButton()
}

/// Dismisses the timer picker view without saving
@IBAction private func timerCancel(_ sender: Any) {
    hideTimerView()
}

/// Saves the selected timer duration and starts the timer
@IBAction private func timerDone(_ sender: Any) {
    viewModel.stopTimer()
    viewModel.setTimerMinutes(selectedMinutesRow)
    viewModel.setTimerSeconds(selectedSecondsRow)
    configureSelectedTime()
    configureActionTimerButton()
    hideTimerView()
}

// MARK: - Timer Picker Helpers

/// Returns the currently selected minutes from the picker view
private var selectedMinutesRow: Int { 
    timePickerView.selectedRow(inComponent: viewModel.minutesComponent) 
}

/// Returns the currently selected seconds from the picker view
private var selectedSecondsRow: Int { 
    timePickerView.selectedRow(inComponent: viewModel.secondsComponent) 
}

And the endGather API interaction:

// MARK: - Gather Control Actions

/// Presents a confirmation alert before ending the gather
@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)
}

/// Ends the current gather and updates the final scores
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()
            }
        }
    }
}

/// Displays an error alert when the gather update fails
private func handleServiceFailure() {
    AlertHelper.present(
        in: self,
        title: "Error update",
        message: "Unable to update gather. Please try again."
    )
}

/// Handles successful gather completion by returning to the player list
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")
}

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 -0.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

In this article, we've documented our journey of transforming our application from MVC to MVVM architecture. By introducing a dedicated layer for business logic, we've successfully decoupled core functionality from the View Controller, resulting in a cleaner separation of responsibilities.

The MVVM pattern proved highly effective in reducing View Controller complexity, producing more maintainable code. A particularly notable improvement was the enhanced testability of our business logic, with unit tests becoming significantly more straightforward to implement.

However, it's important to acknowledge that implementing MVVM with UIKit presents certain challenges, as the framework wasn't originally designed with this architectural pattern in mind.

Key Metrics and Observations

  • Code Distribution:
    • Achieved a substantial reduction of 607 lines in View Controller code
    • Introduced 1113 lines of View Model code
    • Net increase of 506 lines and 7 new files to the codebase
  • Testing Improvements:
    • Achieved higher code coverage for the Gathers feature, increasing by 1.6% to reach 97.3%
    • Significantly simplified unit test implementation for business logic
    • Minor trade-off in test execution time, with an increase of 5.1 seconds

Benefits and Trade-offs

The adoption of MVVM has delivered several key advantages:

  • Enhanced code organization and maintainability
  • Improved separation of concerns
  • Better testability of business logic
  • Reduced risk of errors through cleaner architecture

While the implementation required additional code and slightly longer test execution times, the benefits in terms of maintainability, testability, and code organization make MVVM a valuable architectural choice for iOS applications, particularly as they grow in complexity.

This exercise in architectural transformation has resulted in a more robust, maintainable, and testable application. For teams considering MVVM, our experience suggests that the initial investment in additional code and setup time can pay significant dividends in long-term maintainability and reliability.

Thank you for following along with our architectural journey! Please explore the resources below for additional information and practical examples.

Useful Links

Item Series Links
The iOS App - Football Gather GitHub Repo Link
The web server application made in Vapor GitHub Repo Link
'Building Modern REST APIs with Vapor and Fluent in Swift' article link
'From Vapor 3 to 4: Elevate your server-side app' 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
Article Link
Coordinator Pattern - MVP with Coordinators (MVP-C) GitHub Repo Link
Article Link
View Interactor Presenter Entity Router (VIPER) GitHub Repo Link
Article Link
View Interactor Presenter (VIP) GitHub Repo Link
Article Link