Radu Dan
MVP Banner

Architecture Series - Model View Presenter (MVP)

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 MVP?

MVP (Model-View-Presenter) is a design pattern used in software development, similar to MVVM (Model-View-ViewModel), but with some key distinctions:

  • It introduces a Presenter layer that mediates between the View and the Model.
  • The Presenter controls the View and handles the communication between the layers.

Model

  • The Model layer encapsulates business data and logic.
  • It acts as an interface responsible for managing domain-specific data.

Communication:

  • When a user interacts with the View (e.g., clicking a button), the action is communicated to the Presenter, which then interacts with the Model.
  • When the Model updates (e.g., fetching new data), the Presenter communicates these changes to the View, ensuring the user interface reflects the latest state.

View

  • The View is responsible for rendering the user interface and capturing user interactions.
  • Unlike MVVM, the View does not directly handle its state updates. These are managed by the Presenter.

Communication:

  • The View does not directly communicate with the Model. All communication flows through the Presenter.

Presenter

  • The Presenter handles events triggered by the View and performs the necessary operations with the Model.
  • It acts as the intermediary, connecting the View and the Model without embedding logic into the View itself.
  • Typically, each Presenter is mapped 1:1 with a View.

Communication:

  • The Presenter communicates with both the Model and the View.
  • It updates the View when data changes, ensuring the user interface reflects the current state.
  • All updates to the View are initiated by the Presenter.

When to Use MVP

The MVP pattern is suitable in scenarios where:

  • MVC or MVVM does not provide sufficient modularity or testability for your application.
  • You want to make your app more modular and improve code coverage with unit tests.

However, it may not be ideal for beginners or developers with limited iOS development experience, as implementing MVP involves more boilerplate code.

In our app, we have separated the View layer into two components:

  • The ViewController, which acts as a Coordinator/Router and holds a reference to the View, often set as an IBOutlet.
  • The actual View, which focuses solely on rendering the UI.

Advantages

  • Better separation of concerns compared to other patterns.
  • Most of the business logic can be unit tested.

Disadvantages

  • The "assembly problem" becomes more prominent, requiring additional layers like a Router or Coordinator for navigation and module assembly.
  • The Presenter can become overly large and complex due to its responsibilities.

Applying MVP to Our Code

Implementing MVP in our app involves two major steps:

  1. Converting existing ViewModels into Presenters.
  2. Separating the View from the ViewController to ensure modularity.

The applied MVP pattern is outlined below:

/// FooViewController is the main view controller handling the FooView.
final class FooViewController: UIViewController {

    /// The main view for FooViewController.
    @IBOutlet weak var fooView: FooView!

    /// Called after the controller's view is loaded into memory.
    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
    }

    /// Sets up the view by assigning the presenter and delegate.
    private func setupView() {
        let presenter = FooPresenter(view: fooView)
        fooView.delegate = self
        fooView.presenter = presenter
        fooView.setupView()
    }

    /// Prepares for a segue to another view controller.
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    }
}

/// Conforms FooViewController to FooViewDelegate.
extension FooViewController: FooViewDelegate {
    func didRequestToNavigateToFooDetail() {
        // Perform segue to FooDetail
    }
}

/// A protocol defining delegate methods for FooView.
protocol FooViewDelegate: AnyObject {
    func didRequestToNavigateToFooDetail()
}

/// A protocol defining methods for setting up the view.
protocol FooViewProtocol: AnyObject {
    func setupView()
}

/// Represents the main view of Foo, conforming to FooViewProtocol.
final class FooView: UIView, FooViewProtocol {

    /// The presenter managing the view's business logic.
    var presenter: FooPresenterProtocol = FooPresenter()

    /// A delegate to handle user interactions.
    weak var delegate: FooViewDelegate?

    /// Sets up the view with necessary configurations.
    func setupView() {
    }

    /// Loads data into the view.
    func loadData() {
    }
}

/// A protocol defining methods for presenters managing FooView.
protocol FooPresenterProtocol: AnyObject {
    func loadData()
}

/// A presenter class implementing FooPresenterProtocol.
final class FooPresenter: FooPresenterProtocol {

