Chapters

Hide chapters

Advanced iOS App Architecture

Third Edition · iOS 13 · Swift 5.2 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section I

Section 1: 10 chapters
Show chapters Hide chapters

5. Architecture: MVVM
Written by René Cacheaux & Josh Berlin

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

Model-View-ViewModel (MVVM) is the new trend in the iOS community, but its roots date back to the early 2000s at Microsoft. Yes, you read that correctly! Microsoft. Microsoft architects introduced MVVM to simplify design and development using Extensible Application Markup Language (XAML) platforms, such as Silverlight.

Prior to MVVM, designers would drag and drop user interface components to create views, and developers would write code for each view specifically. This resulted in the tight coupling between views and business logic — changing one typically required changing the other. Designers lost freedom due to this workflow: They became hesitant to change view layouts because, doing so, often required massive code rewrites.

Microsoft specifically introduced MVVM to decouple views and business logic. This alleviated pain points for designers: They could now change the user interface, and developers wouldn’t have to change too much code.

Fast forward to iOS today, and you’ll find that iOS designers usually don’t modify Xcode storyboards or auto layout constraints directly. Rather, they create designs using graphical editors such as Adobe Photoshop. They hand these designs to developers, who, in turn, create both the views and code. Thereby, the goals of MVVM are different for iOS.

MVVM isn’t intended to allow designers to create views via Xcode directly. Rather, iOS developers use MVVM to decouple views from models. But the benefits are the same: iOS designers can freely change the user interface, and iOS developers won’t need to change much business logic code.

What is it?

MVVM is a “reactive” architecture. The view reacts to changes on the view model, and the view model updates its state based on data from the model.

MVVM involves three layers:

  • The model layer contains data access objects and validation logic. It knows how to read and write data, and it notifies the view model when data changes.

  • The view model layer contains the state of the view and has methods to handle user interaction. It calls methods on the model layer to read and write data, and it notifies the view when the model’s data changes.

  • The view layer styles and displays on-screen elements. It doesn’t contain business or validation logic. Instead, it binds its visual elements to properties on the view model. It also receives user inputs and interaction, and it calls methods on the view model in response.

As a result, the view layer and model layer are completely decoupled. The view layer and model layer only communicate with the view model layer.

Next, you’ll go into the each of these layers in depth.

Model layer

The model layer is responsible for all create, read, update and delete (CRUD) operations.

Repository pattern

Repositories contain data access objects that can call out to a server or read from disk.

Repository structure

The repository provides a set of asynchronous CRUD methods. The underlying implementations can be either stateless or stateful. Stateless implementations don’t keep data around after retrieving it, whereas stateful implementations save data for later. The components are usually stateful and keep data in-memory for quick access.

Example: KooberUserSessionRepository

In Koober, signing up or signing in creates a new session. The session contains the current user’s authentication token and metadata, such as name and avatar.

View layer

A view is a user interface for a screen. In MVVM, the view layer reacts to state changes through bindings to view model properties. It also notifies the view model of user interaction, like button taps or text input updates.

View model layer

The view model is the life of the party in this chapter. It contains a view’s state, methods for handling user interaction and bindings to different user interface elements.

Example: Koober sign-in view model

The sign-in view model contains business logic for signing in to Koober and publishers to update state.

Creating the view

The view knows how to style and layout its subviews, as well as hook up user interface elements to the view model publishers. In Koober, view controllers create the view and the view model inside loadView(). The view controller creates the view model first and passes it to the view. Since Koober creates view layouts in code, views can have a custom initializer.

Container views

Each screen in Koober has a container view — a top-level view that contains other child views. The container view’s purpose is to build a complex screen out of modular views. Instead of throwing all the user interface into one massive view, keep your views small, focused and reusable.

Structuring container views

A dependency container initializes a container view with its child views. A container view adds and displays child views in its view hierarchy. Child views limit the responsibility of the top-level container view. The number of child views needed depends on the screen’s complexity. Each child view is reusable and performs all its work independently.

Example: Koober ride request

Communicating amongst view models

