La fabrique mobile Viseo Toulouse

MVC Composition (Part 3)

In this third post about MVC Composition i use a Medium article from Alfian Losari as a starting point:

https://medium.com/@alfianlosari/refactor-mvc-ios-app-to-mvvm-with-rxswift-in-minutes-alfian-losari-ec7905f946f4

This article is interesting because it takes a classic MVC App that displays movie information from https://www.themoviedb.org and refactors it as an MVVM Application with RxSwift.

So what we'll see is how to write the same application using MVC-Composition architecture.

This way we can compare the 3 architectures for the same project:

  • Classic MVC
  • MVVM + RxSwift
  • MVC-Composition

The code for the following article can be found in this repository:

https://github.com/giln/MVC-Composition-Part3.git

This application has two main screens:

  • a "Featured" screen that displays Movies in different categories:
Result
  • a "Search" screen that searching for movies:
Result

To get started, clone the repository and switch to the "start" branch:

https://github.com/giln/MVC-Composition-Part3/tree/start

If you open the project with Xcode and try to compile, you get an error:

Result

Before you can compile, you need to get an API Key from https://developers.themoviedb.org/3/

You need to register then click on the "API" link from within your account settings page. It is quite fast and only takes a couple of minutes.

Once you have your API Key, you just need to create a TMDBAPIKey.swift file in the Classes/Model directory of the project with the following content:

// Replace with your API Key
let TMDBAPIKey = "XXXXXXXXXXXXX"

Once it's done the project should compile without error.

At this point the project only contains the model and service classes which i copied directly from Alfian Losari project and a few helper classes i use.

One of the most important Helper class is the UIViewController+Container extension:

import UIKit

/// Extension used to facilitate adding child viewControllers in a viewController
public extension UIViewController {
    /// Embeds a view controller and also adds it's view in the view hierarchay
    ///
    /// - Parameter viewController: ViewController to add
    func add(asChildViewController viewController: UIViewController, anchored: Bool = true, subview: UIView? = nil) {
        let someView: UIView = subview ?? view

        // Add Child View Controller
        addChild(viewController)

        // Add Child View as Subview
        someView.addSubview(viewController.view)

        if anchored {
            // Embeded viewControllers should not use safeAnchors
            someView.anchor(view: viewController.view, useSafeAnchors: false)
        }

        // Notify Child View Controller after
        viewController.didMove(toParent: self)
    }

    /// Removes a view controller from both view controller and view hierachies
    ///
    /// - Parameter viewControllerToRemove: ViewController to remove
    func remove(viewControllerToRemove: UIViewController?) {
        guard let viewController = viewControllerToRemove else {
            return
        }

        // Notify Child View Controller before
        viewController.willMove(toParent: nil)

        // Remove View
        viewController.view.removeFromSuperview()

        // Remove ViewController
        viewController.removeFromParent()
    }
}

To understand how this extension works, please refer to my first post on MVC-Composition

The project is also configured with a "MVCMovieInfoFramework" target that contains all model classes from project and allows using them from within Xcode playgrounds. You can refer to my Playground Driven Development serie to get a better understand of how it works.

To test it, open the Blank.playground file at top of project:

import PlaygroundSupport
import UIKit

@testable import MVCMovieInfoFramework

NSSetUncaughtExceptionHandler { exception in
    print("💥 Exception thrown: \(exception)")
}

PlaygroundPage.current.needsIndefiniteExecution = true


let movieStore = MovieStore.shared

movieStore.fetchMovies(from: .topRated, params: nil, successHandler: { moviesResponse in
    print(moviesResponse)
}) { error in
    print(error)
}

If you run your playground it should fetch the Top rated movies using the MovieStore and print them to the console.

From there we'll start building our application using "Playgrounds driven development".

We start by creating a simple ListViewController class in our Playground:

import PlaygroundSupport
import UIKit

@testable import MVCMovieInfoFramework

NSSetUncaughtExceptionHandler { exception in
    print("💥 Exception thrown: \(exception)")
}

PlaygroundPage.current.needsIndefiniteExecution = true

public protocol Listable {
    var title: String { get }
}

open class ListViewController: UITableViewController {
    // MARK: - Variables
    open var list = [Listable]() {
        didSet {
            tableView.reloadData()
        }
    }