    /// A weak reference to the view managed by the presenter.
    private(set) weak var view: FooViewProtocol?

    /// Initializes the presenter with an optional view.
    init(view: FooViewProtocol? = nil) {
        self.view = view
    }

    /// Loads data and updates the view.
    func loadData() {
    }
}

LoginPresenter

Let’s see how the LoginPresenter looks like:

/// Defines the public API
protocol LoginPresenterProtocol: AnyObject {
    var rememberUsername: Bool { get }
    var username: String? { get }

    func setRememberUsername(_ value: Bool)
    func setUsername(_ username: String?)
    func performLogin(withUsername username: String?, andPassword password: String?)
    func performRegister(withUsername username: String?, andPassword password: String?)
}

All parameters will be injected through the initialiser.

final class LoginPresenter: LoginPresenterProtocol {
    private weak var view: LoginViewProtocol?
    private let loginService: LoginService
    private let usersService: StandardNetworkService
    private let userDefaults: FootballGatherUserDefaults
    private let keychain: FootbalGatherKeychain

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

The Keychain interactions are defined below:

var rememberUsername: Bool {
    return userDefaults.rememberUsername ?? true
}

var username: String? {
    return keychain.username
}

func setRememberUsername(_ value: Bool) {
    userDefaults.rememberUsername = value
}

func setUsername(_ username: String?) {
    keychain.username = username
}

And we have the two services:

func performLogin(withUsername username: String?, andPassword password: String?) {
    guard let userText = username, !userText.isEmpty,
          let passwordText = password, !passwordText.isEmpty else {
        // Key difference between MVVM and MVP, the presenter now tells the view what should do.
        view?.handleError(title: "Error", message: "Both fields are mandatory.")
        return
    }

    // Presenter tells the view to present a loading indicator.
    view?.showLoadingView()

    let requestModel = UserRequestModel(username: userText, password: passwordText)
    loginService.login(user: requestModel) { [weak self] result in
        DispatchQueue.main.async {
            self?.view?.hideLoadingView()

            switch result {
            case .failure(let error):
                self?.view?.handleError(title: "Error", message: String(describing: error))
            case .success(_):
                // Go to next screen
                self?.view?.handleLoginSuccessful()
            }
        }
    }
}

The register function is basically the same as the login one:

func performRegister(withUsername username: String?, andPassword password: String?) {
    guard let userText = username, !userText.isEmpty,
          let passwordText = password, !passwordText.isEmpty else {
        view?.handleError(title: "Error", message: "Both fields are mandatory.")
        return
    }

    guard let hashedPasssword = Crypto.hash(message: passwordText) else {
        fatalError("Unable to hash password")
    }

    view?.showLoadingView()

    let requestModel = UserRequestModel(username: userText, password: hashedPasssword)
    usersService.create(requestModel) { [weak self] result in
        DispatchQueue.main.async {
            self?.view?.hideLoadingView()

            switch result {
            case .failure(let error):
                self?.view?.handleError(title: "Error", message: String(describing: error))
            case .success(let resourceId):
                print("Created user: \(resourceId)")
                self?.view?.handleRegisterSuccessful()
            }
        }
    }
}

The LoginView has the following protocols:

// MARK: - LoginViewDelegate
/// A protocol defining the delegate's responsibilities for communicating with the LoginViewController.
protocol LoginViewDelegate: AnyObject {
    /// Presents an alert with a given title and message.
    func presentAlert(title: String, message: String)

    /// Notifies that the login operation was successful.
    func didLogin()

    /// Notifies that the registration operation was successful.
    func didRegister()
}

// MARK: - LoginViewProtocol
/// A protocol defining the public API of the LoginView.
protocol LoginViewProtocol: AnyObject {
    /// Sets up the initial view state.
    func setupView()

    /// Displays a loading indicator.
    func showLoadingView()

    /// Hides the loading indicator.
    func hideLoadingView()

    /// Handles errors by presenting an alert with a given title and message.
    func handleError(title: String, message: String)

    /// Notifies the view that login was successful.
    func handleLoginSuccessful()

