Chapters

Hide chapters

Advanced iOS App Architecture

Fourth Edition · iOS 15 · Swift 5.5 · Xcode 13.2

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

Section I

Section 1: 9 chapters
Show chapters Hide chapters

8. Architecture: Elements, Part 2
Written by René Cacheaux

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

In Chapter 7, you learned about Elements and how to design user interface and interaction responder elements. In this chapter, you’ll take a deep dive into two more elements: observer and use case.

Note: The example Koober Xcode project for this chapter is the same as Chapter 7’s Xcode project. To see this chapter’s material in Koober, open the Xcode project that is located in Chapter 7’s project directory.

Observer

Observers are objects view controllers use to receive external events. You can think of these events as input signals to view controllers. Observers know how to:

  • Subscribe to events
  • Process events
  • Deliver processed events to a view controller

For instance, say you’re building a view controller that needs to respond to a NotificationCenter notification. An observer would know how to subscribe to the notification, how to pull out the relevant information from the user info dictionary and would know what view controller method to call. The view controller would then perform some work in response to the processed notification. You might be thinking, but wait, adding and removing observers from NotificationCenter is really easy. Why not leave this code in view controllers? Hang tight, you’ll read about the benefits soon.

Note: Observers allow you to decouple view controllers from event technologies such as NotificationCenter, target-action, etc. Combine also allows you to decouple view controllers from event technologies. As you read this section you might be wondering why not just use Combine? Using Combine adds boilerplate code to your view controllers making them a bit harder to read. You can use the Observer pattern alongside Combine to both decouple view controllers from event technologies and to make view controllers light and easy to read. Using Combine directly inside view controllers is also a valid approach. This decision comes down to reading preference.

Mechanics

This section explains how observers are created, used and de-allocated. If this section is a bit fuzzy, don’t worry. You’ll see code examples of all these concepts further down.

Instantiating

In the simplest usage, you write an observer class for every view controller that needs to observe external events. Observers are initialized with references to the systems that emit events. This is so an observer can subscribe to events when a view controller wants to start observing.

Providing

Observers are created outside view controllers; i.e., observers are provided to their respective view controller. Observers are provided to view controllers either via a view controller’s initializer or by setting a view controller property. At this point, a view controller has a reference to its observer.

Using

Once view controllers are ready to start observing, view controllers can call an observer’s startObserving() method. During this method, observers subscribe to all the events that a view controller needs to observe. At this point, observers are live. They are accepting, processing and delivering events to their view controller.

Tearing down

In the simplest usage, observers live as long as their respective view controllers. Observer and view controller lifetimes should match. To guarantee the lifetime, make sure that a view controller is the only object holding onto an observer. Also, observers need to hold a weak reference to their event responder; i.e., view controller, to avoid retain cycles.

Types

Observer protocol

All observers implement the Observer protocol.

protocol Observer {
  func startObserving()
  func stopObserving()
}

Observer event responder protocols

When events occur, observers need to be able to call methods on their view controller to let their view controller know an event occurred and to pass any related data. In order to do this, observers hold a weak reference to their view controller.

protocol ObserverForSignInEventResponder: AnyObject {
  func received(newErrorMessage errorMessage: ErrorMessage)
  func received(newViewState viewState: SignInViewState)
  func keyboardWillHide()
  func keyboardWillChangeFrame(keyboardEndFrame: CGRect)
}

Observer classes

Observer classes conform to the Observer protocol. As mentioned before, they hold a weak reference to their EventResponder, which is usually a view controller. Observer classes know how to subscribe to events, process events and call methods on an EventResponder. You implement one observer class for each view controller that needs to observe external events. Here’s an example skeleton implementation:

class ObserverForSignIn: Observer {

  // MARK: - Properties
  weak var eventResponder: ObserverForSignInEventResponder? {
    willSet {
      if newValue == nil {
        stopObserving()
      }
    }
  }

  // MARK: - Methods
  func startObserving() {
    // Subscribe to events here.
    // ...
  }

  func stopObserving() {
    // Unsubscribe to events here.
    // ...
  }
}

Example

In this section, you’ll walk through a complete example so you can see how all the different types and objects work together. The example is from Koober’s sign-in screen.

protocol ObserverForSignInEventResponder: AnyObject {
  func received(newErrorMessage errorMessage: ErrorMessage)
  func received(newViewState viewState: SignInViewState)
  func keyboardWillHide()
  func keyboardWillChangeFrame(keyboardEndFrame: CGRect)
}
extension SignInViewController:
  ObserverForSignInEventResponder {

  func received(newErrorMessage errorMessage: ErrorMessage) {
    // ...
  }

  func received(newViewState viewState: SignInViewState) {
    // ...
  }

  func keyboardWillHide() {
    // ...
  }

  func keyboardWillChangeFrame(keyboardEndFrame: CGRect) {
    // ...
  }
}
// 1
class ObserverForSignIn: Observer {

  // MARK: - Properties
  // 2
  weak var eventResponder: ObserverForSignInEventResponder? {
    willSet {
      if newValue == nil {
        stopObserving()
      }
    }
  }

  // 3
  let signInState: AnyPublisher<SignInViewControllerState, 
                                Never>

  var errorStateSubscription: AnyCancellable?
  var viewStateSubscription: AnyCancellable?

  // 4
  private var isObserving: Bool {
    if isObservingState && isObservingKeyboard {
      return true
    } else {
      return false
    }
  }

  private var isObservingState: Bool {
    if errorStateSubscription != nil
      && viewStateSubscription != nil {
        return true
    } else {
      return false
    }
  }

  private var isObservingKeyboard = false

  // MARK: - Methods
  // 5
  init(signInState: AnyPublisher<SignInViewControllerState, 
                                 Never>) {
    self.signInState = signInState
  }

  // 6
  func startObserving() {
    assert(self.eventResponder != nil)

    guard let _ = self.eventResponder else {
      return
    }

    if isObserving {
      return
    }

    subscribeToErrorMessages()
    subscribeToSignInViewState()
    startObservingKeyboardNotifications()
  }

  // 7
  func stopObserving() {
    unsubscribeFromSignInViewState()
    unsubscribeFromErrorMessages()
    stopObservingNotificationCenterNotifications()
  }