    // MARK: Lifecycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        setupTableView()
    }

    private func setupTableView() {
        tableView.tableFooterView = UIView()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: UITableViewCell.defaultReuseIdentifier)
        tableView.rowHeight = UITableView.automaticDimension
    }

    // MARK: - TableViewDataSource
    open override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
        return list.count
    }

    open override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        let cell = tableView.dequeueReusableCell(withIdentifier: UITableViewCell.defaultReuseIdentifier, for: indexPath)

        let element = list[indexPath.row]
        cell.textLabel?.text = element.title

        return cell
    }
}

let listController = ListViewController()

extension Movie: Listable {

}

let movieStore = MovieStore.shared

movieStore.fetchMovies(from: .topRated, params: nil, successHandler: { moviesResponse in
    print(moviesResponse)
    listController.list = moviesResponse.results
}) { error in
    print(error)
}

PlaygroundPage.current.liveView = listController

This class is a simple UITableViewController. It's single responsibility is to display a list of Movies. But what's important is that we create a "Listable" Protocol to decouple our ListViewController from the model.

public protocol Listable {
    var title: String { get }
}

This way we can achieve a better reusability of our ListViewController. Also you can see that the ListViewController does not fetch any data. It's not it's reponsibility. It just reloads the tableView every time it receives an array of "Listable":

open var list = [Listable]() {
    didSet {
        tableView.reloadData()
    }
}

The code at the end of our playground allows us to test our viewController. Open Live View using assistant editor and run your playground:

Result

To run correctly you see that we had to make our Movie model conform to the Listable protocol with an extension. The extension at this point is empty because our Movie model already has a "title" property. We see the result in the assistant editor on the right. Also note that the tableview is interactive (scroll etc...).

Next thing we want to do is design a better cell for the table view. We need a way to download images for the movie posters. Original project from Alfian Losari uses Kingfisher but i use my own class (DownloadImageView) to avoid dependencies:

import UIKit

public class DownloadImageView: UIImageView, DataFetching {

    // MARK: - Variables
    public var placeHolder: UIImage?

    public var url: URL? {
        didSet {
            image = placeHolder
            if let downloadURL = url {
                fetchData(url: downloadURL) { data, _ in

                    guard let data = data else {
                        DispatchQueue.main.async {
                            self.image = self.placeHolder
                        }
                        return
                    }

                    let image = UIImage(data: data)
                    DispatchQueue.main.async {
                        // On vérifie que l'URL n'a pas changé
                        if downloadURL == self.url {
                            self.image = image
                        }
                    }
                }
            }
        }
    }
}

This class uses the DataFetching protocol to make network requests:

import Foundation

public protocol DataFetching {
    func fetchData(url: URL, completion: @escaping (Data?, Error?) -> Void)
}

extension DataFetching {
    public func fetchData(url: URL, completion: @escaping (Data?, Error?) -> Void) {

        let session = URLSession.shared

        session.dataTask(with: url) { (data, response, error) in
            completion(data, error)
            }.resume()

    }
}

After we add both classes in our project, we can start designing our cell in a new Playground:

import PlaygroundSupport
import UIKit

@testable import MVCMovieInfoFramework

open class ImageWithFourLabelView: UIView {
    // MARK: - Variables
    private let horizontalStackView = UIStackView()
    private let verticalStackView = UIStackView()

    public let imageView = DownloadImageView()
    public let firstLabel = UILabel()
    public let secondLabel = UILabel()
    public let thirdLabel = UILabel()
    public let fourthLabel = UILabel()

    // MARK: - Lifecycle
    public override init(frame: CGRect) {
        super.init(frame: frame)

        commonInit()
    }