    /// Notifies the view that registration was successful.
    func handleRegisterSuccessful()
}

// MARK: - LoginView
/// The view responsible for managing the UI components and communicating with the presenter.
final class LoginView: UIView, Loadable {

    // MARK: - Properties
    @IBOutlet weak var usernameTextField: UITextField!
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var rememberMeSwitch: UISwitch!

    lazy var loadingView = LoadingView.initToView(self)

    weak var delegate: LoginViewDelegate?
    var presenter: LoginPresenterProtocol = LoginPresenter()

    /// Configures the "Remember Me" switch and pre-fills the username if necessary.
    private func configureRememberMe() {
        rememberMeSwitch.isOn = presenter.rememberUsername

        if presenter.rememberUsername {
            usernameTextField.text = presenter.username
        }
    }

    /// Stores the username and the state of the "Remember Me" switch in the presenter.
    private func storeUsernameAndRememberMe() {
        presenter.setRememberUsername(rememberMeSwitch.isOn)

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

    /// Handles the login action and delegates the responsibility to the presenter.
    @IBAction private func login(_ sender: Any) {
        presenter.performLogin(withUsername: usernameTextField.text, andPassword: passwordTextField.text)
    }

    /// Handles the registration action and delegates the responsibility to the presenter.
    @IBAction private func register(_ sender: Any) {
        presenter.performRegister(withUsername: usernameTextField.text, andPassword: passwordTextField.text)
    }
}

// MARK: - LoginViewProtocol Implementation
extension LoginView: LoginViewProtocol {
    func setupView() {
        configureRememberMe()
    }

    func handleError(title: String, message: String) {
        delegate?.presentAlert(title: title, message: message)
    }

    func handleLoginSuccessful() {
        storeUsernameAndRememberMe()
        delegate?.didLogin()
    }

    func handleRegisterSuccessful() {
        storeUsernameAndRememberMe()
        delegate?.didRegister()
    }
}

// MARK: - LoginViewController
/// The controller responsible for managing the login view and handling navigation.
final class LoginViewController: UIViewController {

    // MARK: - Properties
    @IBOutlet weak var loginView: LoginView!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
    }

    /// Configures the view and initializes the presenter.
    private func setupView() {
        let presenter = LoginPresenter(view: loginView)
        loginView.delegate = self
        loginView.presenter = presenter
        loginView.setupView()
    }
}

// MARK: - LoginViewDelegate Implementation
extension LoginViewController: LoginViewDelegate {
    func presentAlert(title: String, message: String) {
        // Presents an alert to the user.
        AlertHelper.present(in: self, title: title, message: message)
    }

    func didLogin() {
        // Navigates to the player list screen after successful login.
        performSegue(withIdentifier: SegueIdentifier.playerList.rawValue, sender: nil)
    }

    func didRegister() {
        // Navigates to the player list screen after successful registration.
        performSegue(withIdentifier: SegueIdentifier.playerList.rawValue, sender: nil)
    }
}

// MARK: - PlayerListPresenter
/// The presenter responsible for handling PlayerList-specific logic.
func performPlayerDeleteRequest() {
    guard let indexPath = indexPathForDeletion else { return }

    view?.showLoadingView()

    requestDeletePlayer(at: indexPath) { [weak self] result in
        if result {
            self?.view?.handlePlayerDeletion(forRowAt: indexPath)
        }
    }
}

Now, the check for player deletion is made inside the Presenter and not in the View/ViewController.

// MARK: - Player Deletion Request
private func requestDeletePlayer(at indexPath: IndexPath, completion: @escaping (Bool) -> Void) {
    let player = players[indexPath.row]
    var service = playersService

    // Request to delete the player
    service.delete(withID: ResourceID.integer(player.id)) { [weak self] result in
        DispatchQueue.main.async {
            // [1] Hide the loading spinner view.
            self?.view?.hideLoadingView()

            // Handle the result of the deletion
            switch result {
            case .failure(let error):
                // [2] Notify the view of the error
                self?.view?.handleError(title: "Error", message: String(describing: error))
                completion(false)

            case .success(_):
                // [3] Notify completion of successful deletion
                completion(true)
            }
        }
    }
}

If we look in the PlayerListView, at the table view's data source methods, we observe that the Presenter is behaving exactly as a ViewModel:

// MARK: - Table View Data Source
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    // The number of rows is determined by the presenter
    return presenter.numberOfRows
}

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

    // Check if the presenter is in list view mode
    if presenter.isInListViewMode {
        // Clear any selected player if necessary
        presenter.clearSelectedPlayerIfNeeded(at: indexPath)
        cell.setupDefaultView()
    } else {
        // Setup the cell for selection view
        cell.setupSelectionView()
    }

    // Configure cell with player information provided by the presenter
    cell.nameLabel.text = presenter.playerNameDescription(at: indexPath)
    cell.positionLabel.text = presenter.playerPositionDescription(at: indexPath)
    cell.skillLabel.text = presenter.playerSkillDescription(at: indexPath)
    cell.playerIsSelected = presenter.playerIsSelected(at: indexPath)

    return cell
}