  func subscribeToSignInViewState() {
    viewStateSubscription =
      signInState
        .receive(on: DispatchQueue.main)
        .map { $0.viewState }
        .removeDuplicates()
        .sink { [weak self] viewState in
          self?.received(newViewState: viewState)
        }
  }

  func received(newViewState: SignInViewState) {
    // 8
    eventResponder?.received(newViewState: newViewState)
  }

  func unsubscribeFromSignInViewState() {
    viewStateSubscription = nil
  }

  func subscribeToErrorMessages() {
    errorStateSubscription =
      signInState
        .receive(on: DispatchQueue.main)
        .map { $0.errorsToPresent.first }
        .compactMap { $0 }
        .removeDuplicates()
        .sink { [weak self] errorMessage in
          self?.received(newErrorMessage: errorMessage)
        }
  }

  func received(newErrorMessage errorMessage: ErrorMessage) {
    // 8
    eventResponder?.received(newErrorMessage: errorMessage)
  }

  func unsubscribeFromErrorMessages() {
    errorStateSubscription = nil
  }

  func startObservingKeyboardNotifications() {
    let notificationCenter = NotificationCenter.default

    notificationCenter
      .addObserver(
        self,
        selector: #selector(
          handle(keyboardWillHideNotification:)),
        name: UIResponder.keyboardWillHideNotification,
        object: nil
      )

    notificationCenter
      .addObserver(
        self,
        selector: #selector(
          handle(keyboardWillChangeFrameNotification:)),
        name: UIResponder.keyboardWillChangeFrameNotification,
        object: nil
      )

    isObservingKeyboard = true
  }

  @objc func handle(
    keyboardWillHideNotification notification: Notification
  ) {
    assert(notification.name ==
      UIResponder.keyboardWillHideNotification)

    // 8
    eventResponder?.keyboardWillHide()
  }

  @objc func handle(
    keyboardWillChangeFrameNotification
      notification: Notification) {

    assert(notification.name ==
      UIResponder.keyboardWillChangeFrameNotification)

    guard let userInfo = notification.userInfo else {
      return
    }
    guard let keyboardEndFrameUserInfo =
      userInfo[UIResponder.keyboardFrameEndUserInfoKey] else {
        return
    }
    guard let keyboardEndFrame =
      keyboardEndFrameUserInfo as? NSValue else {
        return
    }

    // 8
    eventResponder?
      .keyboardWillChangeFrame(
        keyboardEndFrame: keyboardEndFrame.cgRectValue)
  }

  func stopObservingNotificationCenterNotifications() {
    let notificationCenter = NotificationCenter.default
    notificationCenter.removeObserver(self)

    isObservingKeyboard = false
  }
}
public class SignInViewController: NiblessViewController {

  // MARK: - Properties
  // Observers
  var observer: Observer

  // User interface
  let userInterface: SignInUserInterfaceView

  // Factories
  let signInUseCaseFactory: SignInUseCaseFactory
  let makeFinishedPresentingErrorUseCase:
    FinishedPresentingErrorUseCaseFactory

  // MARK: - Methods
  init(userInterface: SignInUserInterfaceView,
       observer: Observer,
       signInUseCaseFactory: SignInUseCaseFactory,
       finishedPresentingErrorUseCaseFactory:
         @escaping FinishedPresentingErrorUseCaseFactory
  ) {
    self.userInterface = userInterface
    self.observer = observer
    self.signInUseCaseFactory = signInUseCaseFactory
    self.makeFinishedPresentingErrorUseCase =
      finishedPresentingErrorUseCaseFactory

    super.init()
  }

  public override func loadView() {
    view = userInterface
  }

  public override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    observer.startObserving()
  }

  public override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    observer.stopObserving()
  }

  // ...
}

extension SignInViewController:
  ObserverForSignInEventResponder {

  func received(newErrorMessage errorMessage: ErrorMessage) {
    // ...
  }

  func received(newViewState viewState: SignInViewState) {
    // ...
  }

  func keyboardWillHide() {
    // ...
  }

  func keyboardWillChangeFrame(keyboardEndFrame: CGRect) {
    // ...
  }
}

// ...
public class KooberOnboardingDependencyContainer {
  // ...

  func makeSignInViewController() -> SignInViewController {
    // User interface element
    let userInterface = SignInRootView()

    // Observer element
    // 1
    let statePublisher = 
      makeSignInViewControllerStatePublisher()
    let observer =
      ObserverForSignIn(signInState: statePublisher)

    // ....

    let signInViewController =
      SignInViewController(
        userInterface: userInterface,
        // 2
        observer: observer,
        // ...
      )

    // Wire responders
    userInterface.ixResponder = signInViewController
    //3
    observer.eventResponder = signInViewController

    return signInViewController
  }

  // ...
}

Variations and advanced usage

There’s a lot more to observers than meets the eyes. In this section, you’ll explore more ways to implement observers.

Building multiple observers per view controller

Building one observer class per view controller is simple and straightforward. However, in certain situations, you might prefer to break a single observer into multiple observer classes.

protocol SignInKeyboardObserverEventResponder: AnyObject {
  func keyboardWillHide()
  func keyboardWillChangeFrame(keyboardEndFrame: CGRect)
}
protocol SignInStateObserverEventResponder: AnyObject {
  func received(newErrorMessage errorMessage: ErrorMessage)
  func received(newViewState viewState: SignInViewState)
}
class SignInKeyboardObserver: Observer {

  // MARK: - Properties
  weak var eventResponder:
    SignInKeyboardObserverEventResponder? {
    willSet {
      if newValue == nil {
        stopObserving()
      }
    }
  }

  private var isObserving = false

  // MARK: - Methods
  func startObserving() {
    assert(self.eventResponder != nil)

    guard let _ = self.eventResponder else {
      return
    }

    if isObserving {
      return
    }

    startObservingKeyboardNotifications()
  }

  func stopObserving() {
    stopObservingNotificationCenterNotifications()
  }

  func startObservingKeyboardNotifications() {
    let notificationCenter = NotificationCenter.default

    notificationCenter
      .addObserver(
        self,
        selector: #selector(
          handle(keyboardWillHideNotification:)),
        name: UIResponder.keyboardWillHideNotification,
        object: nil)

