At the end of the previous episode, we discovered and resolved a memory issue. When the user completes the purchase flow, the BuyCoordinator instance is deallocated by removing it from the array of child coordinators.
The user can exit the purchase flow by tapping the Cancel button of the buy view controller. But there's another scenario we haven't accounted for. The user can also exit the purchase flow by tapping the back button of the sign in view controller or the back button of the buy view controller. That scenario introduces a subtle problem. Let me show you what I mean.
Exposing the Problems
Build and run the application. Make sure the user is signed out. Initiate the purchase flow by tapping a photo in the table view and tapping the Buy button in the top right. Tap the Sign In button below the text fields to sign in. After signing in, the buy view controller is shown to the user.
Tap the back button of the buy view controller in the top left to navigate back to the previous view controller. The application takes the user back to the sign in view controller. This is the first issue we need to resolve. Once the user is signed in, they shouldn't be able to navigate back to the sign in view controller.
Tap the back button of the sign in view controller in the top left to navigate back to the photo view controller. The user should be able to cancel the purchase flow by tapping the back button of the sign in view controller. This is the expected behavior, but it introduces a subtle problem.
Click the Debug Memory Graph button in the debug bar at the bottom. Enter BuyCoordinator in the search field at the bottom. Even though the user cancelled the purchase flow by tapping the back button of the sign in view controller, the BuyCoordinator instance that managed the purchase flow is still alive. The memory graph debugger shows that the AppCoordinator instance keeps a reference to the BuyCoordinator instance.

