Building a real-world iOS app (Part 5): Using Clean Swift for developing testable and scalable views

When starting to develop any application it's beneficial to think early about the way code can be testable and whether it would be scalable or maintainable. Many iOS applications have suffered from what is called Massive View Controller problem. By putting all the code that fetches, maps, presents and styles into one ViewController it very quickly overgrows in size and complexity. A lot of solutions where introduced to tackle this problem such as MVVM, MVVM + ReactiveCocoa or VIPER. In this application we'll be using Clean Swift approach for breaking up massive view controllers into testable and maintainable parts.

Data Structures

Data

Struct containing raw data.

     struct Data: Equatable {
        let regions: [Region]
        let trips: [Trip]
    }

View Model

Struct containing State (loading, error, empty, loaded) and mapped data that is used by View Controllers for configuring views.

     struct ViewModel: FeatureViewModel {
        let state: ViewState<Feed.ViewModel.Content>
        let title: String

        struct Content: FeatureContentViewModel, Equatable {
            var rows: [FeedCardViewModel]
            let availableRegions: [Feed.ViewModel.Content.Region]
            let selectedRegion: Feed.ViewModel.Content.Region?

            struct Region: Equatable {
                let id: String
                let name: String
            }

            var hasContent: Bool {
                return !rows.isEmpty
            }
        }
    }

Action

Enum with actions that View Controller can do and Interactor can handle.

     enum Action {
        case load
        case changeRegion(regionId: String?)
    }

Route

Enum with destinations that View Controller can route to.

     enum Route: Equatable {
        case book(Trip)
    }

Components

Interactor

Receives an action, performs work and sends raw data to presenter.

  • Input - Action
  • Output - Data
  • Uses - Presenter

Presenter

Receives raw data and maps it into View Model

  • Input - Data
  • Output - View Model

View Controller

Receives View Model and configures a view according to it. Sends actions to Interactor.

  • Input - View Model
  • Output - Action
  • Uses - Interactor, Router

Router

Receives Route object from View Controller, that contains information about next destination, and opens next View Controller using Configurator

  • Input - Route
  • Uses - Configurator

Configurator

Takes an input and creates configured View Controller with other components.

  • Input - Optional configuration data.
  • Output - View Controller
  • Creates - Interactor, Presenter, View Controller, Router

Feature

The group of these components is called Feature. Clean Swift provides with XCode templates that allow to generate all of these components together. We are using plop templates for feature generation. All of this allows to avoid writing boilerplate code and concentrate on actual code of the feature.

Feed Example

Feed is a main feature of the application. We're going to see how all of these different components is used to create a complete feature.

Feed Interactor

Feed Interactor uses repositories of Region, Trip and Airport for loading data.

dispatch function is an entry point of any Interactor.

     func dispatch(_ action: Feed.Action) {
        switch action {
        case .load:
            contentState = .loading(data: contentState.data)
            load()
        case .changeRegion(let regionId):
            changeRegion(id: regionId)
        }
    }

We can see when FeedInteractor receives load action it sets current state to loading and calls load() method. It combines RegionRepository and TripRepository, maps it to Data object and passes it to FeedPresenter by setting contentState.

     func load() {
        let selectedRegion = regionRepository.getSelectedRegion()

        Observable.combineLatest(
            self.regionRepository.getRegions(),
            self.tripRepository.getTrips(in: selectedRegion?.id)
            )
            .map { (regions, trips) -> Feed.Data in
                return Feed.Data(
                    regions: regions,
                    trips: trips,
                    selectedRegionId: selectedRegion?.id,
                    tripImages: []
                )
            }
            .subscribe(
                onNext: { data in
                    self.contentState = .loaded(data: data, error: nil)
                    self.loadImages(for: data.trips)
                },
                onError: { error in
                    self.contentState = .error(error: .loading(reason: R.string.localizable.errorGenericTitle()))
                }
            )
            .disposed(by: disposeBag)
    }

Feed Presenter

