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