Writing an app with VIPER architecture - Part 2

Writing an app with VIPER architecture - Part 2

I have written a part 1 of writing an iOS app using VIPER architecture. This covers fundamentals and basic example of using VIPER, creating models, entities and utilizing them in the app. This is the second and final part in the series. This will be very much similar to the first one, but with different models, classes and protocols.

first part covered a basic login screen. In this part I will cover another screen which is also written using the VIPER. This is bit more complicated than the first part, but comparable to the earlier version in terms of VIPER responsibility division among its components

We will call this view controller a ListViewController since its basic task will be display the list of items on the viewController.


// ListViewProtocol to which view conforms
protocol ListViewProtocol: class {
    func showDetails(userDetails: [String])
    func showLoading()
    func hideLoading()
    func selectedNameUpdater(with viewModel: ListViewModel)
    func showNextScreenError()
}

// ListViewController
class ListViewController: UIViewController {

    let listPresenter: ListPresenterProtocol
    var viewModel: ListViewModel?
    let activityIndicatorView: UIActivityIndicatorView
    let dismissButton: UIButton
    let loginButton: UIButton
    let namesLabel: UILabel
    let tableView: UITableView    

    init(listPresenter: ListPresenterProtocol) {
        self.listPresenter = listPresenter
        activityIndicatorView = UIActivityIndicatorView()
        activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
        activityIndicatorView.hidesWhenStopped = true
        self.dismissButton = UIButton()
        self.dismissButton.translatesAutoresizingMaskIntoConstraints = false
        self.dismissButton.setTitle("Dismiss", for: .normal)
        self.loginButton = UIButton()
        self.loginButton.translatesAutoresizingMaskIntoConstraints = false
        self.loginButton.setTitle("Loading...", for: .normal)
        self.namesLabel = UILabel()
        self.namesLabel.translatesAutoresizingMaskIntoConstraints = false
        self.namesLabel.textColor = .white
        self.tableView = UITableView()
        self.tableView.translatesAutoresizingMaskIntoConstraints = false                
        super.init(nibName: nil, bundle: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        self.activityIndicatorView.activityIndicatorViewStyle = .whiteLarge
        self.activityIndicatorView.color = .red
        self.view.backgroundColor = .purple
        self.namesLabel.numberOfLines = 0
        self.namesLabel.text = self.listPresenter.selectedName
        self.view.addSubview(dismissButton)
        self.view.addSubview(loginButton)
        self.view.addSubview(namesLabel)
        self.view.addSubview(tableView)
        self.view.addSubview(activityIndicatorView)
        self.dismissButton.addTarget(self, action: #selector(dismissDetails), for: .touchUpInside)
        self.loginButton.addTarget(self, action: #selector(moveToNextScreen), for: .touchUpInside)

        self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        self.tableView.delegate = self
        self.tableView.dataSource = self
        self.tableView.tableFooterView = UIView()

        let views = ["dismissButton": dismissButton, "loginButton": loginButton, "nameLabel": namesLabel, "tableView": tableView]

        self.view.addConstraint(NSLayoutConstraint(item: activityIndicatorView, attribute: .centerX, relatedBy: .equal, toItem: self.view, attribute: .centerX, multiplier: 1.0, constant: 0))
        self.view.addConstraint(NSLayoutConstraint(item: activityIndicatorView, attribute: .centerY, relatedBy: .equal, toItem: self.view, attribute: .centerY, multiplier: 1.0, constant: 0))

        self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-[dismissButton]-|", options: [], metrics: nil, views: views))
        self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-[loginButton]-|", options: [], metrics: nil, views: views))
        self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|-[nameLabel]-|", options: [], metrics: nil, views: views))
        self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[tableView]|", options: [], metrics: nil, views: views))

        self.view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|-64-[dismissButton(44)]-[nameLabel(>=0)][tableView][loginButton(44)]-|", options: [], metrics: nil, views: views))
        self.listPresenter.presentDetails(user: self.listPresenter.user)
    }

    @objc private func moveToNextScreen() {
        self.listPresenter.moveToNextScreen()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    @objc private func dismissDetails() {
        self.listPresenter.dismiss()
    }
}

// A conformance to tableView delegate and datasource protocols
extension ListViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        self.listPresenter.indexSelected(indexPath.row)
    }
}

extension ListViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.listPresenter.userDetails.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = self.listPresenter.userDetails[indexPath.row]
        return cell
    }
}