Sometimes, view models need to signal out to the rest of the app when state changes. If a task is outside of the responsibility of one view model, the application may need to notify another view model to take over.

Collaborating view models

Normally state changes in a view model update a view. Sometimes, those state changes affect the entire app. View models don’t know how to post app-wide notifications; they take inputs and produce outputs. One way for view models to communicate with the app is to call into another view model, forming a graph of view models.

Navigating

Model-driven navigation

In model-driven navigation, view models contain a view enum describing all possible navigation states. The system observes this and navigates to the next screen when the value changes.

System-driven navigation

System-driven navigation is any navigation managed by the system. For example, gestures that trigger scroll view page navigation, or tapping a Back button in a navigation stack, automatically navigate the user to the previous screen.

Combination

You can use built-in, system-driven navigation to your advantage, while still implementing an MVVM architecture. For example, you can use model-driven navigation to move a navigation stack forwards and use system-driven navigation to move backwards.

Managing state

Some navigation schemes create new views when navigating, and other schemes hold onto views and reuse them.

Creating new views on navigation

Creating a new view each time a view is presented is easier to manage. The view and view model aren’t held in memory when the view is offscreen.

Reusing views on navigation

Reusing views makes sense when they need to preserve their state. System containers, like tab bars and navigation controllers, reuse views on navigation.

Applying theory to iOS apps

Congratulations for making it to the code examples - the kangaroos are proud! You’ve just learned a ton of theory about MVVM.

Building a view

The sign-in screen allows you to authenticate with Koober. The initial state shows placeholders for empty email and password fields. The Sign In button is always active, even when the text fields are empty. Tapping the button validates the email and password, and shows an error if either field is empty or if the API call returns an error.

Model layer

The sign-in model layer does most of the authentication work. It authenticates with the Koober server and persists the user session. The repositories are in KooberKit/DataLayer/Repositories and the models are in KooberKit/DataLayer/Model.

public protocol UserSessionRepository {
  func readUserSession() -> Promise<UserSession?>
  func signUp(newAccount: NewAccount) -> Promise<UserSession>
  func signIn(
    email: String, password: String) -> Promise<UserSession>
  func signOut(
    userSession: UserSession) -> Promise<UserSession>
}
public class UserSession: Codable {
  public let profile: UserProfile
  public let remoteSession: RemoteUserSession
}

public struct UserProfile: Codable {
  public let name: String
  public let email: String
  public let mobileNumber: String
  public let avatar: URL
}

public struct RemoteUserSession: Codable {
  let token: AuthToken
}

View model layer

SignInViewModel is where all the reactive magic happens in the sign-in screen. It holds all the view’s state, and it signs the user in.

public class SignInViewModel {

  // MARK: - Properties
  let userSessionRepository: UserSessionRepository
  let signedInResponder: SignedInResponder

  // MARK: - Methods
  public init(userSessionRepository: UserSessionRepository,
              signedInResponder: SignedInResponder) {
    self.userSessionRepository = userSessionRepository
    self.signedInResponder = signedInResponder
  }

  public var email = ""
  public var password: Secret = ""

  // Publishers go here
  // Task Methods go here
}
protocol SignedInResponder {
  func signedIn(to userSession: UserSession)
}
// SignInViewModel’s Publishers
public var errorMessagePublisher: 
  AnyPublisher<ErrorMessage, Never> {
	errorMessagesSubject.eraseToAnyPublisher()
}
private let errorMessagesSubject = 
  PassthroughSubject<ErrorMessage, Never>()

@Published public private(set)
  var emailInputEnabled = true
@Published public private(set)
  var passwordInputEnabled = true
@Published public private(set)
  var signInButtonEnabled = true
@Published public private(set)
  var signInActivityIndicatorAnimating = false

@objc
public func signIn() {
  indicateSigningIn()
  userSessionRepository.signIn(
	  email: email,
	  password: password)
	.done(signedInResponder.signedIn(to:))
	.catch(indicateErrorSigningIn)
}
func indicateSigningIn() {
  emailInputEnabled = false
  passwordInputEnabled = false
  signInButtonEnabled = false
  signInActivityIndicatorAnimating = true
}
func indicateErrorSigningIn(_ error: Error) {
  errorMessagesSubject.send(
    ErrorMessage(
      title: "Sign In Failed",
      message: "Could not sign in.\nPlease try again."))

  emailInputEnabled = true
  passwordInputEnabled = true
  signInButtonEnabled = true
  signInActivityIndicatorAnimating = false
}

