Deep look: iOS Navigation State | Swift

We build professional apps for Android, iOS and mobile web.
Have a look

Routing through View Controllers

When it comes to iOS development in Swift, routing through view controller's can be a bit of a mess, especially if you are implementing several different techniques for doing so. Xcode allows you to navigate through view controller's in many different ways, such as performing a segue, pushing onto a navigation stack, presenting modally, adding as a child, and the list goes on. Ultimately though, all of these methods employ one of two simple principles for adding a new view controller;

  1. Present it on top of the last presented view controller
  2. Add it as a child view controller. (Good tutorial on this here)
    (Small caveat: You also have a rootViewController you set on the keyWindow, or any other if you're daring)

That's it! All other methods of transitioning too or displaying a view controller are incarnations of these two methods.
A UINavigationController stores all of it's pushed view controllers as child view controllers.
A UITabBarViewController stores all of its tab root view controllers as child view controllers.
PopOver view controllers present themselves modally on the last presented view controller.

Extracting Navigation State

Although basic in theory, the combination of many different view controllers being presented or added as children, one after the other can get complex. It can also be useful at times, especially when debugging, to get a glimpse as to exactly what's going on. How many view controllers are allocated at the moment, and how are they all related to one another. With this in mind, I set out to create a small utility that would do just this.

Bellow is a stripped down version of the code used to scrape out this crucial information from a root view controller, written in Swift.

class ScreenState {

    let identifier: String
    let childScreenStates: [ScreenState]
    let presentedScreenState: ScreenState?
    let presentation: ScreenStatePresentation

    init(viewController: UIViewController, presentation: ScreenStatePresentation) {

        self.presentation = presentation
        self.identifier = String(describing: type(of: viewController))

        // add presented screen state
        if let presented = viewController.presentedViewController,
            presentation == .presented || presentation == .root {
            self.presentedScreenState = ScreenState(viewController: presented,
                                                    presentation: .presented)
        } else {
            self.presentedScreenState = nil
        }

        // add child screen states
        var childScreenStates: [ScreenState] = []
        for child in viewController.childViewControllers {
            let screenState = ScreenState(viewController: child,
                                          presentation: .child)
            childScreenStates.append(screenState)
        }
        self.childScreenStates = childScreenStates
    }
}

enum ScreenStatePresentation {  
    case root
    case presented
    case child
}

As can be seen, there's not a lot to it. We simply recursively create an instance of this data structure, holding just the key pieces of information regarding the view controller hierarchy. One of the great things about this is that it works no matter how you build your app, whether you're using storyboards, or doing all of your user interface manually. This class could even easily be expanded, to add things such as custom data in view controllers, logging capabilities and anything else that may be useful for debugging problems.

After adding a simple function to print out all of the details this data structure contains, this is the result.
Console Output Of Hierarchy

As can be seen, it's really quite simple
This information could be useful in many situations:
- You could use it when debugging, to easily see the hierarchy of view controllers allocated.
- You could modify and send it off via analytics, to get a clear idea about the route someone has taken to get to certain parts of the app.
- You could send it off with crash reports, to see if there's any clues in how the user navigated to the point where the crash occurred.

Even if the above reasons don't relate to your projects, I certainly encourage you to experiment, think and investigate more the ways in which UIKit handles view controllers behind the scenes, as it may help you when experimenting with new methods of routing and navigation.

Full Code

import Foundation  
import UIKit

// MARK:- ScreenState

class ScreenState {

    let identifier: String
    let childScreenStates: [ScreenState]
    let presentedScreenState: ScreenState?
    let presentation: ScreenStatePresentation

    // MARK:- Public Methods

    static func rootScreenState() -> ScreenState {
        guard let viewController = UIApplication.shared.keyWindow?.rootViewController else {
            fatalError("Root ViewController could not be retrieved")
        }
        return ScreenState(viewController: viewController, presentation: .root)
    }

    static func screenState(rootViewController: UIViewController) -> ScreenState {
        return ScreenState(viewController: rootViewController, presentation: .root)
    }

    // MARK:- Init Methods

    private init(viewController: UIViewController, presentation: ScreenStatePresentation) {

        self.presentation = presentation
        self.identifier = String(describing: type(of: viewController))

        // add presented screen state
        if let presentedViewController = viewController.presentedViewController, presentation == .presented || presentation == .root {
            self.presentedScreenState = ScreenState(viewController: presentedViewController, presentation: .presented)
        } else {
            self.presentedScreenState = nil
        }

        // add child screen states
        var childScreenStates: [ScreenState] = []
        for child in viewController.childViewControllers {
            let presentation = ScreenState.extractPresentation(child: child, in: viewController)
            let screenState = ScreenState(viewController: child, presentation: presentation)
            childScreenStates.append(screenState)
        }
        self.childScreenStates = childScreenStates
    }

    // MARK:- Helper Methods

    private static func extractPresentation(child: UIViewController, in parent: UIViewController) -> ScreenStatePresentation {

        // navigation
        if let navigation = parent as? UINavigationController, navigation.viewControllers.contains(child) {
            return .navigationChild
        }
            // tab bar
        else if let tabController = parent as? UITabBarController, let viewControllers = tabController.viewControllers, viewControllers.contains(child) {
            return .tabChild
        }
            // page
        else if let pageController = parent as? UIPageViewController, let viewControllers = pageController.viewControllers, viewControllers.contains(child) {
            return .pageChild
        }
            // page
        else if let splitController = parent as? UISplitViewController, splitController.viewControllers.contains(child) {
            return .splitChild
        }
        return .child
    }
}

// MARK:- Print Summary

extension ScreenState {

    fileprivate func layerDetails(spaceCount: Int) -> String {
        let spaces = String(repeating: " ", count: spaceCount)
        let children = childScreenStates.map({ "\n" + $0.layerDetails(spaceCount: spaceCount+4) }).joined(separator: "")
        let currentState = spaces + identifier + " (" + presentation.presentableName + ")"
        return (childScreenStates.isEmpty) ? currentState : currentState + ": {\(children)\n" + spaces + "}"
    }

    func printSummary() {
        print("\(layerDetails(spaceCount: 0))")
        if let presentedScreenState = presentedScreenState {
            print("\nv Presented v\n")
            presentedScreenState.printSummary()
        }
    }
}

// MARK:- ScreenStatePresentation

enum ScreenStatePresentation {

    case root
    case presented
    case navigationChild
    case tabChild
    case pageChild
    case splitChild
    case child

    var presentableName: String {
        switch self {
        case .root: return "Root"
        case .presented: return "Presented"
        case .navigationChild: return "Navigation Child"
        case .tabChild: return "Tab Child"
        case .pageChild: return "Page Child"
        case .splitChild: return "Split Child"
        case .child: return "Child"
        }
    }
}