    public required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        commonInit()
    }

    // MARK: - Private
    private func commonInit() {
        backgroundColor = UIColor.white

        firstLabel.font = UIFont.preferredFont(forTextStyle: .headline)
        firstLabel.adjustsFontForContentSizeCategory = true
        secondLabel.numberOfLines = 3
        thirdLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
        thirdLabel.adjustsFontForContentSizeCategory = true

        verticalStackView.axis = .vertical
        verticalStackView.distribution = .fill
        verticalStackView.alignment = .leading
        verticalStackView.spacing = 10

        horizontalStackView.axis = .horizontal
        horizontalStackView.distribution = .fill
        horizontalStackView.alignment = .center
        horizontalStackView.spacing = 20
        horizontalStackView.preservesSuperviewLayoutMargins = true
        horizontalStackView.isLayoutMarginsRelativeArrangement = true

        addSubview(horizontalStackView)

        horizontalStackView.anchor(in: self)

        horizontalStackView.addArrangedSubview(imageView)
        horizontalStackView.addArrangedSubview(verticalStackView)

        imageView.widthAnchor.constraint(equalToConstant: 90).isActive = true
        imageView.heightAnchor.constraint(equalToConstant: 120).isActive = true

        imageView.backgroundColor = UIColor.black
        verticalStackView.addArrangedSubview(firstLabel)
        verticalStackView.addArrangedSubview(secondLabel)
        verticalStackView.addArrangedSubview(thirdLabel)
        verticalStackView.addArrangedSubview(fourthLabel)
    }
}

let view = ImageWithFourLabelView(frame: CGRect(x: 0, y: 0, width: 375, height: 150))

view.firstLabel.text = "Title"
view.secondLabel.text = "Description"
view.thirdLabel.text = "Date"
view.fourthLabel.text = "⭐️"

PlaygroundPage.current.liveView = view

As you can see, it's a simple UIView, not a UITableViewCell. This allows better reuse for this view as we can use it in a TableViewCell or in any ViewController. Execute playgrounds to display and test your View Layout:

Result

To use this view from a tableViewCell we just create a TableViewCell to embed it:

import UIKit

open class ImageWithFourLabelCell: UITableViewCell {

    public let layout = ImageWithFourLabelView()

    // MARK: - Lifecycle
    public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        commonInit()
    }
    public required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        commonInit()
    }

    private func commonInit() {
        contentView.addSubview(layout)
        layout.anchor(in: contentView)
    }
}

After adding both classes in our project, we can start to modify our ListViewController to use our new cell:

import PlaygroundSupport
import UIKit

@testable import MVCMovieInfoFramework

NSSetUncaughtExceptionHandler { exception in
    print("💥 Exception thrown: \(exception)")
}

PlaygroundPage.current.needsIndefiniteExecution = true

public protocol Listable {
    var firstText: String { get }
    var secondText: String { get }
    var thirdText: String { get }
    var fourthText: String { get }
    var imageUrl: URL { get }
}

public extension ImageWithFourLabelView {
    func configure(with element: Listable) {
        firstLabel.text = element.firstText
        secondLabel.text = element.secondText
        thirdLabel.text = element.thirdText
        fourthLabel.text = element.fourthText
        imageView.url = element.imageUrl
    }
}

open class ListViewController: UITableViewController {
    // MARK: - Variables
    open var list = [Listable]() {
        didSet {
            tableView.reloadData()
        }
    }

    // MARK: Lifecycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        setupTableView()
    }

    private func setupTableView() {
        tableView.tableFooterView = UIView()
        tableView.register(ImageWithFourLabelCell.self, forCellReuseIdentifier: ImageWithFourLabelCell.defaultReuseIdentifier)
        tableView.rowHeight = UITableView.automaticDimension
    }

    // MARK: - TableViewDataSource
    open override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
        return list.count
    }

    open override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: ImageWithFourLabelCell.defaultReuseIdentifier, for: indexPath) as! ImageWithFourLabelCell

        let element = list[indexPath.row]
        cell.layout.configure(with: element)

        return cell
    }
}

extension Movie: Listable {
    private static let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .none
        return formatter
    }()

    public var firstText: String {
        return title
    }

    public var secondText: String {
        return overview
    }

    public var thirdText: String {
        return Movie.dateFormatter.string(from: releaseDate)
    }

    public var fourthText: String {
        let rating = Int(voteAverage)
        let ratingText = (0 ..< rating).reduce("") { (acc, _) -> String in
            return acc + "⭐️"
        }
        return ratingText
    }

    public var imageUrl: URL {
        return posterURL
    }
}


let listController = ListViewController()

let movieStore = MovieStore.shared