Feed Presenter essentially takes Feed.Data and returns Feed.ViewModel.

     func makeContentViewModel(content: Feed.Data) throws -> Feed.ViewModel.Content {
        return Feed.ViewModel.Content(
            rows: makeFeedCardRows(content),
            availableRegions: makeAvailableRegions(content),
            selectedRegion: makeSelectedRegion(content)
        )
    }

We can see that struct such as FeedCardViewModel is fairly complicated and comprehensively describes for a table view row what needs to be displayed. It ensures that there is absolutely no business logic, mapping or formatting done in a view as it's simply sets these properties to appropriate variables.

     private func makeFeedCardRows(_ content: Feed.Data) -> [FeedCardViewModel] {
        return getSortedTrips(content).map { trip in
            currencyFormatter.currencyCode = trip.currency
            return FeedCardViewModel(
                direction: R.string.localizable.feedBothWaysTitle(),
                trip: makeTripString(trip),
                price: formatCurrency(trip),
                dateRange: dateRange(trip),
                routeName: R.string.localizable.feedBookTitle(),
                imageUrl: makeTripImageURL(trip, content: content),
                route: Feed.Route.book(trip),
                isExpired: trip.expiresAt <= Date()
            )
        }
    }

Feed View Controller

View Controller in this architecture is a very lean and clean class. It does what view should do: present data, handle user actions and delegate these actions to 'interactor'.

display() lets FeedViewController know that the state and Feed.ViewModel was updated. Different views then can use parts of view model to configure themselves.

     func display() {
        guard let viewModel = viewModel?.state.viewModel else { return }

        tableView.reloadData()
        headerView.configure(with: viewModel.selectedRegion)
    }

Feed.Action is sent to Feed.Interactor when anything meaningful happens in FeedViewController. For example, loading data when view appears.

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

        interactor.subscribe()
        interactor.dispatch(Feed.Action.load)
    }

Feed.Route is sent to Feed.Router when FeedViewController wants to transition to other view controller.

     func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard let cellViewModel = viewModel?.state.viewModel?.rows[indexPath.row] else { return }

        router.route(to: cellViewModel.route)
    }

Feed Router

FeedRouter handles route actions and opens other view controllers. It uses BookTripConfigurator for building BookTripViewController.

class FeedRouter {

    private let bookTripConfigurator: BookTripConfigurator

    weak var viewController: FeedViewController?

    init(bookTripConfigurator: BookTripConfigurator) {
        self.bookTripConfigurator = bookTripConfigurator
    }

    func route(to route: Feed.Route) {
        switch route {
        case .book(let trip):
            let bookTripViewController = bookTripConfigurator.createViewController(trip: trip)
            bookTripViewController.modalPresentationStyle = .overCurrentContext
            bookTripViewController.modalTransitionStyle = .coverVertical
            viewController?.tabBarController?.present(bookTripViewController, animated: true, completion: nil)
        }
    }
}

Usage

For understanding this flow easier we can imagine a hypothetical scenario of Feed feature.

  1. AppDelegate uses FeedConfigurator and calls createViewController() to create FeedViewController
  2. FeedViewController on viewWillAppear calls interactor.dispatch(Feed.Action.load) to trigger load action
  3. FeedInteractor handles load action and uses TripRepository to load an array of Trips from the backend. It passes an array of Trips to FeedPresenter.
  4. FeedPresenter takes an array of Trips and maps it to FeedViewModel by formatting and localizing text, loading images and splitting it into fields that view needs to know about.
  5. FeedViewController's method display() is triggered and table view is loaded with new data.

Although this all may seem too much at first, it actually provides developers with huge clarity when building and maintaining the project. Moreover, all these different components have clear inputs and outputs than can be unit tested. With the growing complexity of the feature it becomes convenient to simply check Action to see all the different things that ViewController does or analyse Presenter to understand what kind of data is actually presented.

Result

In these series we've seen how to build iOS application by separating it into different frameworks, loading data from API and mapping it using Codable, sketching UIs following Apple's guidelines and develop it all on top of Clean architecture. All of these steps allow the app to be scalable, maintainable and testable.

App Demo