DEV Community

loading...

Coordinator and FlowController

onmyway133 profile image Khoa Pham ・6 min read

Original post https://github.com/onmyway133/blog/issues/106

Every new architecture that comes out, either iOS or Android, makes me very excited. I'm always looking for ways to structure apps in a better way. But after some times, I see that we're too creative in creating architecture, aka constraint, that is too far away from the platform that we're building.

I like things that embrace the system. One of them is Coordinator which helps in encapsulation and navigation. Thanks to my friend Vadym for showing me Coordinator in action.

But after reading A Better MVC, I think that we can just embrace MVC by relying on UIViewController only.

Since I tend to call view controllers as LoginController, ProfileController, ... and the term flow to group those related screens, what should we call a Coordinator that inherits from UIViewController 🤔 Let's call it FlowController 😎

Let see how FlowController fits better into MVC

FlowController as container view controller

In general, a view controller should manage either sequence or UI, but not both.

Basically, FlowController is just a container view controller to solve the sequence, based on a simple concept called composition. It manages many child view controllers in its flow. Let' say we have a ProductFlowController that groups together flow related to displaying products, ProductListController, ProductDetailController, ProductAuthorController, ProductMapController, ... Each can delegate to the ProductFlowController to express its intent, like ProductListController can delegate to say "product did tap", so that ProductFlowController can construct and present the next screen in the flow, based on the embedded UINavigationController inside it.

Normally, a FlowController just displays 1 child FlowController at a time, so normally we can just update its frame

final class AppFlowController: UIViewController {
  override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()

    childViewControllers.first?.view.frame = view.bounds
  }
}

FlowController as dependency container

Each view controller inside the flow can have different dependencies, so it's not fair if the first view controller needs to carry all the stuff just to be able to pass down to the next view controllers. Here are some dependencies

  • ProductListController: ProductNetworkingService
  • ProductDetailController: ProductNetworkingService, ImageDowloaderService, ProductEditService
  • ProductAuthorController: AuthorNetworkingService, ImageDowloaderService
  • ProductMapController: LocationService, MapService

Instead the FlowController can carry all the dependencies needed for that whole flow, so it can pass down to the view controller if needed.

struct ProductDependencyContainer {
  let productNetworkingService: ProductNetworkingService
  let imageDownloaderService: ImageDownloaderService
  let productEditService: ProductEditService
  let authorNetworkingService: AuthorNetworkingService
  let locationService: LocationService
  let mapService: MapService
}

class ProductFlowController {
  let dependencyContainer: ProductDependencyContainer

  init(dependencyContainer: ProductDependencyContainer) {
    self.dependencyContainer = dependencyContainer
  }
}

extension ProductFlowController: ProductListController {
  func productListController(_ controller: ProductListController, didSelect product: Product) {
    let productDetailController = ProductDetailController(
      productNetworkingService: dependencyContainer.productNetworkingService,
      productEditService: dependencyContainer.productEditService,
      imageDownloaderService: dependencyContainer.imageDownloaderService
    )

    productDetailController.delegate = self
    embeddedNavigationController.pushViewController(productDetailController, animated: true)
  }
}

Adding or removing child FlowController

Coordinator

With Coordinator, you need to keep an array of child Coordinators, and maybe use address (=== operator) to identify them

class Coordinator {
  private var children: [Coordinator] = []

  func add(child: Coordinator) {
    guard !children.contains(where: { $0 === child }) else {
      return
    }

    children.append(child)
  }

  func remove(child: Coordinator) {
    guard let index = children.index(where: { $0 === child }) else {
      return
    }

    children.remove(at: index)
  }

  func removeAll() {
    children.removeAll()
  }
}

FlowController

With FlowController, since it is UIViewController subclass, it has viewControllers to hold all those child FlowController. Just add these extensions to simplify your adding or removing of child UIViewController

extension UIViewController {
  func add(childController: UIViewController) {
    addChildViewController(childController)
    view.addSubview(childController.view)
    childController.didMove(toParentViewController: self)
  }

  func remove(childController: UIViewController) {
    childController.willMove(toParentViewController: nil)
    childController.view.removeFromSuperview()
    childController.removeFromParentViewController()
  }
}

And see in action how AppFlowController work

final class AppFlowController: UIViewController {
  func start() {
    if authService.isAuthenticated {
      startMain()
    } else {
      startLogin()
    }
  }

  private func startLogin() {
    let loginFlowController = LoginFlowController(
    loginFlowController.delegate = self
    add(childController: loginFlowController)
    loginFlowController.start()
  }

  fileprivate func startMain() {
    let mainFlowController = MainFlowController()
    mainFlowController.delegate = self
    add(childController: mainFlowController)
    mainFlowController.start()
  }
}

AppFlowController does not need to know about UIWindow

Coordinator

Usually you have an AppCoordinator, which is held by AppDelegate, as the root of your Coordinator chain. Based on login status, it will determine which LoginController or MainController will be set as the rootViewController, in order to do that, it needs to be injected a UIWindow

window = UIWindow(frame: UIScreen.main.bounds)
appCoordinator = AppCoordinator(window: window!)
appCoordinator.start()
window?.makeKeyAndVisible()

You can guess that in the start method of AppCoordinator, it must set rootViewController before window?.makeKeyAndVisible() is called.

final class AppCoordinator: Coordinator {
  private let window: UIWindow

  init(window: UIWindow) {
    self.window = window
  }

  func start() {
    if dependencyContainer.authService.isAuthenticated {
      startMain()
    } else {
      startLogin()
    }
  }
}

FlowController

But with AppFlowController, you can treat it like a normal UIViewController, so just setting it as the rootViewController

appFlowController = AppFlowController(
window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = appFlowController
window?.makeKeyAndVisible()

appFlowController.start()

LoginFlowController can manage its own flow

Supposed we have login flow based on UINavigationController that can display LoginController, ForgetPasswordController, SignUpController

Coordinator

What should we do in the start method of LoginCoordinator? Construct the initial controller LoginController and set it as the rootViewController of the UINavigationController? LoginCoordinator can create this embedded UINavigationController internally, but then it is not attached to the rootViewController of UIWindow, because UIWindow is kept privately inside the parent AppCoordinator.

We can pass UIWindow to LoginCoordinator but then it knows too much. One way is to construct UINavigationController from AppCoordinator and pass that to LoginCoordinator

final class AppCoordinator: Coordinator {
  private let window: UIWindow

  private func startLogin() {
    let navigationController = UINavigationController()

    let loginCoordinator = LoginCoordinator(navigationController: navigationController)

    loginCoordinator.delegate = self
    add(child: loginCoordinator)
    window.rootViewController = navigationController
    loginCoordinator.start()
  }
}

final class LoginCoordinator: Coordinator {
  private let navigationController: UINavigationController

  init(navigationController: UINavigationController) {
    self.navigationController = navigationController
  }

  func start() {
    let loginController = LoginController(dependencyContainer: dependencyContainer)
    loginController.delegate = self

    navigationController.viewControllers = [loginController]
  }
}

FlowController

LoginFlowController leverages container view controller so it fits nicely with the way UIKit works. Here AppFlowController can just add LoginFlowController and LoginFlowController can just create its own embeddedNavigationController.

final class AppFlowController: UIViewController {
  private func startLogin() {
    let loginFlowController = LoginFlowController(
      dependencyContainer: dependencyContainer
    )

    loginFlowController.delegate = self
    add(childController: loginFlowController)
    loginFlowController.start()
  }
}

final class LoginFlowController: UIViewController {
  private let dependencyContainer: DependencyContainer
  private var embeddedNavigationController: UINavigationController!
  weak var delegate: LoginFlowControllerDelegate?

  init(dependencyContainer: DependencyContainer) {
    self.dependencyContainer = dependencyContainer
    super.init(nibName: nil, bundle: nil)

    embeddedNavigationController = UINavigationController()
    add(childController: embeddedNavigationController)
  }

  func start() {
    let loginController = LoginController(dependencyContainer: dependencyContainer)
    loginController.delegate = self

    embeddedNavigationController.viewControllers = [loginController]
  }
}

FlowController and responder chain

Coordinator

Sometimes we want a quick way to bubble up message to parent Coordinator, one way to do that is to replicate UIResponder chain using associated object and protocol extensions, like Inter-connect with Coordinator

extension UIViewController {
    private struct AssociatedKeys {
        static var ParentCoordinator = "ParentCoordinator"
    }

    public var parentCoordinator: Any? {
        get {
            return objc_getAssociatedObject(self, &AssociatedKeys.ParentCoordinator)
        }
        set {
            objc_setAssociatedObject(self, &AssociatedKeys.ParentCoordinator, newValue, .OBJC_ASSOCIATION_ASSIGN)
        }
    }
}

open class Coordinator<T: UIViewController>: UIResponder, Coordinating {
    open var parent: Coordinating?  

    override open var coordinatingResponder: UIResponder? {
        return parent as? UIResponder
    }
}

FlowController

Since FlowController is UIViewController, which inherits from UIResponder, responder chain happens out of the box

Responder objects—that is, instances of UIResponder—constitute the event-handling backbone of a UIKit app. Many key objects are also responders, including the UIApplication object, UIViewController objects, and all UIView objects (which includes UIWindow). As events occur, UIKit dispatches them to your app's responder objects for handling.

FlowController and trait collection

FlowController

I very much like how Kickstarter uses trait collection in testing. Well, since FlowController is a parent view controller, we can just override its trait collection, and that will affect the size classes of all view controllers inside that flow.

As in A Better MVC, Part 2: Fixing Encapsulation

The huge advantage of this approach is that system features come free. Trait collection propagation is free. View lifecycle callbacks are free. Safe area layout margins are generally free. The responder chain and preferred UI state callbacks are free. And future additions to UIViewController are also free.

loginFlowController.setOverrideTraitCollection

FlowController and back button

Coordinator

One problem with UINavigationController is that clicking on the default back button pops the view controller out of the navigation stack, so Coordinator is not aware of that. With Coordinator you needs to keep Coordinator and UIViewController in sync, add try to hook up UINavigationControllerDelegate in order to clean up. Like in Back Buttons and Coordinators

extension Coordinator: UINavigationControllerDelegate {    
    func navigationController(navigationController: UINavigationController,
        didShowViewController viewController: UIViewController, animated: Bool) {

        // ensure the view controller is popping
        guard
          let fromViewController = navigationController.transitionCoordinator?.viewController(forKey: .from),
          !navigationController.viewControllers.contains(fromViewController) else {
            return
        }

        // and it's the right type
        if fromViewController is FirstViewControllerInCoordinator) {
            //deallocate the relevant coordinator
        }
    }
}

Or creating a class called NavigationController that inside manages a list of child coordinators. Like in Navigation coordinators

final class NavigationController: UIViewController {
  // MARK: - Inputs

  private let rootViewController: UIViewController

  // MARK: - Mutable state

  private var viewControllersToChildCoordinators: [UIViewController: Coordinator] = [:]

  // MARK: - Lazy views

  private lazy var childNavigationController: UINavigationController =
      UINavigationController(rootViewController: self.rootViewController)

  // MARK: - Initialization

  init(rootViewController: UIViewController) {
    self.rootViewController = rootViewController

    super.init(nibName: nil, bundle: nil)
  }
}

FlowController

Since FlowController is just plain UIViewController, you don't need to manually manage child FlowController. The child FlowController is gone when you pop or dismiss. If we want to listen to UINavigationController events, we can just handle that inside the FlowController

final class LoginFlowController: UIViewController {
  private let dependencyContainer: DependencyContainer
  private var embeddedNavigationController: UINavigationController!
  weak var delegate: LoginFlowControllerDelegate?

  init(dependencyContainer: DependencyContainer) {
    self.dependencyContainer = dependencyContainer
    super.init(nibName: nil, bundle: nil)

    embeddedNavigationController = UINavigationController()
    embeddedNavigationController.delegate = self
    add(childController: embeddedNavigationController)
  }
}

extension LoginFlowController: UINavigationControllerDelegate {
  func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {

  }
}

Discussion (0)

pic
Editor guide