View layer

We created all Koober root views in code instead of using storyboards. The kangaroos made us do it! No, really, there’s a valid reason for this. Root views get a view model injected on initialization. Using storyboards, this would be impossible. Also, in-code constraint creation is a lot easier these days. But that’s a debate for another day.

public class SignInViewController : NiblessViewController {
  
  // MARK: - Properties
  let viewModelFactory: SignInViewModelFactory
  let viewModel: SignInViewModel
  private var subscriptions = Set<AnyCancellable>()

  // MARK: - Methods
  init(viewModelFactory: SignInViewModelFactory) {
    self.viewModelFactory = viewModelFactory
    self.viewModel = viewModelFactory.makeSignInViewModel()
    super.init()
  }

  public override func loadView() {
    self.view = SignInRootView(viewModel: viewModel)
  }
}
protocol SignInViewModelFactory {
  func makeSignInViewModel() -> SignInViewModel
}
class SignInRootView: NiblessView {

  // MARK: - Properties
  let viewModel: SignInViewModel

  //...

  // MARK: - Methods
  init(frame: CGRect = .zero,
       viewModel: SignInViewModel) {
    self.viewModel = viewModel
    super.init(frame: frame)
    bindTextFieldsToViewModel()
    bindViewModelToViews()
  }

  //...
}
// SignInRootView

// ...

func bindTextFieldsToViewModel() {
  bindEmailField()
  bindPasswordField()
}

func bindEmailField() {
  emailField
	.publisher(for: \.text)
	.map { $0 ?? "" }
	.assign(to: \.email, on: viewModel)
	.store(in: &subscriptions)
}

func bindPasswordField() {
  passwordField
	.publisher(for: \.text)
	.map { $0 ?? "" }
	.assign(to: \.password, on: viewModel)
	.store(in: &subscriptions)
}

// ...
// SignInRootView
// ...

// MARK: - Dynamic behavior
extension SignInRootView {

  func bindViewModelToViews() {
    bindViewModelToEmailField()
    bindViewModelToPasswordField() 
    bindViewModelToSignInButton()
    bindViewModelToSignInActivityIndicator()
  }

  func bindViewModelToEmailField() {
    viewModel
      .$emailInputEnabled
      .receive(on: DispatchQueue.main)
      .assign(to: \.isEnabled, on: emailField)
      .store(in: &subscriptions)
  }

// ...
// SignInRootView
// ...

func wireController() {
  signInButton.addTarget(
	viewModel,
	action: #selector(SignInViewModel.signIn),
	for: .touchUpInside)
}

// ...

Composing views

The pick-me-up screen is the heart of the Koober app. This is where the ’roos hop around and fulfill their ride-sharing destinies.

Pick-me-up container view

public class PickMeUpViewController: NiblessViewController {

  // MARK: - Properties
  // View Model
  let viewModel: PickMeUpViewModel

  // Child View Controllers
  let mapViewController: PickMeUpMapViewController
  let rideOptionPickerViewController: 
    RideOptionPickerViewController
  let sendingRideRequestViewController: 
    SendingRideRequestViewController

  // ...

  // MARK: - Methods
  init(viewModel: 
         PickMeUpViewModel,
       mapViewController: 
         PickMeUpMapViewController,
       rideOptionPickerViewController: 
         RideOptionPickerViewController,
       sendingRideRequestViewController: 
         SendingRideRequestViewController,
       viewControllerFactory: 
         PickMeUpViewControllerFactory) {
    self.viewModel = 
      viewModel
    self.mapViewController = 
      mapViewController
    self.rideOptionPickerViewController = 
      rideOptionPickerViewController
    self.sendingRideRequestViewController = 
      sendingRideRequestViewController
    self.viewControllerFactory = 
      viewControllerFactory

    super.init()
  }