The PlayerListViewController now acts as a router between the Edit, Confirm, and Add screens.

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    switch segue.identifier {
    case SegueIdentifier.confirmPlayers.rawValue:
        // [1] Compose the selected players that will be added in the ConfirmPlayersPresenter.
        if let confirmPlayersViewController = segue.destination as? ConfirmPlayersViewController {
            confirmPlayersViewController.playersDictionary = playerListView.presenter.playersDictionary
        }

    case SegueIdentifier.playerDetails.rawValue:
        // [2] Set the player that we want to show the details.
        if let playerDetailsViewController = segue.destination as? PlayerDetailViewController,
          let player = playerListView.presenter.selectedPlayerForDetails {
            // [3] From the Details screen, we can edit a player. Using delegation, we listen for
            // such modifications and refresh this view with the updated details.
            playerDetailsViewController.delegate = self
            playerDetailsViewController.player = player
        }

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

    default:
        break
    }
}

Breaking into responsibilities, the PlayerList module has the following components:

PlayerListViewController responsibilities:

  • Implements the PlayerListTogglable protocol to return to the listView mode when a gather is completed (called from GatherViewController).
  • Holds an IBOutlet to PlayerListView.
  • Sets the presenter and view delegate, and instructs the view to set up.
  • Handles navigation logic and constructs models for Edit, Add, and Confirm screens.
  • Implements the PlayerListViewDelegate, performing operations such as:
    • Changing the title when requested (func didRequestToChangeTitle(_ title: String)).
    • Adding the right navigation bar button item (Select or Cancel selection of players).
    • Performing the appropriate segue with the identifier constructed in the Presenter.
    • Presenting alerts for service failures or delete confirmations.
  • Implements PlayerDetailViewControllerDelegate to refresh the View when a player is edited.
  • Implements AddPlayerDelegate to reload the list of players in the View.

PlayerListView responsibilities:

  • Exposes a public API via PlayerListViewProtocol. This layer should remain simple and avoid complex logic.

PlayerListPresenter responsibilities:

  • Exposes necessary methods for the View, such as barButtonItemTitle, barButtonItemIsEnabled, etc.

PlayerListViewState responsibilities:

  • Uses the Factory Method pattern to manage different states of PlayerListView, encapsulated in a separate file.

PlayerDetail screen

For the PlayerDetail screen, the View is separated from the ViewController.

// MARK: - PlayerDetailViewController
final class PlayerDetailViewController: UIViewController {

    // MARK: - Properties
    @IBOutlet weak var playerDetailView: PlayerDetailView!

    weak var delegate: PlayerDetailViewControllerDelegate?
    var player: PlayerResponseModel?

    // .. other methods
}

Navigation to the Edit screen follows a delegation pattern:

  • The user taps a row corresponding to a player property. The View informs the ViewController to edit that field, and the ViewController performs the segue. In the prepare(for segue:) method, required properties are allocated for editing.
extension PlayerDetailViewController: PlayerDetailViewDelegate {
    func didRequestEditView() {
        performSegue(withIdentifier: SegueIdentifier.editPlayer.rawValue, sender: nil)
    }
}