movieStore.fetchMovies(from: .topRated, params: nil, successHandler: { moviesResponse in
    print(moviesResponse)
    listController.list = moviesResponse.results
}) { error in
    print(error)
}

PlaygroundPage.current.liveView = listController

You can see we started by modifying our Listable Protocol by adding new variables:

public protocol Listable {
    var firstText: String { get }
    var secondText: String { get }
    var thirdText: String { get }
    var fourthText: String { get }
    var imageUrl: URL { get }
}

This means that our Movie model must conform to Listable protocol before it can be displayed in our TableView:

extension Movie: Listable {
    private static let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .none
        return formatter
    }()

    public var firstText: String {
        return title
    }

    public var secondText: String {
        return overview
    }

    public var thirdText: String {
        return Movie.dateFormatter.string(from: releaseDate)
    }

    public var fourthText: String {
        let rating = Int(voteAverage)
        let ratingText = (0 ..< rating).reduce("") { (acc, _) -> String in
            return acc + "⭐️"
        }
        return ratingText
    }

    public var imageUrl: URL {
        return posterURL
    }
}

If you look at the above extension, it looks a lot like the code from the ViewModel in the MVVM architecture.

After running our playground we get the following result in the LiveView:

Result

What's nice is that our ListViewController is highly reusable. It can display any model that conforms to the Listable Protocol.

Once we are satisfied with the result from the playground, we just need to copy our classes to our Project and test it from the application.

To test our application it we temporarily move the code that fetches the movies in the AppDelegate:

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?

    func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow(frame: UIScreen.main.bounds)
        window?.backgroundColor = UIColor.white
        window?.makeKeyAndVisible()

        let listController = ListViewController()

        let movieStore = MovieStore.shared

        movieStore.fetchMovies(from: .topRated, params: nil, successHandler: { moviesResponse in
            print(moviesResponse)
            listController.list = moviesResponse.results
        }) { error in
            print(error)
        }

        window?.rootViewController = listController

        return true
    }
}

Here is the result in the simulator:

Result

Of course, fetching data is not the responsibility of the AppDelegate, so we create a new MovieFetcherViewController for this:

import UIKit

open class MovieFetcherViewController: UIViewController {
    // MARK: - Variables
    public var movieStore = MovieStore.shared
    private let listController = ListViewController()

    // MARK: - Lifecycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        add(asChildViewController: listController)
    }

    open override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        movieStore.fetchMovies(from: .topRated, params: nil, successHandler: { [weak self] moviesResponse in

            self?.listController.list = moviesResponse.results
        }) { error in
            print(error)
        }

    }
}

As you can see, the fetchMovies is performed in the viewWillAppear. This is important because it allows us to load our viewController without performing the network request until the ViewController is about to be displayed on screen. This is going to be important for our next View Controller, the SegmentedViewController:

import UIKit

open class SegmentedViewController: UIViewController {

    public private(set) var selectedViewControllerIndex = 0

    // MARK: - Public variables
    /// List of ViewControllers. View Controller Titles are used as segment titles.
    public var items = [UIViewController]() {
        didSet {
            // Remove previous viewControllers
            for previousItem in oldValue {
                self.remove(viewControllerToRemove: previousItem)
            }
            // Remove segments
            segmentedControl.removeAllSegments()

            // Add new segments and first viewController
            for (index, item) in items.enumerated() {
                segmentedControl.insertSegment(withTitle: item.title, at: index, animated: false)

                if index == selectedViewControllerIndex {
                    segmentedControl.selectedSegmentIndex = selectedViewControllerIndex
                    add(asChildViewController: item, anchored: true, subview: containerView)
                }
            }
        }
    }

    // MARK: - Private variables
    private let segmentedControl = UISegmentedControl()
    private let containerView = UIView()
    private let stackView = UIStackView()
    private let topStackView = UIStackView()

    // MARK: - LifeCycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        topStackView.axis = .vertical
        topStackView.alignment = .center
        topStackView.distribution = .equalSpacing
        topStackView.isLayoutMarginsRelativeArrangement = true
        topStackView.preservesSuperviewLayoutMargins = true

        view.backgroundColor = .white
        stackView.axis = .vertical
        stackView.distribution = .fill
        stackView.alignment = .fill
        stackView.spacing = 5

