iOS: Root Controller Navigation

How to switch between the application parts and handle launch options.

This is a continuation of my article from June 11, where we created the universal manager to handle all types of deep-linking (notifications, shortcuts, universal links, deeplinks). The only question that we haven’t discussed is:

“How do we actually navigate to an appropriate screen once the deeplink is handled?”

In this article, we will go deeper and implement the clear and flexible way of App Root Navigation, that includes the Deeplink navigation. You will be able to access any part of your app from both inside the app or from any type of external deeplinks.


Most of the modern apps have at least two main parts: authentication part (pre-login), and protected part (post-login). Some apps have even more complex structure: multiple profiles within the same login, condition-based post-launch navigation (deeplinks).

In this tutorial we will design the way to structure your app, so you avoid the some of most-frequent mistakes, that can cause memory leaks, break the app architectural patterns, ruin the existing navigation structure, or simply make your user cry.

There are two common ways to navigate between the app parts that I’ve seen within the multiple applications:

  1. Using a single navigation stack to present or push the new View Controller with no interface to navigate back. This approach usually keeps the old View Controllers in memory.
  2. Using the key window to switch the window.rootViewController. This approach will kill the old ViewControllers, but it doesn’t look good from the UI standpoint. This also doesn’t allow you to easily navigate back and forward when needed.

But how about building the easy-maintainable app structure, that allows you to switch between the multiple parts with no headache, no spaghetti code, or even add a nice navigation to it?


Let’s pretend we are building the app with the following parts:

  • Splash screen: this screen will be presented right after the app is launched, and we can add animation here or make some service API calls.
  • Authentication part: standard screens to login, signup, reset password, confirm email, etc. The user session will be saved, so he doesn’t have to login every time he opens the app.
  • Main Part: the application itself.

All these application parts are isolated from each other and lives in the separate navigation stacks. So we can need to implement the following types of transitions:

  • Splash screen to Authentication screen, when no active user session exists.
  • Splash screen to Main screen, when the user session is valid. This is what will take place most of the time.
  • Main screen to Authentication screen, when the user logs out or the current session becomes invalid.
  • Deeplink Handling: open the app from the specific page based on pre-launch conditions — notifications, shortcuts, universal links, etc.

Basic setup

When the app is launched, we need to provide the RootViewController that will be loaded first. This can be done in code or using the interface builder. Create a new project in Xcode, and this part will be provided for your already: main.storyboard is hooked up to the window.rootViewController.

To focus on the main topic, we will not use the storyboards in this project. So delete main.storyboard file and also clear the main interface field in Deployment info under the Target Settings:

Update didFinishLaunchingWithOptions method in AppDelegate:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
   window = UIWindow(frame: UIScreen.main.bounds)
window?.rootViewController = RootViewController()
window?.makeKeyAndVisible()
return true
}

Now the app will launch with the RootViewControllerr. Create it by remaning the default ViewController to RootViewController:

class RootViewController: UIViewController {
}

This will be the root view controller, that will be responsible for all cross-part transitions. So we always want to keep the reference to this view controller and use it every time, when we need to navigate to another user flow. To simplify access to it, add an extension to AppDelegate:

extension AppDelegate {
static var shared: AppDelegate {
return UIApplication.shared.delegate as! AppDelegate
}
var rootViewController: RootViewController {
return window!.rootViewController as! RootViewController
}
}
Force-unwrap is totally reasonable here as long as you don’t change the RootViewController. If you do suddenly change it — crashing the app is probably the best way to go anyway.

Having that, we can easily get the reference to the current RootViewController from anywhere in the app:

let rootViewController = AppDelegate.shared.rootViewController

Let’s create a few more ViewControllers that we need in this project: SplashViewController, LoginViewController, and MainViewController.

Splash Screen will be the first screen that the user will see when the app is launched. This is the best time to do all the service API calls, check the user session, trigger pre-login analytics, etc. To indicate the activity on this screen, we will use a UIActivityIndicatorView:

class SplashViewController: UIViewController {
private let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .whiteLarge)
   override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.white