Hiding the Back Button
We need to resolve two problems. Let's start with the simplest one. The user shouldn't be able to navigate back to the sign in view controller once they're signed in. The solution is surprisingly simple. The buy view controller shows a Cancel button in the top right to cancel the purchase flow. This means that we can safely hide the back button of the buy view controller.
Open BuyViewController.swift and navigate to the viewDidLoad() method. We set the hidesBackButton property of the navigation item of the buy view controller to true. That's the only change we need to make.
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
// Setup View
setupView()
// Hide Back Button
navigationItem.hidesBackButton = true
}
Notifying the Parent Coordinator
Remember that a child coordinator is responsible for a subflow of the application. It notifies its parent coordinator when it has finished the subflow it is responsible for. The user can cancel the purchase flow by tapping the Cancel button of the buy view controller or by tapping the back button of the sign in view controller.
Tapping the back button is handled by the navigation controller. The sign in view controller isn't aware or notified that the user tapped the back button. This means that the buy coordinator doesn't know that the user cancelled the purchase flow and, as a result, it isn't able to notify its parent coordinator that the purchase flow was cancelled.
To address this issue, the buy coordinator needs to be able to detect when the user taps the back button. In other words, the buy coordinator must be notified when a view controller is popped from the navigation stack. The UINavigationController class defines a delegate property, an object conforming to the UINavigationControllerDelegate protocol. The delegate of a navigation controller is notified when a view controller is pushed onto or popped from the navigation controller's navigation stack.
We are interested in the navigationController(_:didShow:animated:) method of the UINavigationControllerDelegate protocol. This method is invoked by the navigation controller when a view controller is pushed onto or popped from the navigation controller's navigation stack.
That brings us to the question "Which object should be the delegate of the navigation controller?" The application coordinator and the buy coordinator use the same navigation controller to push and pop view controllers. Which coordinator should act as the delegate of the navigation controller, the parent or the child?
One option is to make the child coordinator the delegate of the navigation controller, but that has a few side effects. What happens if another child coordinator presents one or more view controllers? Should that child coordinator become the delegate of the navigation controller? I don't like the idea of updating the delegate of the navigation controller. This can easily result in bugs that can often only be resolved by implementing clunky workarounds. A single object should act as the delegate of the navigation controller.
With that requirement in mind, it makes sense to put the application coordinator, the parent coordinator of the buy coordinator, in charge of this task. Open AppCoordinator.swift and navigate to the start() method. Make the application coordinator the delegate of the navigation controller by setting the delegate property of the navigation controller.
func start() {
// Set Navigation Controller Delegate
navigationController.delegate = self
// Show Photos
showPhotos()
}
The compiler warns us that the AppCoordinator class doesn't conform to the UINavigationControllerDelegate protocol. Create an extension for the AppCoordinator class and conform the AppCoordinator class to the UINavigationControllerDelegate protocol. The methods we are interested in are navigationController(_:willShow:animated:) and navigationController(_:didShow:animated:). We implement these methods later in this episode.
extension AppCoordinator: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {}
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {}
}
The compiler throws another error. The UINavigationControllerDelegate protocol inherits the NSObjectProtocol protocol. To conform to the UINavigationControllerDelegate protocol, the AppCoordinator class also needs to conform to the NSObjectProtocol protocol. This is easy to fix. The AppCoordinator class subclasses or inherits from the NSObject class. That's it.
import UIKit
import Foundation
class AppCoordinator: NSObject {
...
}
The application coordinator is notified when a view controller is pushed or popped. But I don't want to put the application coordinator in charge of deciding when one of its child coordinators has finished its subflow. That logic should live in the child coordinator. The solution isn't complex.
We start by extending the Coordinator protocol. Open Coordinator.swift and define two methods, navigationController(_:willShow:animated:) and navigationController(_:didShow:animated:). Notice that the method signatures are identical to the methods of the UINavigationControllerDelegate protocol we implemented in the AppCoordinator class. The idea is simple. The parent coordinator forwards the messages it receives from the navigation controller to its child coordinators. It's up to the child coordinator to decide how to respond to those messages.
import UIKit
protocol Coordinator: AnyObject {
// MARK: - Properties
var didFinish: ((Coordinator) -> Void)? { get set }
// MARK: - Methods
func start()
// MARK: -
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool)
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool)
}
Create an extension for the Coordinator protocol in Coordinator.swift. We use the extension to provide default implementations for the navigationController(_:willShow:animated:) and navigationController(_:didShow:animated:) methods. By providing default implementations for these methods, types conforming to the Coordinator protocol are not required to provide an implementation for these methods. If a child coordinator doesn't use a navigation controller, then it doesn't make sense to implement these methods.
import UIKit
protocol Coordinator: AnyObject {
// MARK: - Properties
var didFinish: ((Coordinator) -> Void)? { get set }
// MARK: - Methods
func start()
// MARK: -
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool)
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool)
}
extension Coordinator {
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {}
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {}
}
The next question we need to answer is "How should the buy coordinator respond when it is notified that a view controller is pushed or popped?" In other words, how can the buy coordinator detect that the user tapped the back button and cancelled the purchase flow? The solution is surprisingly simple.
Open BuyCoordinator.swift and implement the navigationController(_:didShow:animated:) method of the Coordinator protocol.
// MARK: - Public API
func start() {
if UserDefaults.isSignedIn {
buyPhoto(photo)
} else {
showSignIn()
}
}
// MARK: -
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
}
The user cancelled the purchase flow when the view controller the navigation controller navigates to is identical to the view controller stored in the initialViewController property. Remember that the buy coordinator keeps a reference to the topmost view controller of the navigation stack in its initializer. If the view controller the navigation controller navigates to is identical to the view controller stored in the initialViewController property, then the buy coordinator knows that the user cancelled the purchase flow. In that scenario, it invokes its didFinish handler.
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
if viewController === initialViewController {
didFinish?(self)
}
}
Notice that we use the identity operator, ===. We make sure that the viewController and initialViewController properties are referencing the same instance.
We can optimize the current implementation of the BuyCoordinator class. There's no need to invoke the didFinish handler at the end of the finish() method. The didFinish handler is automatically invoked when the buy coordinator navigates back to the initial view controller. We only invoke the didFinish handler in the else clause of the finish() method.
private func finish() {
// Reset Navigation Controller
if let viewController = initialViewController {
// Pop to Initial Root View Controller
navigationController.popToViewController(viewController, animated: true)
} else {
// Pop to Root View Controller
navigationController.popToRootViewController(animated: true)
// Invoke Handler
didFinish?(self)
}
}
Notifying the Child Coordinators
Before we can test the implementation, the application coordinator needs to notify its child coordinators when a view controller is pushed or popped. This is straightforward. In the navigationController(_:willShow:animated:) method of the UINavigationControllerDelegate protocol, the application coordinator invokes the navigationController(_:willShow:animated:) method of each child coordinator. The implementation of the navigationController(_:didShow:animated:) method is very similar.
extension AppCoordinator: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
childCoordinators.forEach { (childCoordinator) in
childCoordinator.navigationController(navigationController, willShow: viewController, animated: animated)
}
}
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
childCoordinators.forEach { (childCoordinator) in
childCoordinator.navigationController(navigationController, didShow: viewController, animated: animated)
}
}
}
Open BuyCoordinator.swift, implement the deinitializer for the BuyCoordinator class, and add a print statement to it. The print statement in the deinitializer makes it quick and easy to verify that the BuyCoordinator instance is about to be deallocated.
// MARK: - Deinitialization
deinit {
print("DEALLOCATING BUY COORDINATOR")
}
Build and run the application and open the console at the bottom. The user has two options to cancel the purchase flow, (1) they can tap the back button of the sign in view controller or (2) they can tap the Cancel button of the buy view controller.
Make sure the user is signed out. Initiate the purchase flow by tapping a photo in the table view and tapping the Buy button in the top right. Tap the Sign In button below the text fields to sign in. After signing in, the buy view controller is shown to the user. Tap the Cancel button of the buy view controller to cancel the purchase flow. The output in the console shows that the buy coordinator is about to be deallocated.
Navigate to the photos view controller and tap the Sign Out button. Initiate the purchase flow by tapping a photo in the table view and tapping the Buy button in the top right. Tap the back button of the sign in view controller to cancel the purchase flow. The output in the console shows that the buy coordinator is about to be deallocated.
We also need to make sure the buy coordinator is deallocated after the user successfully buys a photo. Navigate to the photos view controller and tap the Sign Out button. Initiate the purchase flow one more time by tapping a photo in the table view and tapping the Buy button in the top right. Tap the Sign In button below the text fields to sign in and tap the Buy button to complete the purchase. The output in the console shows that the buy coordinator is about to be deallocated.
What's Next?
Earlier in this episode, I mentioned that there are several options to respond to the user tapping the back button of the navigation controller. The option Soroush Khanlou proposes puts the parent coordinator in charge of detecting when a child coordinator finishes its subflow. While that's a viable solution, I prefer to put the child coordinator in charge of its own subflow. That improves the reusability and it allows us to encapsulate the logic for the subflow in the child coordinator.