        view.addSubview(stackView)
        view.anchor(view: stackView, useSafeAnchors: false)

        stackView.addArrangedSubview(topStackView)
        topStackView.addArrangedSubview(segmentedControl)
        stackView.addArrangedSubview(containerView)

        segmentedControl.addTarget(self, action: #selector(segmentDidChange(segment:)), for: .valueChanged)
    }

    // MARK: - Actions
    @objc
    private func segmentDidChange(segment: UISegmentedControl) {

        selectController(at: segment.selectedSegmentIndex)

        print(segment.selectedSegmentIndex)
    }

    private func selectController(at index: Int ) {

        let item = items[index]

        add(asChildViewController: item, anchored: true, subview: containerView)
        remove(viewControllerToRemove: items[selectedViewControllerIndex])
        selectedViewControllerIndex = index
    }
}

This SegmentedViewController class displays a list of UIViewControllers stored in the "items" variable. The title of each ViewController is used to create the segments.

Our final application displays four segments at the top of the screen:

  • Now playing
  • Popular
  • upcoming
  • Top Rated

Each segment corresponds to an EndPoint from the MovieStore.

So we modify our MovieListFetcherViewController and Add the endPoint as a parameter to fetch the movies:

import UIKit

open class MovieListFetcherViewController: UIViewController {
    // MARK: - Variables
    public var movieStore = MovieStore.shared
    private let listController = ListViewController()

    public var endpoint: Endpoint = .nowPlaying {
        didSet {
            title = endpoint.description
        }
    }

    // MARK: - Lifecycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        title = endpoint.description
        add(asChildViewController: listController)
    }

    open override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        movieStore.fetchMovies(from: endpoint, params: nil, successHandler: { [weak self] moviesResponse in

            self?.listController.list = moviesResponse.results
        }) { error in
            print(error)
        }
    }
}

This allows us to instantiate the MovieListFetcherViewController 4 times, each with a different endpoint, and pass all 4 as an Array to our SegmentedViewController. We can test this in our Playground:

Result

We refactor this code in a new MovieListCoordinator view Controller:

import UIKit

open class MovieListCoordinator: UIViewController {
    // MARK: - Variables
    let segmentedController = SegmentedViewController()
    let topMovieFetcherViewController = MovieListFetcherViewController()
    let nowPlayingMovieFetcherViewController = MovieListFetcherViewController()
    let popularMovieFetcherViewController = MovieListFetcherViewController()
    let upcomingMovieFetcherViewController = MovieListFetcherViewController()

    // MARK: - Lifecycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        add(asChildViewController: segmentedController)

        topMovieFetcherViewController.endpoint = .topRated
        nowPlayingMovieFetcherViewController.endpoint = .nowPlaying
        popularMovieFetcherViewController.endpoint = .popular
        upcomingMovieFetcherViewController.endpoint = .upcoming

        segmentedController.items = [nowPlayingMovieFetcherViewController, upcomingMovieFetcherViewController, popularMovieFetcherViewController, topMovieFetcherViewController]
    }
}

To be complete we need to manage errors and loading time. To do this we create an ErrorViewController:

import UIKit

open class ErrorViewController: UIViewController {

    // MARK: - Variables
    public let errorLabel = UILabel()

    // MARK: - Lifecycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = UIColor.white
        errorLabel.numberOfLines = 0
        errorLabel.textAlignment = .center

        view.addSubview(errorLabel)

        errorLabel.anchor(in: view)
    }
}

and a LoadingViewController:

import UIKit

open class LoadingViewController: UIViewController {
    // MARK: - Variables
    public let activityIndicator = UIActivityIndicatorView(style: .gray)

    // MARK: - Lifecycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = UIColor.white
        activityIndicator.hidesWhenStopped = true

        view.addSubview(activityIndicator)

        activityIndicator.center(in: view)
    }

    open override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        activityIndicator.startAnimating()
    }

    open override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        activityIndicator.stopAnimating()
    }
}

This ViewController just adds a UIActivityIndicator in it's center. The indicators starts animating in the viewWillAppear and stops in the viewWillDisappear.

The responsibility to manage between the loading, error and movie states is done with the ListStateViewController:

import UIKit