// A conformance to ListViewProtocol protocol
extension ListViewController: ListViewProtocol {
    func showDetails(userDetails: [String]) {
        self.tableView.reloadData()
    }

    func showLoading() {
        self.activityIndicatorView.startAnimating()
        self.loginButton.setTitle("Loading...", for: .normal)
    }

    func hideLoading() {
        self.activityIndicatorView.stopAnimating()
        self.loginButton.setTitle("Go to final Screen", for: .normal)
    }

    func selectedNameUpdater(with viewModel: ListViewModel) {
        self.viewModel = viewModel
        self.namesLabel.text = viewModel.selectedOption
        self.title = viewModel.selectedOptionTransform
        self.dismissButton.setTitle(viewModel.dismissTitle, for: .normal)
        self.loginButton.setTitle(viewModel.nextScreenButtonTitle, for: .normal)
        print(viewModel.successMessage)
    }

    func showNextScreenError() {
        print("Cannot move to next screen. Please select name to continue")
    }
}

Below it the ListViewModel struct we are using in the example,


struct ListViewModel {
    let successMessage: String
    let selectedOptionTransform: String
    let dismissTitle: String
    let nextScreenButtonTitle: String
    let selectedOption: String
}

Presenter


// List presenter protocol which ListPresenter conforms to
protocol ListPresenterProtocol: class {
    var view: ListViewProtocol? { get set }
    var selectedName: String? { get set }
    var user: User { get set }
    var userDetails: [String] { get set }
    func presentDetails(user: User)
    func detailsLoaded(details: [String])
    func indexSelected(_ index: Int)
    func moveToNextScreen()
    func dismiss()
}

class ListPresenter: ListPresenterProtocol {

    weak var view: ListViewProtocol?
    let interactor: ListInteractorProtocol
    let wireframe: ListWireframeProtocol
    var selectedName: String?
    var user: User
    var userDetails: [String]

    init(interactor: ListInteractorProtocol, wireframeProtocol: ListWireframeProtocol, user: User) {
        self.interactor = interactor
        self.wireframe = wireframeProtocol
        self.userDetails = []
        self.user = user
    }

    func presentDetails(user: User) {
        self.view?.showLoading()
        self.interactor.fetchDetails(user: user)
    }

    func detailsLoaded(details: [String]) {
        self.view?.hideLoading()
        self.userDetails = details
        self.view?.showDetails(userDetails: details)
    }

    func moveToNextScreen() {
        guard let name = self.selectedName, name.characters.count > 0 else {
            self.view?.showNextScreenError()
            return
        }
        self.wireframe.moveToFinalScreen(view: view!, details: name)
    }

    func dismiss() {
        self.wireframe.dismiss(view: view!)
    }

    func indexSelected(_ index: Int) {
        self.selectedName = self.userDetails[index]
        guard let name = self.selectedName else { return }
        self.view?.selectedNameUpdater(with: ListViewModel(successMessage: "Item Selected Successfully", selectedOptionTransform: "\(name) \(name)", dismissTitle: "Can't Dismiss now", nextScreenButtonTitle: "Ready to move to next screen", selectedOption: name))
    }
}

Presenter will also communicate with interactor and wireframe. Interactor will fetch the data to display and wireframe will be responsible for routing an app. Let's first look at the wireframe protocol and object whose reference is maintained by the presenter

Wireframe


// Wireframe protocol
protocol ListWireframeProtocol: class {
    func moveToFinalScreen(view: ListViewProtocol, details: String)
    func dismiss(view: ListViewProtocol)
}