  public override func loadView() {
    view = PickMeUpRootView(viewModel: viewModel)
  }

  // ...
}

Ride-option picker view controller

public class RideOptionPickerViewController: 
  NiblessViewController {
	
  // ...

  // MARK: - Methods
  init(pickupLocation: Location,
       imageCache: ImageCache,
       viewModelFactory: RideOptionPickerViewModelFactory) {
    self.pickupLocation = pickupLocation
    self.imageCache = imageCache
    self.viewModel = 
      viewModelFactory.makeRideOptionPickerViewModel()
    super.init()
  }

  // ...
}
public class RideOptionPickerViewController: 
  NiblessViewController {
  
  // ...

  public override func viewDidLoad() {
    super.viewDidLoad()
    rideOptionSegmentedControl
      .loadRideOptions(availableAt: pickupLocation)
    observeErrorMessages()
  }

  // ...
}

Ride-option picker segmented control

RideOptionSegmentedControl displays a button for each Koober ride option:

class RideOptionSegmentedControl: UIControl {
	
  // MARK: - Properties
  let mvvmViewModel: RideOptionPickerViewModel

  // ...

  private func makeRideOptionButton(
	forSegment segment:
	RideOptionSegmentViewModel) ->
	(RideOptionID, RideOptionButton) {
		
	let button = RideOptionButton(segment: segment)
	button.didSelectRideOption = { [weak self] id in
	  self?.mvvmViewModel.select(rideOptionID: id)
	}
	return (segment.id, button)
  }

  // ...
}

Ride-option picker view model

Next, you’ll go into the view model in more depth.

public class RideOptionPickerViewModel {

  // MARK: - Properties
  let repository: RideOptionRepository

  @Published public private(set) var pickerSegments =
    RideOptionSegmentedControlViewModel()
  let rideOptionDeterminedResponder:
    RideOptionDeterminedResponder

  public var errorMessages: 
    AnyPublisher<ErrorMessage, Never> {
    errorMessagesSubject.eraseToAnyPublisher()
  }
  private let errorMessagesSubject = 
    PassthroughSubject<ErrorMessage, Never>()

  // MARK: - Methods
  public init(repository: RideOptionRepository,
              rideOptionDeterminedResponder: 
                RideOptionDeterminedResponder) {
    self.repository = repository
    self.rideOptionDeterminedResponder = 
      rideOptionDeterminedResponder
  }

  public func loadRideOptions(
	  availableAt pickupLocation: Location,
	  screenScale: CGFloat) {
	  // Call loadRideOptions on repository here
	  // and show ride options

          // ...
  }