view.addSubview(activityIndicator)
activityIndicator.frame = view.bounds
activityIndicator.backgroundColor = UIColor(white: 0, alpha: 0.4)
      makeServiceCall()
}
   private func makeServiceCall() {

}
}

To simulate the API call, add a DispatchQueue.main.asyncAfter method with three seconds delay:

private func makeServiceCall() {
activityIndicator.startAnimating()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(3)) {
self.activityIndicator.stopAnimating()
}
}

We assume the service call will validate the user session. To mimic this in our test app, we will use a UserDefaults:

private func makeServiceCall() {
activityIndicator.startAnimating()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(3)) {
self.activityIndicator.stopAnimating()

if UserDefaults.standard.bool(forKey: “LOGGED_IN”) {
// navigate to protected page
} else {
// navigate to login screen
}
}
}
You definitely don’t want to use the UserDefaults in production to store the user session or any other sensitive information. We only use it to keep this project simple, as it’s not the topic of this article.

Create the LoginViewController. It will be used to authenticate the user if the current session is invalid. You can add a custom UI here, but I will only add a screen title and a login button in the navigation bar.

class LoginViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.white
title = "Login Screen"
      let loginButton = UIBarButtonItem(title: "Log In", style: .plain, target: self, action: #selector(login))
navigationItem.setLeftBarButton(loginButton, animated: true)
}
@objc
private func login() {
// store the user session (example only, not for the production)
UserDefaults.standard.set(true, forKey: "LOGGED_IN")
      // navigate to the Main Screen
}
}

Finally, create the MainViewController:

class MainViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.lightGray // to visually distinguish the protected part
title = “Main Screen”
let logoutButton = UIBarButtonItem(title: “Log Out”, style: .plain, target: self, action: #selector(logout))
navigationItem.setLeftBarButton(logoutButton, animated: true)
}
   @objc
private func logout() {
// clear the user session (example only, not for the production)
UserDefaults.standard.set(false, forKey: “LOGGED_IN”)
      // navigate to the Main Screen
}
}

Root Navigation

Return to the RootViewController.

As we discussed above, the RootViewController will be the only object responsible for the transition between the independent navigation stacks. To keep track of the current application state, we create a variable that will point to the current ViewController:

class RootViewController: UIViewController {
private var current: UIViewController
}

Add initializer to the class and create the first ViewController we want to load once the app launched. In our scenario, it will be the SplashViewController:

class RootViewController: UIViewController {
private var current: UIViewController
   init() {
self.current = SplashViewController()
super.init(nibName: nil, bundle: nil)
}
}

In viewDidLoad, add the current viewController to the RootViewController:

class RootViewController: UIViewController {
   ...
   override func viewDidLoad() {
super.viewDidLoad()

addChildViewController(current) // 1
current.view.frame = view.bounds // 2
view.addSubview(current.view) // 3
current.didMove(toParentViewController: self) // 4
}
}

Once we add the childViewController (1), we adjust its frame by calling current.view.frame to the view.bounds (2).

If we skip this line, the viewController will still be aligned properly in the most cases, but it can cause issues when the frame is changed. For example, when the in-call status bar is toggled, the top ViewController will not react to the new frame.

Add the new subview (3), and call didMove(toParentViewController:). This will finish adding the child view controller. Once we load the RootViewController, the SplashViewController will be displayed immediately.

Now we can add a few navigation methods. We will display the LoginViewController without any animations. MainViewController will use the fade-in animation, and the logout transition will use a slide-in navigation.

class RootViewController: UIViewController {
   ...
func showLoginScreen() {

let new = UINavigationController(rootViewController: LoginViewController()) // 1
      addChildViewController(new)                    // 2
new.view.frame = view.bounds // 3
view.addSubview(new.view) // 4
new.didMove(toParentViewController: self) // 5
      current.willMove(toParentViewController: nil)  // 6
current.view.removeFromSuperview()] // 7
current.removeFromParentViewController() // 8
      current = new                                  // 9
}
}