// A wireframe class which conform to ListWireframeProtocol
class ListWireframe: ListWireframeProtocol {
    func moveToFinalScreen(view: ListViewProtocol, details: String) {
        view.navigationController?.pushViewController([sample_vc], animated: true)
    }

    func dismiss(view: ListViewProtocol) {
        if let view = view as? UIViewController {
            view.navigationController?.popViewController(animated: true)
        }
    }
}

Now, we will move to the next component in our chain - An interactor. Interactor will communicate with DataManager object and inform view of any updates through presenter.

Interactor


// Interactor protocol for delegate callbacks.
protocol ListInteractorProtocol: class {
    var presenter: ListPresenterProtocol? { get set }
    func fetchDetails(user: User)
    func detailsForUserFetched(result: [String])
}

// ListInteractor class which will conform to ListInteractorProtocol
class ListInteractor: ListInteractorProtocol {

    weak var presenter: ListPresenterProtocol?
    let dataManager: DataDownloader

    init(dataManager: DataDownloader) {
        self.dataManager = dataManager
    }

    func fetchDetails(user: User) {
        let when = DispatchTime.now() + 2
        DispatchQueue.main.asyncAfter(deadline: when) {
            self.dataManager.downloadItemsFor(name: user.username)
        }
    }

    func detailsForUserFetched(result: [String]) {
        self.presenter?.detailsLoaded(details: result)
    }
}

Since interactor deals with DataManager object, we will enlist it along with protocol it conforms to. DataManager will be the last link in our VIPER architecture which is ultimately responsible for data handling on one end

DataManager


// DataDownloader protocol
protocol DataDownloader: class {
    func downloadItemsFor(name: String)
    var itemsDetailsInteractor: ListInteractorProtocol? { get set }
}

// DataManager class conforming to DataDownloader
class DataManager: DataDownloader {

    weak var itemsDetailsInteractor: ListInteractorProtocol?

    func downloadItemsFor(name: String) {
        // We will add artificial delay to imitate the network request.
        let when = DispatchTime.now() + 0.5
        DispatchQueue.main.asyncAfter(deadline: when) {
            self.itemsDetailsInteractor?.detailsForUserFetched(result: ["A", "B", "C", "D", "E"])
        }
    }
}

Now, since we are done putting all the pieces together, we will now combine all the moving parts. Let's say we want to jump to this list from another viewController. This is how we will proceed with making all the necessary VIPER modules and passing them over to create a list view controller built from ground up using VIPER.


// dataManager class which conforms to DataDownloader
let dataManager: DataDownloader = DataManager()

// Interactor which gets initialized with dataManager object
let interactor: ListInteractorProtocol = ListInteractor(dataManager: dataManager)

// A wireframe/router which is responsible for routing viewController to the next screen
let wireframe: ListWireframeProtocol = ListWireframe()

// A presenter which has reference to interactor and router object forms the basis of communication between interactor and view
let listPresenter: ListPresenterProtocol = ListPresenter(interactor: interactor, wireframeProtocol: wireframe, user: user)

// A detailsVC which is of type ListViewController and conforms to ListViewProtocol and gets initialized with presenter object
let detailsVC: ListViewProtocol = ListViewController(listPresenter: listPresenter)

// Interactor should have reference to presenter
interactor.presenter = listPresenter

// presenter should have reference to view
listPresenter.view = detailsVC

// dataManager should have reference to interactor
dataManager.itemsDetailsInteractor = interactor

// view here is the view currently presented and detailsVC represents the list viewController we're making transition to after making all the VIPER components.
if let view = view as? UIViewController, let detailsVC = detailsVC as? UIViewController {
    view.navigationController?.pushViewController(detailsVC, animated: true)
}

This is simple demo screen of how app will look like on user interaction

initial_list_loading

initial_list_state

after_list_selection

And this should be it. If you have any questions about VIPER I will be free to answer them for you. The code is hosted on GitHub with some more examples.