  public func select(rideOptionID: RideOptionID) {
  	var segments = pickerSegments.segments
    for (index, segment) in segments.enumerated() {
      segments[index].isSelected = 
        (segment.id == rideOptionID)
    }
    pickerSegments = RideOptionSegmentedControlViewModel(
       segments: segments
    )
    rideOptionDeterminedResponder.pickUpUser(in: rideOptionID)
  }
}
protocol RideOptionDeterminedResponder {
  func pickUpUser(in rideOptionID: RideOptionID)
}

Pick-me-up view model

PickMeUpViewModel describes the state of the pick-me-up screen using enums:

public enum PickMeUpView {
  case initial
  case selectDropoffLocation
  case selectRideOption
  case confirmRequest
  case sendingRideRequest
  case final
}
enum PickMeUpRequestProgress {
  case initial(pickupLocation: Location)
  case waypointsDetermined(waypoints: NewRideWaypoints)
  case rideRequestReady(rideRequest: NewRideRequest)
}

public struct NewRideRequest: Codable {
  public let waypoints: NewRideWaypoints
  public let rideOptionID: RideOptionID
}

public struct NewRideWaypoints: Codable {
  let pickupLocation: Location
  let dropoffLocation: Location
}

public class PickMeUpViewModel:
  DropoffLocationDeterminedResponder,
  RideOptionDeterminedResponder,
  CancelDropoffLocationSelectionResponder {

  // MARK: - Properties
  var progress: PickMeUpRequestProgress
  let newRideRepository: NewRideRepository
  let newRideRequestAcceptedResponder: 
    NewRideRequestAcceptedResponder
  let mapViewModel: PickMeUpMapViewModel

  @Published public private(set) var view: PickMeUpView
  @Published public private(set) var shouldDisplayWhereTo = true

  // ...

  func pickUpUser(in rideOptionID: RideOptionID) {
    if case let .waypointsDetermined(waypoints) = progress {
      // 1
      let rideRequest = NewRideRequest(
                          waypoints: waypoints,
                          rideOptionID: rideOptionID)
      // 2
      progress = .rideRequestReady(rideRequest: rideRequest)
      // 3
      view = .confirmRequest
    } else if case 
        let .rideRequestReady(oldRideRequest) = progress {
      let rideRequest = NewRideRequest(
                          waypoints: oldRideRequest.waypoints,
                          rideOptionID: rideOptionID)
      progress = .rideRequestReady(rideRequest: rideRequest)
      view = .confirmRequest
    } else {
      fatalError()
    }
  }

  // ...
}

Navigating

This section is all about navigation. You’ll learn different techniques for driving navigation, how to manage initial view state on navigation and managing scopes when transitioning from onboarding to signed in.

Driving navigation

Koober uses three main techniques for driving navigation:

Model-driven navigation

The transitions from the map to the drop-off selection screen and drop-off selection screen back to the map use model-driven navigation.

public enum PickMeUpView {
  case initial
  case selectDropoffLocation
  case selectRideOption
  case confirmRequest
  case sendingRideRequest
  case final
}

public class PickMeUpViewModel:
  DropoffLocationDeterminedResponder,
  RideOptionDeterminedResponder,
  CancelDropoffLocationSelectionResponder {

  // MARK: - Properties
  // ...
  @Published public private(set) var view: PickMeUpView

  // ...
}
class PickMeUpRootView: NiblessView {

  // MARK: - Properties
  let viewModel: PickMeUpViewModel
  private var subscriptions = Set<AnyCancellable>()
  let whereToButton: UIButton = {
    // Create and return button here
    // ...
  }()

  // ...

  func bindWhereToButtonToViewModel() {
    whereToButton.addTarget(
	  viewModel,
	  action: #selector(
	    PickMeUpViewModel.
		  showSelectDropoffLocationView),
	  for: .touchUpInside)
  }

  // ...
}
public class PickMeUpViewModel:
  DropoffLocationDeterminedResponder,
  RideOptionDeterminedResponder,
  CancelDropoffLocationSelectionResponder {

  // ...

  @Published public private(set) var view: PickMeUpView

  // ...

  @objc
  public func showSelectDropoffLocationView() {
    view = .selectDropoffLocation
  }

  // ...
}
public class PickMeUpViewController: NiblessViewController {

  // MARK: - Properties
  // View Model
  let viewModel: PickMeUpViewModel

  // Child View Controllers
  let mapViewController: 
    PickMeUpMapViewController
  let rideOptionPickerViewController: 
    RideOptionPickerViewController
  let sendingRideRequestViewController: 
    SendingRideRequestViewController

  // State
  private var subscriptions = Set<AnyCancellable>()

  // Factories
  let viewControllerFactory: PickMeUpViewControllerFactory

  // MARK: - Methods
  // ...

  public override func viewDidLoad() {
    addFullScreen(childViewController: mapViewController)
    super.viewDidLoad()
    subscribe(to: viewModel.$view.eraseToAnyPublisher())
    observeErrorMessages()
  }

  func subscribe(to publisher: 
    AnyPublisher<PickMeUpView, Never>) {

    publisher
      .receive(on: DispatchQueue.main)
      .sink { [weak self] view in
        self?.present(view)
      }.store(in: &subscriptions)
  }

  func present(_ view: PickMeUpView) {
    switch view {
    case .initial:
      presentInitialState()
    case .selectDropoffLocation:
      presentDropoffLocationPicker()
    case .selectRideOption:
      dropoffLocationSelected()
      // Handle other states
      // ...
    }
  }

  // ...

