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
- Model View Controller (MVC)
- Model View ViewModel (MVVM)
- Model View Presenter (MVP) - Current Article
- Model View Presenter with Coordinators (MVP-C)
- View Interactor Presenter Entity Router (VIPER)
- View Interactor Presenter (VIP)
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

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:
- Converting existing ViewModels into Presenters.
- 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 thelistView
mode when a gather is completed (called fromGatherViewController
). - Holds an
IBOutlet
toPlayerListView
. - 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.
- Changing the title when requested (
- 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 |