open class ListStateViewController: UIViewController {
    public enum State {
        case loading
        case list([Listable])
        case empty(String)
        case error(String)
    }

    // MARK: - Variables
    public var state = State.empty("") {
        didSet {
            switch state {
            case .loading:
                remove(viewControllerToRemove: emptyViewController)
                remove(viewControllerToRemove: errorViewController)
                remove(viewControllerToRemove: listViewController)
                add(asChildViewController: loadingViewController)
            case let .list(list):
                listViewController.list = list
                remove(viewControllerToRemove: emptyViewController)
                remove(viewControllerToRemove: errorViewController)
                remove(viewControllerToRemove: loadingViewController)
                add(asChildViewController: listViewController)
            case let .empty(emptyString):
                emptyViewController.errorLabel.text = emptyString
                remove(viewControllerToRemove: listViewController)
                remove(viewControllerToRemove: errorViewController)
                remove(viewControllerToRemove: loadingViewController)
                add(asChildViewController: emptyViewController)
            case let .error(errorString):
                errorViewController.errorLabel.text = errorString
                remove(viewControllerToRemove: emptyViewController)
                remove(viewControllerToRemove: listViewController)
                remove(viewControllerToRemove: loadingViewController)
                add(asChildViewController: errorViewController)
            }
        }
    }

    private let loadingViewController = LoadingViewController()
    private let emptyViewController = ErrorViewController()
    private let errorViewController = ErrorViewController()
    private let listViewController = ListViewController()

    // MARK: - Lifecycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        addChild(listViewController)
    }
}

To use it we just modify our MovieListFetcherViewController:

import UIKit

open class MovieListFetcherViewController: UIViewController {
    // MARK: - Variables
    public let listStateController = ListStateViewController()

    public let movieStore = MovieStore.shared

    public var endpoint: Endpoint = .nowPlaying {
        didSet {
            title = endpoint.description
        }
    }

    // MARK: - Lifecycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        title = endpoint.description
        add(asChildViewController: listStateController)
    }

    open override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        listStateController.state = .loading

        movieStore.fetchMovies(from: endpoint, params: nil, successHandler: { moviesResponse in

            self.listStateController.state = .list(moviesResponse.results)
        }) { error in

            self.listStateController.state = .error(error.localizedDescription)
        }
    }
}

As you can see we have a lot of ViewControllers just for a single screen of the application. However all our ViewControllers have a Single Responsibility and most of them are under 50 lines of code. This makes them easy to maintain and reuse.

Result

We are going to see now that we can reuse most of our ViewControllers for the Search part of the application.

Back to our Playgrounds, we create a new MovieSearchViewController:

import PlaygroundSupport
import UIKit

@testable import MVCMovieInfoFramework

PlaygroundPage.current.needsIndefiniteExecution = true

NSSetUncaughtExceptionHandler { exception in
    print("💥 Exception thrown: \(exception)")
}


open class MovieSearchViewController: UIViewController, UISearchResultsUpdating {
    var service: MovieService = MovieStore.shared

    // MARK: - Variables
    private let listStateViewController = ListStateViewController()
    private let searchController = UISearchController(searchResultsController: nil)

    // MARK: - Lifecycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        title = "Search Movie"
        view.backgroundColor = UIColor.white
        add(asChildViewController: listStateViewController)
        listStateViewController.state = .empty("Start searching your favorite movies")

        setupNavigationBar()
    }

    // MARK: - Private
    private func setupNavigationBar() {
        navigationItem.searchController = searchController
        definesPresentationContext = true

        searchController.searchResultsUpdater = self
        searchController.dimsBackgroundDuringPresentation = false
        searchController.hidesNavigationBarDuringPresentation = false

        navigationItem.hidesSearchBarWhenScrolling = false
        navigationController?.navigationBar.prefersLargeTitles = true
        navigationItem.largeTitleDisplayMode = .automatic
    }

    open func searchMovie(query: String?) {
        guard let query = query, !query.isEmpty else {
            listStateViewController.state = .empty("Start searching your favorite movies")
            return
        }

        listStateViewController.state = .loading

        service.searchMovie(query: query, params: nil, successHandler: {
            [unowned self] response in

            self.listStateViewController.state = .list(response.results)

            }, errorHandler: {
                [unowned self] error in

                self.listStateViewController.state = .error(error.localizedDescription)
        })
    }

    // MARK: - UISearchResultsUpdating
    public func updateSearchResults(for _: UISearchController) {
        searchMovie(query: searchController.searchBar.text)
    }
}