  func presentDropoffLocationPicker() {
    let viewController =
      viewControllerFactory.
        makeDropoffLocationPickerViewController()
    present(viewController, animated: true)
  }

  // ...
}
class DropoffLocationPickerContentRootView: NiblessView {

  // MARK: - Properties
  let viewModel: DropoffLocationPickerViewModel

  // ...

  // MARK: - Methods
  init(frame: CGRect = .zero,
       viewModel: DropoffLocationPickerViewModel) {
    // ...
  }

  // ...
}

// ...

extension DropoffLocationPickerContentRootView:
  UITableViewDelegate {

  func tableView(_ tableView: UITableView,
                 didSelectRowAt indexPath: IndexPath) {
    let selectedLocation = searchResults[indexPath.row]
    viewModel.select(dropoffLocation: selectedLocation)
  }
}
protocol DropoffLocationDeterminedResponder {
  func dropOffUser(at location: Location)
}
public class DropoffLocationPickerViewModel {

  // MARK: - Properties
  // ...
  // Injected Dependency
  let dropoffLocationDeterminedResponder:
    DropoffLocationDeterminedResponder

  // ...

  // MARK: - Methods
  public init(pickupLocation: Location,
              locationRepository: LocationRepository,
              dropoffLocationDeterminedResponder: 
                DropoffLocationDeterminedResponder,
              cancelDropoffLocationSelectionResponder: 
                CancelDropoffLocationSelectionResponder) {
    // ...
  }

  // ...

  public func select(dropoffLocation: NamedLocation) {
	dropoffLocationDeterminedResponder.
	  dropOffUser(at: dropoffLocation.location)
  }

  // ...
}
public class PickMeUpViewModel:
  DropoffLocationDeterminedResponder,
  RideOptionDeterminedResponder,
  CancelDropoffLocationSelectionResponder {

  // ...

  func dropOffUser(at location: Location) {
    guard case let .initial(pickupLocation) = progress
      else {
        fatalError()
    }

    let waypoints = NewRideWaypoints(
      pickupLocation: pickupLocation,
      dropoffLocation: location)
    progress = .waypointsDetermined(waypoints: waypoints)
    view = .selectRideOption
    mapViewModel.dropoffLocation = location
  }

  // ...
}
public class PickMeUpViewController: NiblessViewController {

  // ...

  func subscribe(to publisher: 
    AnyPublisher<PickMeUpView, Never>) {
    
    publisher
      .receive(on: DispatchQueue.main)
      .sink { [weak self] view in
        self?.present(view)
      }.store(in: &subscriptions)
  }

  func present(_ view: PickMeUpView) {
    switch view {
    case .initial:
      presentInitialState()
    case .selectDropoffLocation:
      presentDropoffLocationPicker()
    case .selectRideOption:
      dropoffLocationSelected()
    case .confirmRequest:
      presentConfirmControl()
    case .sendingRideRequest:
      presentSendingRideRequestScreen()
    case .final:
      dismissSendingRideRequestScreen()
    }
  }

  // ...

  func dropoffLocationSelected() {
    if presentedViewController is 
      DropoffLocationPickerViewController {
      dismiss(animated: true)
    }
    presentRideOptionPicker()
  }

  // ...
}

System-driven navigation

Koober doesn’t use pure system-driven navigation anywhere in the app. So leave Koober land for a bit and take a look at a simple UITabBarController example.

let firstViewController = UIViewController()
firstViewController.tabBarItem =
  UITabBarItem(
	title: "Red",
	image: nil,
	selectedImage: nil)
firstViewController.view.backgroundColor = .red

let secondViewController = UIViewController()
secondViewController.tabBarItem =
UITabBarItem(
  title: "Blue",
  image: nil,
  selectedImage: nil)
secondViewController.view.backgroundColor = .blue

let tabBarController = UITabBarController()
tabBarController.viewControllers =
  [firstViewController, secondViewController]

tabBarController.selectedViewController = secondViewController

Combination

The onboarding screen uses model-driven navigation from the welcome screen to the sign-in screen, and it uses system-driven navigation backwards to the welcome screen.

