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.
Struct
containing raw data.
struct Data: Equatable {
let regions: [Region]
let trips: [Trip]
}
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
}
}
}
Enum
with actions that View Controller can do and Interactor can handle.
enum Action {
case load
case changeRegion(regionId: String?)
}
Enum
with destinations that View Controller can route to.
enum Route: Equatable {
case book(Trip)
}
Receives an action, performs work and sends raw data to presenter.
Receives raw data and maps it into View Model
Receives View Model and configures a view according to it. Sends actions to Interactor.
Receives Route object from View Controller, that contains information about next destination, and opens next View Controller using Configurator
Takes an input and creates configured View Controller with other components.
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
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
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 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()
)
}
}
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)
}
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)
}
}
}
For understanding this flow easier we can imagine a hypothetical scenario of Feed
feature.
FeedConfigurator
and calls createViewController()
to create FeedViewController
FeedViewController
on viewWillAppear
calls interactor.dispatch(Feed.Action.load)
to trigger load
actionFeedInteractor
handles load
action and uses TripRepository
to load an array of Trips
from the backend. It passes an array of Trips
to FeedPresenter
.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.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.
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.