Inside PlayerDetailViewController:

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

    let presenter = playerDetailView.presenter
    destinationViewController.viewType = presenter?.destinationViewType ?? .text
    destinationViewController.playerEditModel = presenter?.playerEditModel
    destinationViewController.playerItemsEditModel = presenter?.playerItemsEditModel
    destinationViewController.delegate = self
}

PlayerDetailView:

final class PlayerDetailView: UIView, PlayerDetailViewProtocol {

    // MARK: - Properties
    @IBOutlet weak var playerDetailTableView: UITableView!

    weak var delegate: PlayerDetailViewDelegate?
    var presenter: PlayerDetailPresenterProtocol!

    // MARK: - Public API
    var title: String {
        return presenter.title
    }

    func reloadData() {
        playerDetailTableView.reloadData()
    }

    func updateData(player: PlayerResponseModel) {
        presenter.updatePlayer(player)
        presenter.reloadSections()
    }
}

The table view delegate and data source implementation:

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

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return presenter.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 = presenter.rowTitleDescription(for: indexPath)
        cell.rightLabel.text = presenter.rowValueDescription(for: indexPath)

        return cell
    }

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

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        presenter.selectPlayerRow(at: indexPath)
        delegate?.didRequestEditView()
    }
}

Inside PlayerDetailViewController:


/// Prepares for a segue by passing the necessary data to the destination view controller.
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    // Ensure the segue identifier matches and cast the destination view controller.
    guard segue.identifier == SegueIdentifier.editPlayer.rawValue,
          let destinationViewController = segue.destination as? PlayerEditViewController else {
        return
    }

    // Get the presenter from the player detail view.
    let presenter = playerDetailView.presenter

    // [1] Show the textfield or the picker for editing a player field.
    destinationViewController.viewType = presenter?.destinationViewType ?? .text

    // [2] Pass the edit model to the destination view controller.
    destinationViewController.playerEditModel = presenter?.playerEditModel

    // [3] Pass the data source for picker mode if applicable.
    destinationViewController.playerItemsEditModel = presenter?.playerItemsEditModel

    // Set the delegate to self.
    destinationViewController.delegate = self
}

PlayerDetailView is presented below:

final class PlayerDetailView: UIView, PlayerDetailViewProtocol {

    // MARK: - Properties
    @IBOutlet weak var playerDetailTableView: UITableView!

    weak var delegate: PlayerDetailViewDelegate?
    var presenter: PlayerDetailPresenterProtocol!

    // MARK: - Public API

    /// Returns the title for the view.
    var title: String {
        return presenter.title
    }

    /// Reloads the table view data.
    func reloadData() {
        playerDetailTableView.reloadData()
    }

    /// Updates the player data and refreshes the view.
    func updateData(player: PlayerResponseModel) {
        presenter.updatePlayer(player)
        presenter.reloadSections()
    }
}

And the table view delegate and data source implementation:

extension PlayerDetailView: UITableViewDelegate, UITableViewDataSource {

    /// Returns the number of sections in the table view.
    func numberOfSections(in tableView: UITableView) -> Int {
        return presenter.numberOfSections
    }

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

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

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

        return cell
    }

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

    /// Handles the selection of a row and requests the edit view.
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        presenter.selectPlayerRow(at: indexPath)
        delegate?.didRequestEditView()
    }
}

The PlayerDetailPresenter:

final class PlayerDetailPresenter: PlayerDetailPresenterProtocol {

    // MARK: - Properties
    private(set) var player: PlayerResponseModel
    private lazy var sections = makeSections()
    private(set) var selectedPlayerRow: PlayerRow?

    // MARK: - Public API
    init(player: PlayerResponseModel) {
        self.player = player
    }

    // other methods
}

Edit Screen

We follow the same approach for the remaining screens of the app.
Exemplifying below the PlayerEdit functionality. The PlayerEditView class is basically the new ViewController.

final class PlayerEditView: UIView, Loadable {

    // MARK: - Properties
    @IBOutlet weak var playerEditTextField: UITextField!
    @IBOutlet weak var playerTableView: UITableView!