// NavigationAction.swift
public enum NavigationAction<ViewModelType>: Equatable
  where ViewModelType: Equatable {

  case present(view: ViewModelType)
  case presented(view: ViewModelType)
}

// OnboardingViewModel.swift
public typealias OnboardingNavigationAction =
  NavigationAction<OnboardingView>
public enum OnboardingView {
  case welcome
  case signin
  case signup

  // ...
}
public class OnboardingViewModel: 
  GoToSignUpNavigator, GoToSignInNavigator {
  
  // MARK: - Properties
  @Published public private(set) 
    var navigationAction: OnboardingNavigationAction = 
      .present(view: .welcome)

  // MARK: - Methods
  public init() {}
  
  func navigateToSignUp() {
    navigationAction = .present(view: .signup)
  }

  func navigateToSignIn() {
    navigationAction = .present(view: .signin)
  }

  public func uiPresented(onboardingView: OnboardingView) {
    navigationAction = .presented(view: onboardingView)
  }
}
public class OnboardingViewController: 
  NiblessNavigationController {
  
  // MARK: - Properties
  // View Model
  let viewModel: OnboardingViewModel
  var subscriptions = Set<AnyCancellable>()
  
  // ...

  public override func viewDidLoad() {
    super.viewDidLoad()
    let navigationActionPublisher = 
      viewModel.$navigationAction.eraseToAnyPublisher()
    subscribe(to: navigationActionPublisher)
  }

  func subscribe(to publisher: 
    AnyPublisher<OnboardingNavigationAction, Never>) {

    publisher
      .receive(on: DispatchQueue.main)
      .removeDuplicates()
      .sink { [weak self] action in
        guard let strongSelf = self else { return }
        strongSelf.respond(to: action)
      }.store(in: &subscriptions)
  }

  func respond(to navigationAction: 
    OnboardingNavigationAction) {

    switch navigationAction {
    case .present(let view):
      present(view: view)
    case .presented:
      break
    }
  }

  func present(view: OnboardingView) {
    switch view {
    case .welcome:
      presentWelcome()
    case .signin:
      presentSignIn()
    case .signup:
      presentSignUp()
    }
  }

  func presentWelcome() {
    pushViewController(welcomeViewController, 
                       animated: false)
  }

  func presentSignIn() {
    pushViewController(signInViewController, 
                       animated: true)
  }

  func presentSignUp() {
    pushViewController(signUpViewController, 
                       animated: true)
  }
}
extension OnboardingViewController:
  UINavigationControllerDelegate {

  // ...
  
  public func navigationController(
    _ navigationController: UINavigationController,
    didShow viewController: UIViewController,
    animated: Bool) {
	  
    guard let shownView =
      onboardingView(associatedWith: viewController) else {
		  return 
    }

    viewModel.uiPresented(onboardingView: shownView)
  }
}

Managing state

When you navigate between screens, there are two ways to manage state:

New views on navigation

Creating a new view each time you present a new screen makes state management easier. You guarantee the screen starts from the initial state each time it’s presented.

protocol SignedInViewControllerFactory {
  func makeGettingUsersLocationViewController() ->
    GettingUsersLocationViewController
		
  func makePickMeUpViewController(pickupLocation: Location) ->
    PickMeUpViewController
		
  func makeWaitingForPickupViewController() ->
    WaitingForPickupViewController
}

public class SignedInViewController: NiblessViewController {

  // ...
 	
  // MARK: Factories
  let viewControllerFactory: SignedInViewControllerFactory

  // ...

  func present(_ view: SignedInView) {
    switch view {
    case .gettingUsersLocation:
      let viewController = viewControllerFactory.
        makeGettingUsersLocationViewController()
      transition(to: viewController)
    case .pickMeUp(let pickupLocation):
      let viewController = viewControllerFactory.
        makePickMeUpViewController(
          pickupLocation: pickupLocation)
      transition(to: viewController)
    case .waitingForPickup:
      let viewController = viewControllerFactory.
        makeWaitingForPickupViewController()
      transition(to: viewController)
    }
  }

  // ...