Create the LoginViewController (1), add it as a child view controller (2), and align its frame (3). Add its view as a subview (4) and call didMove (5). Next, prepare the current view controller for being removed by calling willMove (6). Finally, remove the current view from the superview (7), and remove the current view controller from the RootViewController (8). Don’t forget to update the current view controller (9).

Next, create a switchToMainScreen method:

func switchToMainScreen() {   
let mainViewController = MainViewController()
let new = UINavigationController(rootViewController: mainViewController)
...
}

To animate this transition, we need another method.

private func animateFadeTransition(to new: UIViewController, completion: (() -> Void)? = nil) {
current.willMove(toParentViewController: nil)
addChildViewController(new)

transition(from: current, to: new, duration: 0.3, options: [.transitionCrossDissolve, .curveEaseOut], animations: {
}) { completed in
self.current.removeFromParentViewController()
new.didMove(toParentViewController: self)
self.current = new
completion?() //1
}
}

This method is very similar to showLoginScreen, but all the last steps are performed after the animation is finished. To notify the caller of a successful transition, we call a completion handled at the end (1).

Now we can finish the switchToMainScreen method:

func switchToMainScreen() {   
let mainViewController = MainViewController()
let new = UINavigationController(rootViewController: mainViewController)
animateFadeTransition(to: mainScreen)
}

Finally, let’s create the last method, that will handle a navigation from MainViewController back to LoginViewController:

func switchToLogout() {
let loginViewController = LoginViewController()
let logoutScreen = UINavigationController(rootViewController: loginViewController)
animateDismissTransition(to: logoutScreen)
}

AnimateDismissTransition method will have a slide-out navigation:

private func animateDismissTransition(to new: UIViewController, completion: (() -> Void)? = nil) {
let initialFrame = CGRect(x: -view.bounds.width, y: 0, width: view.bounds.width, height: view.bounds.height)
current.willMove(toParentViewController: nil)
addChildViewController(new)
   transition(from: current, to: new, duration: 0.3, options: [], animations: {
new.view.frame = self.view.bounds
}) { completed in
self.current.removeFromParentViewController()
new.didMove(toParentViewController: self)
self.current = new
completion?()
}
}
These were just two examples of navigation transitions. Using the same approach, you can create any type of complex animations, that are best suitable for your app.

To complete the setup, call the transition methods from SplashViewController, LoginViewController, and MainViewControllers:

class SplashViewController: UIViewController {
...
   private func makeServiceCall() {
if UserDefaults.standard.bool(forKey: “LOGGED_IN”) {
// navigate to protected page
AppDelegate.shared.rootViewController.switchToMainScreen()
} else {
// navigate to login screen
AppDelegate.shared.rootViewController.switchToLogout()
}
}
}

class LoginViewController: UIViewController {
...

@objc
private func login() {
...
AppDelegate.shared.rootViewController.switchToMainScreen()
}
}

class MainViewController: UIViewController {
...
   @objc
private func logout() {
...
AppDelegate.shared.rootViewController.switchToLogout()
}
}

Build and run the app, and test it for two scenarios: when the user is logged in, and when the authentication is required. In both cases, you should see the transition to an appropriate screen once the SplashScreen loading is completed.

1. Root navigation

Fine-grained Navigation

Another use-case for this approach is handling Notifications (both remote and local), Shortcuts and Deeplinks. When you want to navigate straight to a specific page in your app, you can add the appropriate route method to the RootViewController.

If you want to follow along, you can refer to the article below and integrate the DeeplinkManager in our current app. I am only focusing on Shortcut part in this article, but Notifications and Deeplinks can be handled the same way

Here is the deeplink types we set in the Deeplink project:

enum DeeplinkType {
enum Messages {
case root
case details(id: String)
}
   case messages(Messages)
case activity
case newListing
case request(id: String)
}

We already parsed the Notifications, Shortcuts, and Deeplinks, and every time the app is launched or become active we have a DeeplinkType ready to be used for navigation. To handle this DeeplinkType we create a variable in RootViewController:

class RootViewController: UIViewController {
   ...
   var deeplink: DeeplinkType?
}

Now we can have two possible scenarios:

  1. The app is launched from the background. This means we don’t have to display the splash screen, authenticate the user, or make any other API calls before we can initiate the navigation.
  2. The app was not launched before. This means we have to first show the Splash Screen, check the validity of the user session, request authentication if needed (present login flow), make all necessary API calls, and only after all these steps we are actually ready to handle the deeplink.

The first scenario is quite simple: add a did-set property observer to the deeplink variable and run handleDeeplink method when the new value is set:

class RootViewController: UIViewController {
   ...
   var deeplink: DeeplinkType? {
didSet {
handleDeeplink()
}
}
   private func handleDeeplink() {
   }
}

In this example, all the Deeplinks can only start from the MainViewController. So the check is very straightforward. First, create a MainNavigationController, that is a subclass of UINavigationController:

class MainNavigationController: UINavigationController { }

We will use to distinguish the main navigation flow from other navigation controllers, that we may use in the app. Return to RootViewController and update handleDeeplink method:

class RootViewController: UIViewController {
...
private func handleDeeplink() {
// make sure we are on the correct screen
if let mainNavigationController = current as? MainNavigationController, let deeplink = deeplink {
// handle deeplink from here
      }
}
}

If we are currently on the MainNavigationController, this method will just skip the entire deeplink part. Let’s handle the Activity Shortcut:

class RootViewController: UIViewController {
...
private func handleDeeplink() {
if let mainViewController = current as? MainViewController, let deeplink = deeplink {
switch deeplink { // 1
case .activity: //2
mainNavigationController.popToRootViewController(animated: false) //3
(mainNavigationController.topViewController as? MainViewController)?.showActivityScreen() //4
default:
// handle any other types of Deeplinks here
break
}

self.deeplink = nil. //5
}
}
}

First, we check the current deeplink type (1), so we can act appropriately. If the deeplink is an activity deeplink (2), we want to dismiss all the view controllers, that may be pushed already (3), and push the activity view controller from the parent navigation controller (4).

This depends on the app structure. The general rule is not to break the regular navigation flow of the app. For example, if the user cannot navigate to activity screen from a messenger screen, we don’t want this to happen through the deeplink either.

Next, reset the current deeplink, so it will not be hadled more than once (5).

Don’t forget to add showActivityScreen method in MainViewController, and also create the ActivityViewController that will be presented with this method:

class ActivityViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.lightGray
title = “Activity”
}
}

Finally, modify the checkDeplink method in DeeplinkManager class:

func checkDeepLink() {
AppDelegate.shared.rootViewController.deeplink = deeplinkType

// reset deeplink after handling
self.deeplinkType = nil
}

Run your app and test the behavior. You should be able to open the ActivityViewController from the app shortcut.

2. Open deeplink from inactive state

But what is the app was not launched when you open the shortcut? Or even worse case, when the user needs to authenticate first?

The solution here is actually much simpler than it sounds. In both SplashViewController and LoginViewController we already have all the logic to check the user session, handle authentication and navigate to the MainViewController when everything is set.

We only need to add a few line of code at the end of showMainScreen method:

class RootViewController: UIViewController {
   ...
func showMainScreen() {
...
animateFadeTransition(to: mainScreen) { [weak self] in
self?.handleDeeplink()
}
}
}

How will this work? When the app is launched, we always set the parsed DeeplinkType (or nil, if no deeplink was used) to the RootViewController. So it will live there and wait until the app finishes all the required logic: animate the splash screen, make a service API call, check the user session, log in the user, etc. Once the MainScreen is presented, it will run the callback and trigger the existing deeplink. If the deeplink is not nil, it will be handled. If the deeplink is nil, the app will load the home screen.

To test this, kill the app and open it again with the ActivityShortcut. Once the Splash Screen animation is finished and LoginScreen is skipped, you will see the ActivityViewController with no extra steps required.

3. Open deeplink from unauthorized state
If something doesn’t work the expected way, or you want to follow the flow in the debugger, tap on edit scheme ->info -> check “wait for executable to be launched”.

Thank you for reading! Please tap 👏 if you like this article, so I will know, you are interested. If you have any questions, suggestions or observations, feel free to leave comments.

You can find the full code below.