    private lazy var doneButton = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(doneAction))
    lazy var loadingView = LoadingView.initToView(self)

    weak var delegate: PlayerEditViewDelegate?
    var presenter: PlayerEditPresenterProtocol!

    // other methods
}

The selectors are pretty straightforward:

// MARK: - Selectors
@objc private func textFieldDidChange(textField: UITextField) {
    doneButton.isEnabled = presenter.doneButtonIsEnabled(newValue: playerEditTextField.text)
}

@objc private func doneAction(sender: UIBarButtonItem) {
    presenter.updatePlayerBasedOnViewType(inputFieldValue: playerEditTextField.text)
}

And the Public API:

extension PlayerEditView: PlayerEditViewProtocol {
    var title: String {
        return presenter.title
    }

    func setupView() {
        setupNavigationItems()
        setupPlayerEditTextField()
        setupTableView()
    }

    func handleError(title: String, message: String) {
        delegate?.presentAlert(title: title, message: message)
    }

    func handleSuccessfulPlayerUpdate() {
        delegate?.didFinishEditingPlayer()
    }
}

Finally, the UITableViewDataSource and UITableViewDelegate methods:

extension PlayerEditView: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return presenter.numberOfRows
    }

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

        cell.textLabel?.text = presenter.itemRowTextDescription(indexPath: indexPath)
        cell.accessoryType = presenter.isSelectedIndexPath(indexPath) ? .checkmark : .none

        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if let selectedItemIndex = presenter.selectedItemIndex {
            clearAccessoryType(forSelectedIndex: selectedItemIndex)
        }

        presenter.updateSelectedItemIndex(indexPath.row)
        tableView.cellForRow(at: indexPath)?.accessoryType = .checkmark

        doneButton.isEnabled = presenter.doneButtonIsEnabled(selectedIndexPath: indexPath)
    }

    private func clearAccessoryType(forSelectedIndex selectedItemIndex: Int) {
        let indexPath = IndexPath(row: selectedItemIndex, section: 0)
        playerTableView.cellForRow(at: indexPath)?.accessoryType = .none
    }

    func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
        tableView.cellForRow(at: indexPath)?.accessoryType = .none
    }
}

PlayerEditPresenter handles the business logic and exposes the properties for updating the UI elements.

final class PlayerEditPresenter: PlayerEditPresenterProtocol {

    // MARK: - Properties
    private weak var view: PlayerEditViewProtocol?
    private var playerEditModel: PlayerEditModel
    private var viewType: PlayerEditViewType
    private var playerItemsEditModel: PlayerItemsEditModel?
    private var service: StandardNetworkService

    // MARK: - Public API
    init(view: PlayerEditViewProtocol? = nil,
          viewType: PlayerEditViewType = .text,
          playerEditModel: PlayerEditModel,
          playerItemsEditModel: PlayerItemsEditModel? = nil,
          service: StandardNetworkService = StandardNetworkService(resourcePath: "/api/players", authenticated: true)) {
        self.view = view
        self.viewType = viewType
        self.playerEditModel = playerEditModel
        self.playerItemsEditModel = playerItemsEditModel
        self.service = service
    }

    // other methods
}

An API call is detailed below:

func updatePlayerBasedOnViewType(inputFieldValue: String?) {
    // [1] Check if we updated something.
    guard shouldUpdatePlayer(inputFieldValue: inputFieldValue) else { return }

    // [2] Present a loading indicator.
    view?.showLoadingView()

    let fieldValue = isSelectionViewType ? selectedItemValue : inputFieldValue

    // [3] Make the Network call.
    updatePlayer(newFieldValue: fieldValue) { [weak self] updated in
        DispatchQueue.main.async {
            self?.view?.hideLoadingView()
            self?.handleUpdatedPlayerResult(updated)
        }
    }
}

PlayerAdd, Confirm and Gather screens follow the same approach.

Testing our business logic

The testing approach is 90% the same as we did for MVVM.

In addition, we need to mock the view and check if the appropriate methods were called. For example, when a service API call is made, check if the view reloaded its state or handled the error in case of failures.

Unit Testing below GatherPresenter:

// [1] Basic setup
final class GatherPresenterTests: XCTestCase {

    // [2] Define the Mocked network classes.
    private let session = URLSessionMockFactory.makeSession()
    private let resourcePath = "/api/gathers"
    private let appKeychain = AppKeychainMockFactory.makeKeychain()

    // [3] Setup and clear the Keychain variables.
    override func setUp() {
        super.setUp()
        appKeychain.token = ModelsMock.token
    }

    override func tearDown() {
        appKeychain.storage.removeAll()
        super.tearDown()
    }
}

Testing the countdownTimerLabelText:

func testFormattedCountdownTimerLabelText_whenViewModelIsAllocated_returnsDefaultTime() {
    // given
    let gatherTime = GatherTime.defaultTime
    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 = GatherPresenter(gatherModel: mockGatherModel)

    // when
    let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText

    // then
    XCTAssertEqual(formattedCountdownTimerLabelText, "\(expectedFormattedMinutes):\(expectedFormattedSeconds)")
}

func testFormattedCountdownTimerLabelText_whenPresenterIsAllocated_returnsDefaultTime() {
    // given
    let gatherTime = GatherTime.defaultTime
    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 = GatherPresenter(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 = GatherPresenter(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 = GatherPresenter(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 = GatherPresenter(gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)

    // when
    let formattedCountdownTimerLabelText = sut.formattedCountdownTimerLabelText

    // then
    XCTAssertEqual(formattedCountdownTimerLabelText, "00:10")
}

Toggle timer becomes more interesting:

func testToggleTimer_whenSelectedTimeIsNotValid_returns() {
    // given
    let mockGatherTime = GatherTime(minutes: -1, seconds: -1)
    let mockGatherTimeHandler = GatherTimeHandler(selectedTime: mockGatherTime)
    let mockGatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 2)
    // [1] Allocate the mock view.
    let mockView = MockView()
    let sut = GatherPresenter(view: mockView, gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)

    // when
    sut.toggleTimer()

    // then
    // [2] configureSelectedTime() was not called.
    XCTAssertFalse(mockView.selectedTimeWasConfigured)
}

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)
    // [1] Configure the mock view parameters
    let exp = expectation(description: "Waiting timer expectation")
    let mockView = MockView()
    mockView.numberOfUpdateCalls = numberOfUpdateCalls
    mockView.expectation = exp

    let sut = GatherPresenter(view: mockView, gatherModel: mockGatherModel, timeHandler: mockGatherTimeHandler)

    // when
    sut.toggleTimer()

    // then
    // Selector should be called two times.
    waitForExpectations(timeout: 5) { _ in
        XCTAssertTrue(mockView.selectedTimeWasConfigured)
        XCTAssertEqual(mockView.actualUpdateCalls, numberOfUpdateCalls)
        sut.stopTimer()
    }
}

And below is the mock view:

/// This extension contains a mock view implementation used for testing purposes
private extension GatherPresenterTests {
    /// MockView conforms to GatherViewProtocol and helps in testing the GatherPresenter logic
    final class MockView: GatherViewProtocol {
        /// Indicates whether the selected time was configured in the view
        private(set) var selectedTimeWasConfigured = false

        weak var expectation: XCTestExpectation? = nil
        var numberOfUpdateCalls = 1
        private(set) var actualUpdateCalls = 0

        /// Configures the selected time when called
        func configureSelectedTime() {
            selectedTimeWasConfigured = true
            actualUpdateCalls += 1

            if expectation != nil && numberOfUpdateCalls == actualUpdateCalls {
                expectation?.fulfill()
            }
        }

        /// Handles the successful end of the gather process and fulfills the expectation
        func handleSuccessfulEndGather() {
            expectation?.fulfill()
        }

        /// Sets up the view (no implementation for testing purposes)
        func setupView() {}

        /// Shows a loading view (no implementation for testing purposes)
        func showLoadingView() {}

        /// Hides the loading view (no implementation for testing purposes)
        func hideLoadingView() {}

        /// Handles an error and displays the message (no implementation for testing purposes)
        func handleError(title: String, message: String) {}

