Swift MVP: A Step-by-Step Guide for Clean Code

Gerald Brigen
5 min readJun 6, 2023

--

The Model-View-Presenter (MVP) architecture is a software design pattern commonly used to organize code in a clean and understandable manner. The MVP pattern allows for a separation of concerns in your codebase, making it more testable and scalable. Here, we’ll walk you through implementing the MVP pattern in Swift.

Understanding MVP

Before diving into the step-by-step process, let’s quickly review the concepts behind MVP:

- Model: This represents the data and business logic of the application. It’s responsible for fetching, storing, and manipulating the application data.

- View: The View is responsible for anything that has to do with user interface and user interaction. In iOS applications, this is typically a UIViewController.

- Presenter: The Presenter acts as a bridge between the Model and the View. It fetches data from the Model, applies the necessary transformations, and then passes the data to the View to be displayed.

Example Project

Let’s take a practical example to illustrate how MVP works. Imagine we are creating a simple application that fetches a list of users from an API and displays them.

Step 1: Define the Model

The first step is defining the data structure for a user. In Swift, we can use a `struct` for this:

struct User {
let id: Int
let name: String
let email: String
}

This model will be used to map the JSON response from the API.

Step 2: Create the Model Interface

We’ll define a protocol that our data manager (the component responsible for fetching the data from the API) will conform to:

protocol DataManagerProtocol {
func fetchUsers(completion: @escaping ([User]?, Error?) -> Void)
}

The `fetchUsers` function fetches a list of users and uses a closure to return the result asynchronously.

Step 3: Implement the Model

Next, let’s implement a DataManager that conforms to DataManagerProtocol. This class will be responsible for making the actual API calls:

class DataManager: DataManagerProtocol {
func fetchUsers(completion: @escaping ([User]?, Error?) -> Void) {
// Code to fetch users from API goes here
}
}

This is where we would use URLSession to fetch data from the API and map it to our User model. The result is then passed back via the `completion` closure.

Step 4: Define the View Protocol

We’ll define another protocol, this time for our view:

protocol UserViewProtocol: AnyObject {
func showUsers(users: [User])
func showError(error: String)
}

The showUsers method displays the list of users, and showError displays an error message to the user.

Step 5: Implement the View

Now, we implement the UserViewProtocol in our UIViewController:

class UserViewController: UIViewController, UserViewProtocol {
var presenter: UserPresenterProtocol!

override func viewDidLoad() {
super.viewDidLoad()
presenter.viewLoaded()
}

func showUsers(users: [User]) {
// Code to update UI with the list of users goes here
}

func showError(error: String) {
// Code to show an error message to the user goes here
}
}

When the view loads, it notifies the presenter so that it can fetch the data. The presenter will then call either `showUsers` or `showError`, depending on the result of the fetch operation.

Step 6: Define the Presenter Protocol

Similar to the model and view, we’ll define a protocol for the presenter as well:

protocol UserPresenterProtocol: AnyObject {
func viewLoaded()
}

In our example, we just have one method: `viewLoaded`, which is called when the view finishes loading.

Step 7: Implement the Presenter

Finally, let’s implement the presenter. The presenter will have references to the view and the data manager:

class UserPresenter: UserPresenterProtocol {
weak var view: UserViewProtocol?
var dataManager: DataManagerProtocol

init(view: UserViewProtocol, dataManager: DataManagerProtocol) {
self.view = view
self.dataManager = dataManager
}

func viewLoaded() {
dataManager.fetchUsers { [weak self] users, error in
if let error = error {
self?.view?.showError(error: error.localizedDescription)
} else if let users = users {
self?.view?.showUsers(users: users)
}
}
}
}

When the view loads, the presenter asks the data manager to fetch the users. Once the data is fetched, the presenter either calls showUsers or showError on the view, depending on the result.

Step 8: Wiring it All Together

The last step is to create and connect these components. This usually happens when the view is being created. Here’s how it might look in the AppDelegate:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let viewController = UserViewController()
let dataManager = DataManager()
let presenter = UserPresenter(view: viewController, dataManager: dataManager)
viewController.presenter = presenter

// Set up the rest of your app
return true
}

This code creates instances of the view, data manager, and presenter, and connects them together. The presenter is given references to the view and data manager, and the view is given a reference to the presenter.

In some iOS applications, especially those using SwiftUI or multiple scenes, you’ll use the SceneDelegate instead of the AppDelegate to configure your initial view controller. This is part of Apple’s more recent design intended to support multiple windows in the same application, which was introduced with iOS 13.

Here’s how you might create and connect your components in the SceneDelegate:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }

let viewController = UserViewController()
let dataManager = DataManager()
let presenter = UserPresenter(view: viewController, dataManager: dataManager)
viewController.presenter = presenter

let window = UIWindow(windowScene: windowScene)
window.rootViewController = UINavigationController(rootViewController: viewController)
self.window = window
window.makeKeyAndVisible()
}

In this case, we’re creating an instance of UserViewController, DataManager, and UserPresenter in the SceneDelegate rather than the AppDelegate. The UserViewController is set as the root view controller of the navigation controller, which is then set as the root view controller of the window. This setup allows the use of multiple scenes and is a more modern approach, especially for applications that may require multiple windows or are using SwiftUI.

Conclusion

The MVP architecture can seem complex at first, but it offers many advantages. By separating concerns, it makes your code more modular, easier to understand, and easier to test. It also encourages the development of reusable components.

This step-by-step guide illustrates how to implement MVP in a Swift project. Of course, every project is different, and you might need to adapt this pattern to suit your specific needs. However, the principles of separating concerns and maintaining clear interfaces remain fundamental for writing clean, maintainable code.

--

--