    notificationCenter
      .addObserver(
        self,
        selector: #selector(
          handle(keyboardWillChangeFrameNotification:)),
        name: UIResponder.keyboardWillChangeFrameNotification,
        object: nil)

    isObserving = true
  }

  @objc func handle(
    keyboardWillHideNotification notification: Notification
  ) {
    assert(notification.name ==
      UIResponder.keyboardWillHideNotification)

    eventResponder?.keyboardWillHide()
  }

  @objc func handle(
    keyboardWillChangeFrameNotification
      notification: Notification
  ) {
    assert(notification.name ==
      UIResponder.keyboardWillChangeFrameNotification)

    guard let userInfo = notification.userInfo else {
      return
    }
    guard let keyboardEndFrameUserInfo =
      userInfo[UIResponder.keyboardFrameEndUserInfoKey] else {
        return
    }
    guard let keyboardEndFrame =
      keyboardEndFrameUserInfo as? NSValue else {
        return
    }

    eventResponder?
      .keyboardWillChangeFrame(
        keyboardEndFrame: keyboardEndFrame.cgRectValue)
  }

  func stopObservingNotificationCenterNotifications() {
    let notificationCenter = NotificationCenter.default
    notificationCenter.removeObserver(self)

    isObserving = false
  }
}
class SignInStateObserver: Observer {

  // MARK: - Properties
  weak var eventResponder: SignInStateObserverEventResponder? {
    willSet {
      if newValue == nil {
        stopObserving()
      }
    }
  }

  let signInState: AnyPublisher<SignInViewControllerState,
                                Never>

  var errorStateSubscription: AnyCancellable?
  var viewStateSubscription: AnyCancellable?

  private var isObserving: Bool {
    if errorStateSubscription != nil
      && viewStateSubscription != nil {
        return true
    } else {
      return false
    }
  }

  // MARK: - Methods
  init(signInState: AnyPublisher<SignInViewControllerState, 
                                 Never>) {
    self.signInState = signInState
  }

  func startObserving() {
    assert(self.eventResponder != nil)

    guard let _ = self.eventResponder else {
      return
    }

    if isObserving {
      return
    }

    subscribeToErrorMessages()
    subscribeToSignInViewState()
  }

  func stopObserving() {
    unsubscribeFromSignInViewState()
    unsubscribeFromErrorMessages()
  }

  func subscribeToSignInViewState() {
    viewStateSubscription =
      signInState
        .receive(on: DispatchQueue.main)
        .map { $0.viewState }
        .removeDuplicates()
        .sink { [weak self] viewState in
          self?.received(newViewState: viewState)
        }
  }

  func received(newViewState: SignInViewState) {
    eventResponder?.received(newViewState: newViewState)
  }

  func unsubscribeFromSignInViewState() {
    viewStateSubscription = nil
  }

  func subscribeToErrorMessages() {
    errorStateSubscription =
      signInState
        .receive(on: DispatchQueue.main)
        .map { $0.errorsToPresent.first }
        .compactMap { $0 }
        .removeDuplicates()
        .sink { [weak self] errorMessage in
          self?.received(newErrorMessage: errorMessage)
        }
  }

  func received(newErrorMessage errorMessage: ErrorMessage) {
    eventResponder?.received(newErrorMessage: errorMessage)
  }

  func unsubscribeFromErrorMessages() {
    errorStateSubscription = nil
  }
}
class SignInViewController: NiblessViewController {

  // MARK: - Properties
  // 2
  var stateObserver: Observer
  var keyboardObserver: Observer

  let userInterface: SignInUserInterfaceView

  // MARK: - Methods
  // 1
  init(userInterface: SignInUserInterfaceView,
       stateObserver: Observer,
       keyboardObserver: Observer) {
    self.userInterface = userInterface
    self.stateObserver = stateObserver
    self.keyboardObserver = keyboardObserver

    super.init()
  }

  override func loadView() {
    view = userInterface
  }

  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    // 3
    stateObserver.startObserving()
  }

  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    // 3
    keyboardObserver.startObserving()
  }

  override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    // 4
    stateObserver.stopObserving()
    keyboardObserver.stopObserving()
  }

  // ...
}

extension SignInViewController:
  SignInStateObserverEventResponder {

  func received(newErrorMessage errorMessage: ErrorMessage) {
    // ...
  }

  func received(newViewState viewState: SignInViewState) {
    // ...
  }
}

extension SignInViewController:
  SignInKeyboardObserverEventResponder {

  func keyboardWillHide() {
    // ...
  }

  func keyboardWillChangeFrame(keyboardEndFrame: CGRect) {
    // ...
  }
}

// ...
class KooberOnboardingDependencyContainer {
  // ...

  func makeSignInViewController() -> SignInViewController {
    // User interface element
    let userInterface = SignInRootView()

    // Observer elements
    // 1
    let statePublisher = 
      makeSignInViewControllerStatePublisher()
    let stateObserver =
      SignInStateObserver(signInState: statePublisher)
    let keyboardObserver = SignInKeyboardObserver()

    // 2
    let signInViewController =
      SignInViewController(
        userInterface: userInterface,
        stateObserver: stateObserver,
        keyboardObserver: keyboardObserver)

    // Wire responders
    userInterface.ixResponder = signInViewController
    // 3
    stateObserver.eventResponder = signInViewController
    keyboardObserver.eventResponder = signInViewController

    return signInViewController
  }

  // ...
}

Building reusable observers

What if you find yourself writing the same observer over and over again? Many of the systems that generate events in Cocoa Touch are general in nature.

struct KeyboardUserInfo {

  // MARK: - Properties
  let animationCurve: UIView.AnimationCurve
  let animationDuration: Double
  let isLocal: Bool
  let beginFrame: CGRect
  let endFrame: CGRect

  let animationCurveKey =
    UIResponder.keyboardAnimationCurveUserInfoKey
  let animationDurationKey =
    UIResponder.keyboardAnimationDurationUserInfoKey
  let isLocalKey = UIResponder.keyboardIsLocalUserInfoKey
  let frameBeginKey = UIResponder.keyboardFrameBeginUserInfoKey
  let frameEndKey = UIResponder.keyboardFrameEndUserInfoKey

  // MARK: - Methods
  init?(_ notification: Notification) {
    guard let userInfo = notification.userInfo else {
      return nil
    }

    // Animation curve.
    guard let animationCurveUserInfo =
            userInfo[animationCurveKey],
          let animationCurveRaw =
            animationCurveUserInfo as? Int,
          let animationCurve =
            UIView.AnimationCurve(rawValue: animationCurveRaw)
    else {
      return nil
    }
    self.animationCurve = animationCurve

    // Animation duration.
    guard let animationDurationUserInfo =
            userInfo[animationDurationKey],
          let animationDuration =
            animationDurationUserInfo as? Double
    else {
        return nil
    }
    self.animationDuration = animationDuration

    // Is local.
    guard let isLocalUserInfo = userInfo[isLocalKey],
          let isLocal = isLocalUserInfo as? Bool else {
        return nil
    }
    self.isLocal = isLocal

    // Begin frame.
    guard let beginFrameUserInfo = userInfo[frameBeginKey],
          let beginFrame = beginFrameUserInfo as? CGRect else {
      return nil
    }
    self.beginFrame = beginFrame

    // End frame.
    guard let endFrameUserInfo = userInfo[frameEndKey],
          let endFrame = endFrameUserInfo as? CGRect else {
        return nil
    }
    self.endFrame = endFrame
  }
}
protocol KeyboardObserverEventResponder: AnyObject {
  func keyboardWillShow(_ userInfo: KeyboardUserInfo)
  func keyboardDidShow(_ userInfo: KeyboardUserInfo)
  func keyboardWillHide(_ userInfo: KeyboardUserInfo)
  func keyboardDidHide(_ userInfo: KeyboardUserInfo)
  func keyboardWillChangeFrame(_ userInfo: KeyboardUserInfo)
  func keyboardDidChangeFrame(_ userInfo: KeyboardUserInfo)
}
extension KeyboardObserverEventResponder {
  func keyboardWillShow(_ userInfo: KeyboardUserInfo) {
    // No-op.
    // This default implementation allows this protocol method
    // to be optional.
  }

  func keyboardDidShow(_ userInfo: KeyboardUserInfo) {
    // No-op.
    // This default implementation allows this protocol method
    // to be optional.
  }

  func keyboardWillHide(_ userInfo: KeyboardUserInfo) {
    // No-op.
    // This default implementation allows this protocol method
    // to be optional.
  }

  func keyboardDidHide(_ userInfo: KeyboardUserInfo) {
    // No-op.
    // This default implementation allows this protocol method
    // to be optional.
  }

  func keyboardWillChangeFrame(_ userInfo: KeyboardUserInfo) {
    // No-op.
    // This default implementation allows this protocol method
    // to be optional.
  }

  func keyboardDidChangeFrame(_ userInfo: KeyboardUserInfo) {
    // No-op.
    // This default implementation allows this protocol method
    // to be optional.
  }
}
class KeyboardObserver: Observer {

  // MARK: - Properties
  weak var eventResponder: KeyboardObserverEventResponder? {
    didSet {
      if eventResponder == nil {
        stopObserving()
      }
    }
  }

  private var isObserving = false

  // MARK: - Methods
  func startObserving() {
    if isObserving == true {
      return
    }

    let notificationCenter = NotificationCenter.default

    notificationCenter.addObserver(
      self,
      selector: #selector(keyboardWillShow),
      name: UIResponder.keyboardWillShowNotification,
      object: nil
    )

    notificationCenter.addObserver(
      self,
      selector: #selector(keyboardDidShow),
      name: UIResponder.keyboardDidShowNotification,
      object: nil
    )

    notificationCenter.addObserver(
      self,
      selector: #selector(keyboardWillHide),
      name: UIResponder.keyboardWillHideNotification,
      object: nil
    )

    notificationCenter.addObserver(
      self,
      selector: #selector(keyboardDidHide),
      name: UIResponder.keyboardDidHideNotification,
      object: nil
    )

    notificationCenter.addObserver(
      self,
      selector: #selector(keyboardWillChangeFrame),
      name: UIResponder.keyboardWillChangeFrameNotification,
      object: nil
    )

    notificationCenter.addObserver(
      self,
      selector: #selector(keyboardDidChangeFrame),
      name: UIResponder.keyboardDidChangeFrameNotification,
      object: nil
    )

    isObserving = true
  }

  func stopObserving() {
    let notificationCenter = NotificationCenter.default
    notificationCenter.removeObserver(self)

    isObserving = false
  }

  @objc func keyboardWillShow(notification: Notification) {
    // 1
    assert(notification.name ==
      UIResponder.keyboardWillShowNotification)
    // 2
    guard let userInfo = KeyboardUserInfo(notification) else {
      assertionFailure()
      return
    }
    // 3
    eventResponder?.keyboardWillShow(userInfo)
  }

  @objc func keyboardDidShow(notification: Notification) {
    // 1
    assert(notification.name ==
      UIResponder.keyboardDidShowNotification)
    // 2
    guard let userInfo = KeyboardUserInfo(notification) else {
      assertionFailure()
      return
    }
    // 3
    eventResponder?.keyboardDidShow(userInfo)
  }

  @objc func keyboardWillHide(notification: Notification) {
    // 1
    assert(notification.name ==
      UIResponder.keyboardWillHideNotification)
    // 2
    guard let userInfo = KeyboardUserInfo(notification) else {
      assertionFailure()
      return
    }
    // 3
    eventResponder?.keyboardWillHide(userInfo)
  }

  @objc func keyboardDidHide(notification: Notification) {
    // 1
    assert(notification.name ==
      UIResponder.keyboardDidHideNotification)
    // 2
    guard let userInfo = KeyboardUserInfo(notification) else {
      assertionFailure()
      return
    }
    // 3
    eventResponder?.keyboardDidHide(userInfo)
  }

  @objc func keyboardWillChangeFrame(
    notification: Notification
  ) {
    // 1
    assert(notification.name ==
      UIResponder.keyboardWillChangeFrameNotification)
    // 2
    guard let userInfo = KeyboardUserInfo(notification) else {
      assertionFailure()
      return
    }
    // 3
    eventResponder?.keyboardWillChangeFrame(userInfo)
  }

  @objc func keyboardDidChangeFrame(
    notification: Notification
  ) {
    // 1
    assert(notification.name ==
      UIResponder.keyboardDidChangeFrameNotification)
    // 2
    guard let userInfo = KeyboardUserInfo(notification) else {
      assertionFailure()
      return
    }
    // 3
    eventResponder?.keyboardDidChangeFrame(userInfo)
  }
}

Building multicast observers

In the case that you have a large number of view controllers on-screen, which are all listening to the same events from the same reusable observer class, your app could have a large number of observer instances all listening to the exact same notifications or events.

Composing multiple observers

Say you’re working on a view controller and you’ve designed four different observers. You plan on calling startObserving and stopObserving on all four observers at the same time. Creating four observer properties in the view controller and calling these methods can be inconvenient.

class ObserverComposition: Observer {

  // MARK: - Properties
  let observers: [Observer]

  // MARK: - Methods
  init(observers: Observer...) {
    self.observers = observers
  }

  func startObserving() {
    observers.forEach {
      $0.startObserving()
    }
  }

  func stopObserving() {
    observers.forEach {
      $0.stopObserving()

    }
  }
}
class KooberOnboardingDependencyContainer {
  // ...

  func makeSignInViewController() -> SignInViewController {
    // User interface element
    let userInterface = SignInRootView()

    // Observer elements
    // 1
    let statePublisher = 
      makeSignInViewControllerStatePublisher()
    let stateObserver =
      SignInViewControllerStateObserver(state: statePublisher)
    let keyboardObserver = KeyboardObserver()

    // 2
    let composedObservers =
      ObserverComposition(stateObserver, keyboardObserver)

    // 3
    let signInViewController =
      SignInViewController(
        userInterface: userInterface,
        observer: composedObservers
      )

    // Wire responders
    userInterface.ixResponder = signInViewController
    // 4
    stateObserver.eventResponder = signInViewController
    keyboardObserver.eventResponder = signInViewController

    return signInViewController
  }

  // ...
}

Initializing observer with event responder

One thing you might have noticed is the event responder property on all of the observers is mutable and not private. If, in your code, you’re following the dependency container factory method patterns shown in the examples, this isn’t a huge problem because view controllers don’t have access to observer’s event responder properties. However, you don’t have to use the dependency container pattern in order to use this Observer pattern.

class KeyboardObserver: Observer {

  // MARK: - Properties
  private weak var eventResponder:
    KeyboardObserverEventResponder?
  private var isObserving = false

  // MARK: - Methods
  init(eventResponder: KeyboardObserverEventResponder) {
    self.eventResponder = eventResponder
  }

  func startObserving() {
    if isObserving {
      return
    }
    // ...
    isObserving = true
  }

  func stopObserving() {
    // ...
    isObserving = false
  }

  // ...
}
class SignInViewController: NiblessViewController {

  // MARK: - Properties
  let userInterface: SignInUserInterfaceView
  var observer: Observer?  // < Look here.


  // MARK: - Methods
  init(userInterface: SignInUserInterfaceView) {
    self.userInterface = userInterface
    super.init()
  }

  override func loadView() {
    view = userInterface
  }

  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    observer?.startObserving()
  }

  override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    observer?.stopObserving()
  }

  // ...
}

When to use it

The Observer element is perfect for situations where view controllers need to update their view hierarchy in response to external events; i.e., events not emitted by the view controller’s own view hierarchy. If you’re taking a unidirectional approach, all of your view controllers probably need an observer to listen for view state changes.

Why use this element?

Observers help keep your view controllers small and light. They remove a lot of technology-specific boilerplate from your view controllers. This ends up making your view controllers much easier to read and reason about. Using observers, any developer can read a view controller without having to know specifics of NotificationCenter, Combine, ReSwift store subscriptions, etc. Anyone reading a view controller can clearly and obviously see what all external events come into the view controller by inspecting the event responder methods.

Origin

The observer element isn’t a new idea. It’s one of the patterns explained in the famous 1994 Gang of Four book, Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides.

Use case

Use cases are command pattern objects that know how to do a task needed by a user. Use cases know:

Mechanics

In this section you’ll learn, at a high level, how to create, inject, use and de-allocate use case objects. This section is pure theory. If it’s a bit fuzzy, don’t worry, you’ll walk through many different code examples further ahead. The theory will help you hit the ground running when reading through the code examples.

Instantiating

Use cases are created every time your app needs to perform a user task. For instance, a Twitter app would create a new LikeTweetUseCase instance every time a user taps on a tweet’s Like button. Use cases are usually created and started by view controllers in response to a user’s interaction with the UI. However, you can create and start a use cases in response to any system event as well; i.e., use cases aren’t just for responding to UI events.

Providing

Because use cases are created on-demand, whenever you need to perform a user task, they cannot be injected into other objects. Say you’re building a view controller for a settings screen. The view controller needs to be able to create a new use case every time a user toggles a setting. So the view controller can’t be injected with a single use case instance, because the view controller might need to create more than one instance.

Using

Use cases are super easy to use. Once you’ve created a use case, you just need to start it. It’s similar to how you create and resume URLSessionDataTasks.

Tearing down

This part is a bit more complicated. Ideally, use cases are created when needed and deallocated when completed. The easiest way to accomplish this is to have view controllers, or whatever objects are starting use cases, hold each use case instance in an optional stored property. When a use case finishes, the view controller can nil out the property.

Types

It’s time to transition from theory to code. To get started, this section covers the main types you’ll declare in order to build, create and use use case objects.

Use case protocol

Use cases are represented by a very simple protocol:

protocol UseCase {
  func start()
}

Swift Result enum

For the simplest usage, use cases report back their result. Result is a great way to report success or failure in a clean manner. Result is included starting with Swift 5:

// A value that represents either a success or a failure, including an
// associated value in each case.
public enum Result<Success, Failure> where Failure : Error {

  // A success, storing a `Success` value.
  case success(Success)

  // A failure, storing a `Failure` value.
  case failure(Failure)

  // ...
}

Use case result type alias

Because Result is generic, specializing the enum in type annotations is inconvenient. Especially in closure types because the type signature becomes really long. typealiases, like the following one, help keep lines of code short:

typealias SignInUseCaseResult = Result<UserSession,
                                       ErrorMessage>

Use case classes

This is a skeleton of an example use case implementation used to sign users in to Koober:

// 1
class SignInUseCase: UseCase {

  // MARK: - Properties
  // 2
  // Input data
  let username: String
  let password: Secret

  // 3
  // Side-effect subsystems
  let remoteAPI: AuthRemoteAPI
  let dataStore: UserSessionDataStore

  // 4
  // Progress closures
  let onStart: () -> Void
  let onComplete: (SignInUseCaseResult) -> Void

  // MARK: - Methods
  // 5
  init(
    username: String,
    password: String,
    remoteAPI: AuthRemoteAPI,
    dataStore: UserSessionDataStore,
    onStart: (() -> Void)? = nil,
    onComplete: ((SignInUseCaseResult) -> Void)? = nil
  ) {
    // Input data
    self.username = username
    self.password = password

    // Side-effect subsystems
    self.remoteAPI = remoteAPI
    self.dataStore = dataStore

    // Progress closures
    self.onStart = onStart ?? {}
    self.onComplete = onComplete ?? { result in }
  }

  // 6
  func start() {
    assert(Thread.isMainThread)
    onStart()

	// Do some work and call onComplete when finished.
    // ...
  }
}

Use case factory type alias

In the sign-in example that you’ll walk through in the next section, the SignInViewController needs to be able to create a use case when the user taps the Sign in button. SignInViewController creates a use case using a use case factory closure. The closure’s type is too long to use inline. This use case factory typealias solves the closure type length problem:

typealias SignInUseCaseFactory =
  (
    String,
    Secret,
    @escaping () -> Void,
    @escaping (SignInUseCaseResult) -> Void
  ) -> UseCase

Example

This section uses Koober’s sign-in functionality to demonstrate how use cases can be built and used. Koober’s SignInViewController needs a use case that’s capable of trying to sign a user into Koober with a username and password. In Koober, SignInUseCase implements the logic needed by SignInViewController. When a user taps the Sign In button on the sign-in screen, SignInViewController starts a SignInUseCase.

typealias SignInUseCaseResult = Result<UserSession,
                                       ErrorMessage>
class SignInUseCase: UseCase {

  // MARK: - Properties
  // Input data
  let username: String
  let password: Secret

  // Side-effect subsystems
  let remoteAPI: AuthRemoteAPI
  let dataStore: UserSessionDataStore

  // Progress closures
  let onStart: () -> Void
  let onComplete: (SignInUseCaseResult) -> Void

  // MARK: - Methods
  init(
    username: String,
    password: String,
    remoteAPI: AuthRemoteAPI,
    dataStore: UserSessionDataStore,
    onStart: (() -> Void)? = nil,
    onComplete: ((SignInUseCaseResult) -> Void)? = nil
  ) {
    // Input data
    self.username = username
    self.password = password

    // Side-effect subsystems
    self.remoteAPI = remoteAPI
    self.dataStore = dataStore

    // Progress closures
    self.onStart = onStart ?? {}
    self.onComplete = onComplete ?? { result in }
  }

  public func start() {
	assert(Thread.isMainThread)
    onStart()

    // 1
    firstly {
      // 2
      self.remoteAPI.signIn(username: username,
                            password: password)
    }.then { userSession in
      // 3
      self.dataStore.save(userSession: userSession)
    }.done { userSession in
      // 4
      self.onComplete(.success(userSession))
    }.catch { error in
      // 5
      let errorMessage =
        ErrorMessage(title: "Sign In Failed",
                     message: """
                              Could not sign in.
                              Please try again.
                              """)
      self.onComplete(.failure(errorMessage))
    }
  }
}
typealias SignInUseCaseFactory =
  (
    String, // username
    Secret, // password
    @escaping () -> Void, // onStart
    @escaping (SignInUseCaseResult) -> Void  // onComplete
  ) -> UseCase
class SignInViewController: NiblessViewController {

  // MARK: - Properties
  // 1
  let makeSignInUseCase: SignInUseCaseFactory

  let userInterface: SignInUserInterfaceView

  // MARK: - Methods
  // 2
  init(
    userInterface: SignInUserInterfaceView,
    signInUseCaseFactory: @escaping SignInUseCaseFactory
  ) {
    self.userInterface = userInterface
    self.makeSignInUseCase = signInUseCaseFactory
    super.init()
  }

  public override func loadView() {
    view = userInterface
  }

  // ...
}

extension SignInViewController: SignInIxResponder {

  // 3
  func signIn(email: String, password: Secret) {
    // 4
    let onStart = {
      // Update UI to indicate use case has started,
      // such as starting an activity indicator.
	  // ...
    }
    let onComplete: (SignInUseCaseResult) -> Void = { result in
      // Process result from running use case by
      // for example, stopping activity indicator
      // and presenting error if necessary.
      // ...
    }

    // 5
    let useCase = makeSignInUseCase(email,
                                    password,
                                    onStart,
                                    onComplete)
    // 6
    useCase.start()
  }

  // ...
}
class KooberOnboardingDependencyContainer {
  // ...

  // 1
  func makeSignInUseCase(
    username: String,
    password: Secret,
    onStart: @escaping () -> Void,
    onComplete: @escaping (SignInUseCaseResult) -> Void
  ) -> UseCase {
    // 2
    let authRemoteAPI = self.makeAuthRemoteAPI()
    let userSessionDataStore =
      self.userSessionDataStore

    // 3
    let useCase = SignInUseCase(
      username: username,
      password: password,
      remoteAPI: authRemoteAPI,
      dataStore: userSessionDataStore,
      onStart: onStart,
      onComplete: onComplete)
    // 4
    return useCase
  }

  // 5
  func makeSignInViewController() -> SignInViewController {
    // User interface element
    let userInterface = SignInRootView()

    // Use case element
    // 6
    let signInUseCaseFactory = self.makeSignInUseCase

    // 7
    let signInViewController =
      SignInViewController(
        userInterface: userInterface,
        stateObserver: stateObserver,
        keyboardObserver: keyboardObserver,
        signInUseCaseFactory: signInUseCaseFactory)

    // Wire responders
    userInterface.ixResponder = signInViewController

    return signInViewController
  }

  // ...
}

Variations and advanced usage

You’ve read most of what you need to incorporate use cases into your own Xcode projects. However, there are some subtle variations that you might prefer to use. This section walks through using protocols instead of closure types for use case factories, designing unidirectional use cases and designing cancelable use cases.

Using use case factory protocols instead of closures

One of the big drawbacks with the use case factory closure type is that the parameters aren’t labeled:

typealias SignInUseCaseFactory =
  (
    String, // username
    Secret, // password
    @escaping () -> Void, // onStart
    @escaping (SignInUseCaseResult) -> Void  // onComplete
  ) -> UseCase
protocol SignInUseCaseFactory {
  func makeSignInUseCase(
    username: String,
    password: Secret,
    onStart: @escaping () -> Void,
    onComplete: @escaping (SignInUseCaseResult) -> Void
  ) -> UseCase
}
class SignInViewController: NiblessViewController {

  // MARK: - Properties
  let signInUseCaseFactory: SignInUseCaseFactory
  let userInterface: SignInUserInterfaceView

  // MARK: - Methods
  init(
    userInterface: SignInUserInterfaceView,
    signInUseCaseFactory: SignInUseCaseFactory
  ) {
    self.userInterface = userInterface
    self.signInUseCaseFactory = signInUseCaseFactory
    super.init()
  }

  override func loadView() {
    view = userInterface
  }

  // ...
}

extension SignInViewController: SignInIxResponder {
  func signIn(email: String, password: Secret) {
    let onStart = {
      // Update UI to indicate use case has started,
      // such as starting an activity indicator.
    }
    let onComplete: (SignInUseCaseResult) -> Void = { result in
      // Process result from running use case by
      // for example, stopping activity indicator
      // and presenting error if necessary.
    }

    let useCase =
      signInUseCaseFactory.makeSignInUseCase(
        username: email,
        password: password,
        onStart: onStart,
        onComplete: onComplete
      )
    useCase.start()
  }
}

// ...
class KooberOnboardingDependencyContainer {
  // ...

  func makeSignInUseCase(
    username: String,
    password: Secret,
    onStart: @escaping () -> Void,
    onComplete: @escaping (SignInUseCaseResult) -> Void
  ) -> UseCase {
    // Factory method implementation.
    // ...
  }

  // ...
}
extension KooberOnboardingDependencyContainer:
  SignInUseCaseFactory {}
class KooberOnboardingDependencyContainer {
  // ...

  func makeSignInViewController() -> SignInViewController {
    // User interface element
    let userInterface = SignInRootView()

    let signInViewController =
      SignInViewController(
        userInterface: userInterface,
        signInUseCaseFactory: self  // < Look here.
      )

    // Wire responders
    userInterface.ixResponder = signInViewController

    return signInViewController
  }

  // ...
}

Providing use case completion closure on start

In the main example, the sign-in use case’s onComplete closure was provided to the use case during initialization of the use case. You might have thought that looked a bit strange.

class SignInViewController: NiblessViewController {
  // ...
}

extension SignInViewController: SignInIxResponder {
  func signIn(email: String, password: Secret) {
    let useCase = makeSignInUseCase(email,
                                    password,
                                    onStart,
                                    onComplete)
    useCase.start() { result in
	  // Process result from running use case by
      // for example, stopping activity indicator
      // and presenting error if necessary.
      // ...
    }
  }

  // ...
}
protocol UseCase {
  associatedtype Success
  associatedtype Failure: Error

  func start(
    onComplete: (Result<Success, Failure>) -> Void)
}
class KooberOnboardingDependencyContainer {
  // ...

  // ! Does not compile. Compiler error:
  // Protocol 'UseCase' can only be used as a generic constraint
  // because it has Self or associated type requirements
  func makeSignInUseCase(
    username: String,
    password: Secret,
    onStart: @escaping () -> Void,
    onComplete: @escaping (SignInUseCaseResult) -> Void
  ) -> UseCase { // < The problem is here, with the return type.
    // ...
  }

  // ...
}

Designing hybrid unidirectional-bidirectional use cases

In the main example, the SignInUseCase gives the SignInViewController the use case result via the onComplete closure. What if another object also needs to know the result? The SignInViewController could start communicating with other objects by passing the result around. However, this isn’t great because object data flow becomes very hard to follow.

typealias SignInUseCaseResult = Result<Void,
                                       ErrorMessage>
class SignInUseCase: UseCase {

  // MARK: - Properties
  // Input data
  let username: String
  let password: Secret

  // Side-effect subsystems
  let remoteAPI: AuthRemoteAPI
  let dataStore: UserSessionDataStore

  // Progress closures
  let onStart: () -> Void
  let onComplete: (SignInUseCaseResult) -> Void

  // MARK: - Methods
  init(
    username: String,
    password: String,
    remoteAPI: AuthRemoteAPI,
    dataStore: UserSessionDataStore,
    onStart: (() -> Void)? = nil,
    onComplete: ((SignInUseCaseResult) -> Void)? = nil
  ) {
    // Input data
    self.username = username
    self.password = password

    // Side-effect subsystems
    self.remoteAPI = remoteAPI
    self.dataStore = dataStore

    // Progress closures
    self.onStart = onStart ?? {}
    self.onComplete = onComplete ?? { result in }
  }

  func start() {
    assert(Thread.isMainThread)
    onStart()

    firstly {
      self.remoteAPI.signIn(username: username,
                            password: password)
    }.then { userSession in
      self.dataStore.save(userSession: userSession)
    }.done { userSession in
      self.onComplete(.success(())) // < Look here.
    }.catch { error in
      let errorMessage =
        ErrorMessage(title: "Sign In Failed",
                     message: """
                              Could not sign in.
                              Please try again.
                              """)
      self.onComplete(.failure(errorMessage))
    }
  }
}

Designing database backed unidirectional use cases

When building apps following unidirectional data-flow patterns, you can either store your app’s state in a database or in a Redux-like in-memory state store. This section demonstrates what use cases look like if you’re using a database to store your app state.

class SignInUseCase: UseCase {

  // MARK: - Properties
  // Input data
  let username: String
  let password: Secret

  // Side-effect subsystems
  let remoteAPI: AuthRemoteAPI
  let dataStore: UserSessionDataStore

  // MARK: - Methods
  init(
    username: String,
    password: String,
    remoteAPI: AuthRemoteAPI,
    dataStore: UserSessionDataStore
  ) {
    // Input data
    self.username = username
    self.password = password

    // Side-effect subsystems
    self.remoteAPI = remoteAPI
    self.dataStore = dataStore
  }

  func start() {
    assert(Thread.isMainThread)

	firstly {
      // 1
      self.dataStore.save(signingIn: true)
	}.then { _ in
      self.remoteAPI.signIn(username: username,
                            password: password)
    }.done { userSession in
      // 2
      self.dataStore.save(userSession: userSession,
						  signingIn: false)
    }.catch { error in
      let errorMessage =
        ErrorMessage(title: "Sign In Failed",
                     message: """
                              Could not sign in.
                              Please try again.
                              """)
      // 3
      firstly {
        self.dataStore.save(signInError: errorMessage,
							signingIn: false)
      }.catch { error in
		assertionFailure("\(error)")
      }
    }
  }
}

Designing Redux unidirectional use cases

Use cases also work really well in apps built using the Redux architecture pattern. Here’s another version of SignInUseCase that could be used inside Chapter 6’s example project:

class SignInUseCase: UseCase {

  // MARK: - Properties
  // Input data
  let username: String
  let password: Secret

  // Side-effect subsystems
  let remoteAPI: AuthRemoteAPI

  // Redux action dispatcher
  let actionDispatcher: ActionDispatcher

  // MARK: - Methods
  init(
    username: String,
    password: String,
    remoteAPI: AuthRemoteAPI,
    actionDispatcher: ActionDispatcher
  ) {
    // Input data
    self.username = username
    self.password = password

    // Side-effect subsystems
    self.remoteAPI = remoteAPI
    self.actionDispatcher = actionDispatcher
  }

  func start() {
    assert(Thread.isMainThread)

    // 1
	let action = SignInActions.SigningIn()
    actionDispatcher.dispatch(action)

    firstly {
      self.remoteAPI.signIn(username: username,
                            password: password)
    }.done { userSession in
      // 2
      let action =
        SignInActions.SignedIn(userSession: userSession)
      self.actionDispatcher.dispatch(action)
    }.catch { error in
      let errorMessage =
        ErrorMessage(title: "Sign In Failed",
                     message: """
                              Could not sign in.
                              Please try again.
                              """)
      // 3
      let action =
        SignInActions.SignInFailed(errorMessage: errorMessage)
      self.actionDispatcher.dispatch(action)
    }
  }
}

Designing cancelable use cases

By adding some additional types, you can take what you’ve learn so far and add cancelation to any use case. The first type to look at is the Cancelable protocol:

protocol Cancelable {
  func cancel()
}
typealias CancelableUseCase = Cancelable & UseCase
typealias SearchDropoffLocationsUseCaseFactory =
  (
    String, // query
    Location // pickupLocation
  ) -> CancelableUseCase
class SearchDropoffLocationsUseCase: CancelableUseCase {

  // MARK: - Properties
  let query: String
  let pickupLocation: Location
  let actionDispatcher: ActionDispatcher
  let remoteAPI: NewRideRemoteAPI

  // 1
  var cancelled = false

  // MARK: - Methods
  init(query: String,
       pickupLocation: Location,
       actionDispatcher: ActionDispatcher,
       remoteAPI: NewRideRemoteAPI) {
    self.query = query
    self.pickupLocation = pickupLocation
    self.actionDispatcher = actionDispatcher
    self.remoteAPI = remoteAPI
  }

  // 2
  func cancel() {
    assert(Thread.isMainThread)
    cancelled = true
  }

  func start() {
    assert(Thread.isMainThread)
    // 3
    guard !cancelled else {
      return
    }

    firstly {
      remoteAPI.getLocationSearchResults(
        query: query,
        pickupLocation: pickupLocation
      )
    }.done { results in
      // 4
      guard self.cancelled == false else {
        return
      }

      let action = ReceivedSearchResultsAction(results: results)
      self.actionDispatcher.dispatch(action: action)
    }.catch { error in
      let errorMessage =
        ErrorMessage(title: "Error Searching",
                     message: """
                              Could not run location search.
                              Please try again.
                              """)
      let action =
        SignedInErrorOccuredAction(errorMessage: errorMessage)
      self.actionDispatcher.dispatch(action: action)
    }
  }
}

When to use

Most of the time, use cases are used within view controllers or view models. Use cases typically run as a response to a user’s interaction with your app’s UI. However, sometimes you need to do some work in response to some system event, such as a location notification. You can use use cases for these situations as well.

Why use this element?

The use case pattern is one of the most versatile patterns I’ve used in iOS app development. Use cases fit into nearly all architecture patterns. And, they come with a lot of benefits.

Origin

I first came across code that looked like use cases when reading Agile Principles, Patterns, and Practices in C# by Robert C. Martin and Micah Martin. The use case pattern in Elements was inspired by the transaction pattern presented in the book’s Payroll case study.

Pros and cons of Elements

Pros of Elements

Cons of Elements

  1. Elements makes use of many different protocols. You might feel like you’re working with too many protocols. This is especially true in the dependency container code. If this is the case, the protocols are all optional. Feel free to exclusively use concrete versions. Just know that you might lose some unit testing benefits.
  2. Elements breaks logic down into fairly small pieces. You can end up with lots of classes. It can be difficult to navigate an Xcode project if the files aren’t organized well.
  3. While most of the Elements evolved from existing ideas and techniques, Elements as a whole is new and other developers might not be familiar with the patterns. As of this writing, this book is the only source of information about Elements.

Key points

  • Observers are objects that view controllers use to receive external events. You can think of these events as input signals to view controllers.
  • The Observer element is perfect for situations where view controllers need to update their view hierarchy in response to external events; i.e., events not emitted by the view controller’s own view hierarchy.
  • Observers help keep your view controllers small and light. They remove a lot of technology specific boilerplate from your view controllers.
  • Use cases are command pattern objects that know how to do a task needed by a user.
  • UseCases fit into nearly all architecture patterns — and they come with a lot of benefits.
  • Most of the time, use cases are used within view controllers or view models. Use cases typically run as a response to a user’s interaction with your app’s UI.
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