        /// Confirms the end of the gather process (no implementation for testing purposes)
        func confirmEndGather() {}
    }
}

I’d say that testing the presenter is very cool. You don’t need to do magic stuff, and the methods are very small in size which is helping.
The complex thing comes with the fact that you will need to mock the View layer and check if some parameters are changing accordingly.

Key Metrics

Lines of code - View Controllers

File Number of lines of code MVVM - Lines of code MVC - Lines of code
PlayerAddViewController 59 77 (-18) 79 (-20)
PlayerListViewController 115 296 (-115) 387 (-272)
PlayerDetailViewController 85 96 (-11) 204 (-119)
LoginViewController 43 96 (-53) 126 (-83)
PlayerEditViewController 68 124 (-56) 212 (-144)
GatherViewController 73 227 (-154) 359 (-286)
ConfirmPlayersViewController 51 104 (-53) 260 (-209)
TOTAL 494 1020 (-526) 1627 (-1133)

Lines of code - Views

File Number of lines of code
PlayerAddView 75
PlayerListView 238
PlayerDetailView 77
LoginView 88
PlayerEditView 127
GatherView 198
ConfirmPlayersView 103
TOTAL 906

Lines of code - Presenters

File Number of lines of code MVVM - View Model LOC
LoginPresenter 111 75 (+36)
PlayerListPresenter 259 258 (+1)
PlayerEditPresenter 187 155 (+32)
PlayerAddPresenter 52 37 (+15)
PlayerDetailPresenter 195 178 (+17)
GatherPresenter 237 204 (+33)
ConfirmPlayersPresenter 195 206 (-11)
TOTAL 1236 1113 (+123)

Lines of code - Local Models

File Number of lines of code
PlayerListViewState 69
TeamSection 50
GatherTimeHandler 100
TOTAL 219

Unit Tests

Topic Data MVVM Data MVC Data
Number of key classes (ViewControllers, Views, Presenters, Local Models) 24 14 +10 7 +17
Key Class GatherPresenter GatherViewModel GatherViewController
Number of Unit Tests 34 34 30 +4
Code Coverage of Gathers feature 97.2% 97.3% -0.1 95.7% +1.5
How hard to write unit tests 3/5 3/5 5/5 -2

Build Times

Build Time (sec)* MVVM Time (sec)* MVC Time (sec)*
Average Build Time (after clean Derived Data & Clean Build) 10.18 9.65 +0.53 9.78 +0.4
Average Build Time 0.1 0.1 0.1
Average Unit Test Execution Time (after clean Derived Data & Clean Build) 16.52 17.88 -1.36 12.78 +3.74

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

Conclusion

In this refactor, we successfully transitioned the application from the MVVM architecture to MVP. The process was straightforward: we replaced each ViewModel with a corresponding Presenter layer, ensuring that our application followed the new pattern seamlessly.

Additionally, we introduced a new View layer, separating it from the ViewController to further clarify the division of responsibilities. This made the codebase cleaner, with thinner view controllers and smaller, more focused classes and functions that adhere to the Single Responsibility Principle (SRP).

Personally, I find the MVP pattern more intuitive, especially for apps built with UIKit. It offers a more natural approach compared to MVVM in this context.

Looking at the key metrics, we can draw the following conclusions:

  • The View Controllers are significantly thinner; we reduced their size by more than 1,000 lines of code.
  • A new View layer was introduced for managing UI updates, improving clarity and separation of concerns.
  • Presenters are larger than ViewModels due to their added responsibility of managing the views.
  • Unit testing was similar to the MVVM approach, resulting in almost identical code coverage of 97.2%.
  • While the number of files and classes increased, the impact on build time was minimal, increasing by just 530 ms compared to MVVM, and 400 ms compared to MVC.
  • Surprisingly, the average unit test execution time was faster by 1.36 seconds compared to MVVM.
  • Unit tests for business logic were considerably easier to write when compared to the MVC pattern.

It's exciting to see how transforming an app from MVVM to MVP can improve structure and maintainability. From my perspective, separating the View from the ViewController in MVP offers a cleaner and more powerful

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