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
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.
- AppDelegate uses
FeedConfiguratorand callscreateViewController()to createFeedViewController FeedViewControlleronviewWillAppearcallsinteractor.dispatch(Feed.Action.load)to triggerloadactionFeedInteractorhandlesloadaction and usesTripRepositoryto load an array ofTripsfrom the backend. It passes an array ofTripstoFeedPresenter.FeedPresentertakes an array ofTripsand maps it toFeedViewModelby formatting and localizing text, loading images and splitting it into fields that view needs to know about.FeedViewController‘s methoddisplay()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.
