Getting Started with the VIPER Architecture Pattern
In this tutorial, you’ll learn about using the VIPER architecture pattern with SwiftUI and Combine, while building an iOS app that lets users create road trips. By Michael Katz.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Getting Started with the VIPER Architecture Pattern
30 mins
- Getting Started
- What is VIPER?
- Comparing Architectures
- Defining an Entity
- Adding an Interactor
- Setting Up the Presenter
- Building a View
- Creating a View with a Presenter
- Modifying the Model from the View
- Seeing It In Action
- Deleting a Trip
- Routing to the Detail View
- Setting Up the Trip Detail Screens
- Routing
- Finishing Up the Detail View
- Using a Second Presenter for the Map
- Considering the Map View
- Editing Waypoints
- Making Modules
- Where to Go From Here?
Routing
Before building out the detail view, you’ll want to link it to the rest of the app through a router from the trip list.
Create a new Swift File named TripListRouter.swift.
Set its contents to:
import SwiftUI
class TripListRouter {
func makeDetailView(for trip: Trip, model: DataModel) -> some View {
let presenter = TripDetailPresenter(interactor:
TripDetailInteractor(
trip: trip,
model: model,
mapInfoProvider: RealMapDataProvider()))
return TripDetailView(presenter: presenter)
}
}
This class outputs a new TripDetailView
that’s been populated with an interactor and presenter. The router handles transitioning from one screen to another, setting up the classes needed for the next view.
In an imperative UI paradigm — in other words, with UIKit — a router would be responsible for presenting view controllers or activating segues.
SwiftUI declares all of the target views as part of the current view and shows them based on view state. To map VIPER onto SwiftUI, the view is now responsible for showing/hiding of views, the router is a destination view builder, and the presenter coordinates between them.
In TripListPresenter.swift, add the router as a property:
private let router = TripListRouter()
You’ve now created the router as part of the presenter.
Next, add this method:
func linkBuilder<Content: View>(
for trip: Trip,
@ViewBuilder content: () -> Content
) -> some View {
NavigationLink(
destination: router.makeDetailView(
for: trip,
model: interactor.model)) {
content()
}
}
This creates a NavigationLink
to a detail view the router provides. When you place it in a NavigationView
, the link becomes a button that pushes the destination
onto the navigation stack.
The content
block can be any arbitrary SwiftUI view. But in this case, the TripListView
will provide a TripListCell
.
Go to TripListView.swift and change the contents of the ForEach
to:
self.presenter.linkBuilder(for: item) {
TripListCell(trip: item)
.frame(height: 240)
}
This uses the NavigationLink
from the presenter, sets the cell as its content and puts it in the list.
Build and run, and now, when the user taps the cell, it will route them to a “Hello World” TripDetailView
.
Finishing Up the Detail View
There are a few trip details you still need to fill out the detail view so the user can see the route and edit the waypoints.
Start by adding a the trip title:
In TripDetailInteractor
, add the following properties:
var tripName: String { trip.name }
var tripNamePublisher: Published<String>.Publisher { trip.$name }
This exposes just the String
version of the trip name and a Publisher
for when that name changes.
Also, add the following:
func setTripName(_ name: String) {
trip.name = name
}
func save() {
model.save()
}
The first method allows the presenter to change the trip name, and the second will save the model to the persistence layer.
Now, move onto TripDetailPresenter
. Add the following properties:
@Published var tripName: String = "No name"
let setTripName: Binding<String>
These provide the hooks for the view to read and set the trip name.
Then, add the following to the init
method:
// 1
setTripName = Binding<String>(
get: { interactor.tripName },
set: { interactor.setTripName($0) }
)
// 2
interactor.tripNamePublisher
.assign(to: \.tripName, on: self)
.store(in: &cancellables)
This code:
- Creates a binding to set the trip name. The
TextField
will use this in the view to be able to read and write from the value. - Assigns the trip name from the interactor’s publisher to the
tripName
property of the presenter. This keeps the value synchronized.
Separating the trip name into properties like this allows you to synchronize the value without creating an infinite loop of updates.
Next, add this:
func save() {
interactor.save()
}
This adds a save feature so the user can save any edited details.
Finally, go to TripDetailView
, and replace the body
with:
var body: some View {
VStack {
TextField("Trip Name", text: presenter.setTripName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding([.horizontal])
}
.navigationBarTitle(Text(presenter.tripName), displayMode: .inline)
.navigationBarItems(trailing: Button("Save", action: presenter.save))
}
The VStack
for now holds a TextField
for editing the trip name. The navigation bar modifiers define the title using the presenter’s published tripName
, so it updates as the user types, and a save button that will persist any changes.
Build and run, and now, you can edit the trip title.
Save after editing the trip name, and the changes will appear after you relaunch the app.
Using a Second Presenter for the Map
Adding additional widgets to a screen will follow the same pattern of:
- Adding functionality to the interactor.
- Bridging the functionality through the presenter.
- Adding the widgets to the view.
Go to TripDetailInteractor
, and add the following properties:
@Published var totalDistance: Measurement<UnitLength> =
Measurement(value: 0, unit: .meters)
@Published var waypoints: [Waypoint] = []
@Published var directions: [MKRoute] = []
These provide the following information about the waypoints in a trip: the total distance as a Measurement
, the list of waypoints and a list of directions that connect those waypoints.
Then, add the follow subscriptions to the end of init(trip:model:mapInfoProvider:)
:
trip.$waypoints
.assign(to: \.waypoints, on: self)
.store(in: &cancellables)
trip.$waypoints
.flatMap { mapInfoProvider.totalDistance(for: $0) }
.map { Measurement(value: $0, unit: UnitLength.meters) }
.assign(to: \.totalDistance, on: self)
.store(in: &cancellables)
trip.$waypoints
.setFailureType(to: Error.self)
.flatMap { mapInfoProvider.directions(for: $0) }
.catch { _ in Empty<[MKRoute], Never>() }
.assign(to: \.directions, on: self)
.store(in: &cancellables)
This performs three separate actions based on the changing of the trip’s waypoints.
The first is just a copy to the interactor’s waypoint list. The second uses the mapInfoProvider
to calculate the total distance for all of the waypoints. And the third uses the same data provider to get directions between the waypoints.
The presenter then uses these values to provide information to the user.
Go to TripDetailPresenter
, and add these properties:
@Published var distanceLabel: String = "Calculating..."
@Published var waypoints: [Waypoint] = []
The view will use these properties. Wire them up for tracking data changes by adding the following to the end of init(interactor:)
:
interactor.$totalDistance
.map { "Total Distance: " + MeasurementFormatter().string(from: $0) }
.replaceNil(with: "Calculating...")
.assign(to: \.distanceLabel, on: self)
.store(in: &cancellables)
interactor.$waypoints
.assign(to: \.waypoints, on: self)
.store(in: &cancellables)
The first subscription takes the raw distance from the interactor and formats it for display in the view, and the second just copies over the waypoints.