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.

4.6 (42) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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.

Detail Screen Hello World

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:

  1. 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.
  2. 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.

Edit the name in the detail view

Save after editing the trip name, and the changes will appear after you relaunch the app.

Changes persist after saving

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.