let movieSearch = MovieSearchViewController()
let navigationController = UINavigationController(rootViewController: movieSearch)

PlaygroundPage.current.liveView = navigationController

This ViewController reuses most of our previous ViewControllers (StateViewController, ListViewController...) to display the search results:

Result

However we feel like this ViewController has 2 responsibilities (Displaying and managing the Search Controller and Fetching the data) so we split it into two ViewControllers:

A MovieSearchFetcherViewController whose responsibility is to fetch the data:

import UIKit

open class MovieSearchFetcherViewController: UIViewController {
    var service: MovieService = MovieStore.shared

    // MARK: - Variables
    private let listStateViewController = ListStateViewController()
    private let searchController = UISearchController(searchResultsController: nil)

    // MARK: - Lifecycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        add(asChildViewController: listStateViewController)
        listStateViewController.state = .empty("Start searching your favorite movies")
    }

    // MARK: - Public
    open func searchMovie(query: String?) {
        guard let query = query, !query.isEmpty else {
            listStateViewController.state = .empty("Start searching your favorite movies")
            return
        }

        listStateViewController.state = .loading

        service.searchMovie(query: query, params: nil, successHandler: {
            [unowned self] response in

            self.listStateViewController.state = .list(response.results)

        }, errorHandler: {
            [unowned self] error in

            self.listStateViewController.state = .error(error.localizedDescription)
        })
    }
}

and a MovieSearchViewController whose reponsilibity is to display and manage the Searchbar:

import UIKit

open class MovieSearchViewController: UIViewController, UISearchResultsUpdating {
    var service: MovieService = MovieStore.shared

    // MARK: - Variables
    private let movieSearchFetcherViewController = MovieSearchFetcherViewController()
    private let searchController = UISearchController(searchResultsController: nil)

    // MARK: - Lifecycle
    open override func viewDidLoad() {
        super.viewDidLoad()

        title = "Search Movie"
        view.backgroundColor = UIColor.white
        setupNavigationBar()

        add(asChildViewController: movieSearchFetcherViewController)
    }

    // MARK: - Private
    private func setupNavigationBar() {
        navigationItem.searchController = searchController
        definesPresentationContext = true

        searchController.searchResultsUpdater = self
        searchController.dimsBackgroundDuringPresentation = false
        searchController.hidesNavigationBarDuringPresentation = false

        navigationItem.hidesSearchBarWhenScrolling = false
        navigationController?.navigationBar.prefersLargeTitles = true
        navigationItem.largeTitleDisplayMode = .automatic
    }

    // MARK: - UISearchResultsUpdating
    public func updateSearchResults(for _: UISearchController) {
        movieSearchFetcherViewController.searchMovie(query: searchController.searchBar.text)
    }
}

Now we just need to use a TabBarController to assemble all of this in the AppDelegate (temporarily because it should be moved in a RootViewCoordinator):

import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?

    func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow(frame: UIScreen.main.bounds)
        window?.backgroundColor = UIColor.white
        window?.makeKeyAndVisible()

        let movieListCoordinator = MovieListCoordinator()
        movieListCoordinator.tabBarItem = UITabBarItem(tabBarSystemItem: .featured, tag: 0)

        let searchController = MovieSearchViewController()
        searchController.tabBarItem = UITabBarItem(tabBarSystemItem: .search, tag: 0)
        let navBar = UINavigationController(rootViewController: searchController)

        let tabbarController = UITabBarController()
        tabbarController.viewControllers = [movieListCoordinator, navBar]

        window?.rootViewController = tabbarController

        return true
    }
}

And run the app:

Result

All the code above can be found in the following repository on the branch "FinalResult"

https://github.com/giln/MVC-Composition-Part3.git

The above repository contains one last commit in which i try to make the ListViewController completely generic with any "Configurable" View, but this is beyond the scope of this tutorial.

Thank you and don't hesitate making comments for improvements!

Tagged with: