MVVM coordinators and popping a UIViewController

Viewed 2009

I've recently started using coordinators (Example: MVVM with Coordinators and RxSwift) to improve my current MVVM architecture. It's a nice solution to remove navigation related code from the UIViewController.

But I'm having trouble with 1 specific scenario. The issue arrises when a UIViewController is popped by the default back button or edge-swipe gesture.

Quick example using a list-detail interface:

A list UIViewController is shown by a ListCoordinator inside a UINavigationController. When an item is tapped, the ListCoordinator creates a DetailCoordinator, registers it as a child coordinator and starts it. The DetailCoordinator pushes the detail UIViewController onto the UINavigationController, just like every MVVM-C blog post illustrates.

What every MVVM-C blog post fails to illustrate is what happens when the detail UIViewController is popped by the default back button or edge-swipe gesture.

The DetailCoordinator should be responsible for popping the detail UIViewController, but a) it doesn't know the back button was tapped and b) the pop happens automatically. Also, the ListCoordinator wasn't able to remove the DetailCoordinator from its child coordinators.

One solution would be to use custom back buttons, which signal the tap and pass it on to the DetailCoordinator. Another one is probably using UINavigationControllerDelegate.

How have others solved this issue? I'm sure I'm not the first one.

3 Answers

I use Action for communication between coordinators and also between coordinator and a view controller.

AuthCoordinator

final class AuthCoordinator: Coordinator {
    func startLogin(viewModel: LoginViewModel) {
        let loginCoordinator = LoginCoordinator(navigationController: navigationController)

        loginCoordinator.start(viewModel: viewModel)
        viewModel.coordinator = loginCoordinator

        // This is where a child coordinator removed
        loginCoordinator.stopAction = CocoaAction { [unowned self] _ in
            if let index = self.childCoordinators.index(where: { type(of: $0) == LoginCoordinator.self }) {
                self.childCoordinators.remove(at: index)
            }
            return .empty()
        }
    }
}

LoginCoordinator

final class LoginCoordinator: Coordinator {
    var stopAction: CocoaAction?

    func start(viewModel: LoginViewModel) {
        let loginViewController = UIStoryboard.auth.instantiate(LoginViewController.self)
        loginViewController.setViewModel(viewModel: viewModel)
        navigationController?.pushViewController(loginViewController, animated: true)

        loginViewController.popAction = CocoaAction { [unowned self] _ in
            self.stopAction?.execute(Void())
            return .empty()
        }
    }
}

LoginViewController

class LoginViewController: UIViewController {
    var popAction: CocoaAction?

    override func didMove(toParentViewController parent: UIViewController?) {
        super.didMove(toParentViewController: parent)
        if parent == nil { // parent is `nil` when the vc is popped
            popAction?.execute(Void())
        }
    }
}

So the LoginViewController executes the action when it's popped. It's coordinator LoginCoordinator is aware that the view is popped. It triggers another action from its parent coordinator AuthCoordinator. The parent coordinator AuthCoordinator removes its child LoginCoordinator from the childControllers array/set.

BTW, why do you need to keep the child coordinators in the array and then thinking about how to remove them. I tried another approach, the child coordinator retained by a view model, once the view model deallocated, the coordinator deallocates too. Worked for me.

But I'm personally don't like so many connections and thinking about a simpler approach using a single coordinator object for everything.

Unless I am missing something, you can solve this by using this piece of code in your coordinate method. I am specifically using didShow instead of willShow (which was suggested in another answer) for the possibility of edge swipe gestures.

if let topViewController = navigationController?.topViewController {
    navigationController?.rx
        .didShow
        .filter { $0.viewController == topViewController }
        .first()
        .subscribe(onSuccess: { [weak self] _ in
            // remove child coordinator
        })
        .disposed(by: disposeBag)
}
Related