  func transition(to viewController: UIViewController) {
    remove(childViewController: currentChildViewController)
    addFullScreen(childViewController: viewController)
    currentChildViewController = viewController
  }
}

Reusing views on navigation

Reusing views on navigation makes state management harder. Each time you present a new screen, you need to make sure the state is reset back to the original state. The onboarding flow is an example of reusing views on navigation.

public class OnboardingViewController:
  NiblessNavigationController {

  // ...
	
  // Child View Controllers
  let welcomeViewController: WelcomeViewController
  let signInViewController: SignInViewController
  let signUpViewController: SignUpViewController

  // ...

  func presentWelcome() {
	pushViewController(welcomeViewController,
	                   animated: false)
  }

  func presentSignIn() {
    pushViewController(signInViewController,
	                   animated: true)
  }

  func presentSignUp() {
    pushViewController(signUpViewController, 
                         animated: true)
  }
}

Managing scopes: Onboarding to signed in

During the onboarding flow, no authenticated user exists. There’s no reason to create a map, since the map needs an authenticated user to work.

public class MainViewController: NiblessViewController {

  // MARK: - Properties
  // View Model
  let viewModel: MainViewModel
	
  // Child View Controllers
  let launchViewController: LaunchViewController
  var signedInViewController: SignedInViewController?
  var onboardingViewController: OnboardingViewController?

  // ...
}
public class MainViewController: NiblessViewController {

  // ...
	
  public func presentOnboarding() {
    let onboardingViewController = 
      makeOnboardingViewController()
    onboardingViewController.modalPresentationStyle = 
      .fullScreen
    present(onboardingViewController, animated: true) { 
      [weak self] in
      guard let strongSelf = self else {
        return
      }

      strongSelf.remove(childViewController: 
        strongSelf.launchViewController)
      if let signedInViewController = 
        strongSelf.signedInViewController {
        strongSelf.remove(childViewController: 
          signedInViewController)
        strongSelf.signedInViewController = nil
      }
    }
    self.onboardingViewController = onboardingViewController
  }

  // ...
}
// ...

public func presentSignedIn(userSession: UserSession) {
  remove(childViewController: launchViewController)

  let signedInViewControllerToPresent:
    SignedInViewController
  if let vc = self.signedInViewController {
	signedInViewControllerToPresent = vc
  } else {
	signedInViewControllerToPresent = 	
	  makeSignedInViewController(userSession)
	self.signedInViewController =
	  signedInViewControllerToPresent
  }

  addFullScreen(childViewController:
	signedInViewControllerToPresent)

  if onboardingViewController?.
	presentingViewController != nil {
	  onboardingViewController = nil
	  dismiss(animated: true)
  }
}

// ...

Pros and cons of MVVM

Pros of MVVM

  1. View model logic is easy to test independently from the user interface code. View models contain zero UI — only business and validation logic.
  2. View and model are completely decoupled from each other. View model talks to the view and model separately.
  3. MVVM helps parallelize developer workflow. One team member can build a view while another team member builds the view model and model. Parallelizing tasks gives your team’s productivity a nice boost.
  4. While not inherently modular, MVVM does not get in the way of designing a modular structure. You can build out modular UI components using container view and child views, as long as your view models know how to communicate with each other.
  5. View models can be used across Apple platforms (iOS, tvOS, macOS, etc.) because they don’t import UIKit. Especially if view models are granular.

Cons of MVVM

  1. There is a learning curve with Combine (compared to MVC.) New team members need to learn Combine and how to properly use view models. Development time may slow down at first, until new team members get up to speed.
  2. Typical implementation requires view models to collaborate. Managing memory and syncing state across your app is more difficult when using collaborating view models.
  3. Business logic is not reusable from different views, since business logic is inside view specific view models.
  4. It can be hard to trace and debug, because UI updates happen through binding instead of method calls.
  5. View models have properties for both UI state and dependencies. This means that view models can be difficult to read, because state management is mixed with side effects and dependencies.

Key points

Where to go from here?

Koober is meant to be a real-world use case, and there’s a ton of code in the example project we couldn’t cover in one chapter. Feel free to explore the codebase on your own.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now