Screen navigation in iOS app development

In this blog, we will explain you the different ways of presenting screens in the iOS app. We will initiate with most straightforward cases and finally go through various advanced states. In a nutshell we will be including more abstraction layers to compose our screens decoupled and navigation testable by unit tests. Here are some example codes are offered. Please note that the ways of doing navigation that we explained here are not essentially well-suited, and it may be a bad idea to use all of them at the same time.

A UIViewController to UIViewController presentation

The following are the important way of screen presentation which is optimistic by Apple:

We can execute this by doing one of the following:

1) Physically allocating a UIViewController for the next screen and granting it.

private func presentViewControllerManually() {
 let viewController = DetailsViewController(detailsText: "My 
 details") { [weak self] in
 self?.dismiss(animated: true)
 }
 self.present(viewController, animated: true)
}

2) Assigning of a UIViewController from a xib file or a storyboard.

private func presentViewControllerFromXib() {
 let viewController = DetailsViewControllerFromIB(nibName: 
 "DetailsViewControllerFromXib", bundle: nil)
 viewController.detailsText = "My details"
 viewController.didFinish = { [weak self] in
 self?.dismiss(animated: true)
 }
 self.present(viewController, animated: true)
 }
 private func presentViewControllerFromStoryboard() {
 let viewController = UIStoryboard(name: 
 "DetailsViewControllerFromIB", bundle: nil)
 .instantiateInitialViewController() as! 
 DetailsViewControllerFromIB
 viewController.detailsText = "My details"
 viewController.didFinish = { [weak self] in
 self?.dismiss(animated: true)
 }
 self.present(viewController, animated: true)
 }

3) Performing segues from Storyboards.

self.performSegue(withIdentifier: "detailsSegue", sender: nil)
...
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    guard segue.identifier == "detailsSegue" else { return }
    let viewController = segue.destination as! 
        DetailsViewControllerFromIB
    viewController.detailsText = "My details"
    viewController.didFinish = { [weak viewController] in
        viewController?.performSegue(withIdentifier: "unwind",
            sender: nil)
    }
}

The second and the third outlook power us to use two unpleasant practices:

1) By string identifiers, which rapidly results in a crush in the runtime if you modify them in IB or mistype them in the code?

We can work around this disadvantage by using third-party tools for compile-time safe identifiers.

2) Two-step initialization of the UIViewController: This describes that properties of the UIViewController have to be set to ‘var’ even though the existence of the UIViewController doesn’t make sense without those properties set.

The first approach, physical allocation, permits us to start the UIViewController with required dependencies and make those properties constant with the ‘let’ storage type.

This approach want us not to use the interface builder and build all UI elements in code, which takes additional time and results in more code to support, but if results in the most tough application.

Every approach mentioned above remains a valid way of doing navigation, but still, everyone have two negative consequences:

1) It is not possible to write unit tests for such presentations. certainly, forever we can use OCMock and swizzle initializers for the presented UIViewController, but this approach will not work for pure Swift classes, and so it is a terrible practice to accept.

2) Screens are coupled: the presenting UIViewController openly builds the presented UIViewController, so it identifies what screen it is. If at some point you desire to present another view controller you will be tempted to put an ‘if’ statement to make this decision thereby putting, even more responsibilities into the presenting UIViewController.

3) The presenting UIViewController recognizes what happens when the button is pressed — the new screen is accessible. Thus, there is no elegant way to modify what happens when you press the button if screen A is reuse elsewhere.

Testable and decoupled navigation

Dependency Injection and protocol-oriented programming come to our rescue and permit us to address the errors mentioned above.

Dependency injections will permit us to decouple the presenting and presented controller, and so they inject a details view controller using a factory closure:

let detailsViewControllerProvider = { detailsText, didFinish in
        return DetailsViewControllerToInject(detailsText: 
            detailsText, didFinish: didFinish)
    }
examplesViewController.nextViewControllerProvider = detailsViewControllerProvider

At this instant we can call a closure to obtain a UIViewController to present:

private func presentInjectedViewController() {
    let viewController: UIViewController = 
    self.nextViewControllerProvider("My details") { [weak self] in
        self?.dismiss(animated: true)
    }
    self.present(viewController, animated: true)
}

Here the only thing we know is that to display next screen, we have to pass details text and completion closure, and this will make some UIViewController to present.

With protocols to cover our completion details permits us to separate decisions from actions and test the code. Now  cover presentation functions by the protocol:

protocol ViewControllerPresenting {
    func present(_ viewControllerToPresent: UIViewController,   
        animated flag: Bool, completion: (() -> Swift.Void)?)
    func dismiss(animated flag: Bool, completion: (() -> 
        Swift.Void)?)
}

Tailor UIViewController to this protocol:

extension UIViewController: ViewControllerPresenting { }

Inject the UIViewController masked as a presenter like this:

let presenterProvider = { [unowned examplesViewController] in 
    return examplesViewController
}
examplesViewController.presenterProvider = presenterProvider 
// presenterProvider will return examplesViewController itself

And then write a test using a mock implementation:

let mockPresenter = MockViewControllerPresenter()
examplesViewController.presenterProvider = {
    return mockPresenter
}
// When select cell causing presentation
examplesViewController.tableView(examplesViewController.tableView, 
    didSelectRowAt: IndexPath(row: 1, section: 1))
// Then presented view controller is the injected VC
let vc = mockPresenter.invokedPresentArguments.0
XCTAssertTrue(vc is DetailsViewControllerToInject)

If you consider about it, we didn’t modify the way presentation works; the presenter is still the same UIViewController. The beauty of this solution lies in the UIViewController (former ‘self’) performing the presentation under the hood of a custom presenter. This permits you to inject a custom presenter if you desire to modify the way the view controller is accessible or in order to test this code.

Domain specific navigation

One more question is unanswered, how can we use ‘self.navigationViewController’ and test push presentations? In fact, Apple persuades hiding the details of presentation, and this is the reason -showViewController and -showDetailsViewController why it is recommended to use. So, I recommend you should either extract the methods above to protocol in the similar way we did for ‘presentViewController’ let’s try to execute the second approach.

State the type of presentation you need to support in a protocol:

protocol ViewControllerPresentingWithNavBar:   
             ViewControllerPresenting {
    func presentWithNavigationBar(_ controller: UIViewController,
            animated: Bool, completion: (() -> Void)?)
    func dismissRespectingNavigationBar(animated: Bool, 
            completion: (() -> Void)?)
}

Tailor the UIViewController to the protocol by building a navigation controller if essential:

public func presentNavigationBar(_ controller: UIViewController, animated: Bool, completion: (() -> Void)?) {
    if let navigationController = navigationController {
        navigationController.pushViewController(controller, 
             animated: animated, completion: completion)
    } else {
        let navigationController = 
            UINavigationController(rootViewController: controller)
        self.present(navigationController, animated: animated, 
            completion: completion)
        let button = UIBarButtonItem(barButtonSystemItem: .cancel, 
            target: self, action: #selector(userInitiatedDismiss))
        controller.navigationItem.leftBarButtonItem = button
    }
}

Inject a UIViewController as a presenter:

let presenterWithNavBarProvider = { [unowned examplesViewController] in
    return examplesViewController
}
examplesViewController.presenterWithNavBarProvider =   
    presenterWithNavBarProvider

Extracting decisions into ActionHandlers

Let’s fix the last error: we desire to hide the details of what is occurrence after the user taps the button by introducing an additional protocol:

protocol ActionHandling {
    func handleAction(for detailText: String)
}

We create an ActionHandler:

class ActionHandler: ActionHandling {
    private let presenterProvider: () -> ViewControllerPresenting
    private let detailsControllerProvider: (detailLabel: String, 
         @escaping () -> Void) -> UIViewController
    init(presenterProvider: @escaping () -> UIViewController, 
        detailsControllerProvider: @escaping (String, @escaping () 
        -> Void) -> UIViewController) {
        self.presenterProvider = presenterProvider
        self.detailsControllerProvider = detailsControllerProvider
    }

And move the presentation code there:

func handleAction(for detailText: String) {
        let viewController = detailsControllerProvider(detailText) { 
            [weak self] in
            self?.presenterProvider().dismiss(animated: true, 
                completion: nil)
        }
        presenterProvider().present(viewController, animated: true, 
            completion: nil)
    }
}

Thus, in the view controller we only have this:

private func handleAction() {
        self.actionHandler.handleAction(for: "My details")
    }

In a real app, you may desire your ViewModel to be the action handler. If you perform this, it means that the ViewModel will identify about a UIViewController. This is perhaps poor, as on the one hand it violates The Dependency Rule from the Clean Architecture, and on the other hand, we can think about the ViewModel as a Mediator between UI and Use Cases (business logic), so it should now be about involved parties.

Offered we don’t want to expose UIViewController to a ViewModel we can form a Screen Presenting protocol:

protocol ScreenPresenting {
    func presentScreen(for detailText: String, 
        didFinish: @escaping () -> Void)
    func dismissScreen()
}

And exploit it in the following technique from the ViewModel:

class MyViewModel: ActionHandling {
    let screenPresenter: ScreenPresenting
    init(screenPresenter: ScreenPresenting) {
        self.screenPresenter = screenPresenter
    }
    func handleAction(for detailText: String) {
        screenPresenter.presentScreen(for: detailText, didFinish: {  
             [weak self] in
            self?.screenPresenter.dismissScreen()
        })
    }
}

Basically there is not much difference between ScreenPresenting and ActionHandler, but we just included another layer of abstraction to evade injecting UIViewControllers into the ViewModel.

Module to module navigation

A potential option approach is to create the app using association between Flow Coordinators.

A Flow Coordinator is a frontage and entry point to a Module. A Module is a group of objects which may consist of a UIViewController, ViewModel/Presenter, Interactor and Services. A Module does not inevitably have a UI.

Classically, the root Flow Coordinator is maintained and initiated by the App Delegate:

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

A Flow Coordinator can hold and establish child Flow Coordinators:

func start() {  
    if self.isLoggedIn {
        self.startLandingCoordinator() 
    } else {  
        self.startLoginCoordinator()
    }  
}

Flow coordinators permit us to systematize collaboration between modules built with different levels of abstraction, for example Flow Coordinators for MVC and VIPER modules will have the same API. The significant warning about Flow Coordinators is that they will force you to sustain a hierarchy of Flow Coordinators parallel to the UI hierarchy. This may cause issues because UIViewControllers and ViewModel don’t keep strong references to Flow Coordinators, and you have to be very careful to ensure that modules do not run into an inconsistent state when a UIViewController is released, but the Flow Coordinator energetic it still exists.

Testable Flow Coordinators on two-part tutorial is available here

It is also feasible to accept Flow Coordinators slowly, and this means that your first Flow Coordinator is likely to be retaining by your UIViewController or a ViewModel/Presenter instead of UIAppDelegate. By this way, you can bring Flow Coordinators for new aspects, avoiding the require to refactor of the whole app.

Managing opening from a deep link or a push notification

These two problems ultimately boil down to an obligation of having a centralized navigation system. By a centralized system, I mean a thing which is conscious of the current stack of navigation and can function on it as a complete thing.

The following are the rules you have to follow when creating a centralised navigation system:

1) Presentations through the System should not break offering UIViewController based navigation

2) UIViewController based navigation should not be prohibited when the System is initiated.

The System can resolve the following two issues:

1) Presenting a screen related to a push notification or a deep link.

2) Handling of priorities Opening a stack of screens.

A naive version of executing this task is as following:

1) pop to the root view controller

2) Present some view controllers sequentially

final class Navigator: Navigating {
    func handlePush() {
        self.dismissAll { [weak self] in
            self?.presentTwoDetailsControllers()
        }
    }
...
}

PresentTwoDetailsControllers may look something like this:

private func presentTwoDetailsControllers() {
    let viewController = self.controllerForDetailsProvider(
        "My details") { [weak self] in
        self?.navigationController.dismissRespectingNavigationBar(
            animated: true, completion: nil)
    }
    self.navigationController.presentWithNavigationBar(
        viewController, animated: true, completion: { [weak self] in
guard let tSelf = self else { return }
      
        let viewController2 = tSelf.controllerForDetailsProvider(
            "My another details") { [weak viewController] in
            viewController?.dismissRespectingNavigationBar(
                animated: true, completion: nil)
        }
        viewController.presentWithNavigationBar(viewController2,
            animated: true, completion: nil)
    })
}

As you observe, this type of approach is not scalable because it needs manual handling of every case. One way of creating this scalable is to make a more complicated system based on graphs.

The execution could be as follows:

1) Construct two trees of UIViewControllers, actual and required.

2) Explode UIViewControllers until the actual hierarchy is a subset of the required hierarchy.

3) Shove the required view controllers until they equal the required hierarchy.

The following approach requires the capability to make and present screens separately of each other. So, if your screens are communicating not directly but via services, it is much simple to develop such a system.

Handling modal blockers

Often your app may require interaction to be blocked until a user enters a PIN or confirms some information.

The system should handle those types of screens in a particular way which matches your product expectations.

The naive solution could be just to disregard any requested hierarchy modifies if there is a blocking screen.

func handlePush() {
    guard self.hasNoBlockingViewController() else { return }
    self.dismissAll { [weak self] in
        self?.presentTwoDetailsControllers()
    }
}
private func hasNoBlockingViewController() -> Bool {
    
     
    return true
}

The more advanced approach is to relate a priority with a screen and treat screens with different priorities differently. The exact solution will be reliant on your domain, but could be as easy as not presenting screens with lower priorities unless there are screens with higher priority in the hierarchy.

Instead, you may want to display modal screens based on their priority: displaying a screen with top priority and keeping rest in a stack until the top one is dismissed.

I hope from this blog you clear about the screen presentation on iOS and issues you may want to solve in your app.

Last Update: September 28, 2018  

September 27, 2018   69   Nandini R    IOS Features    
Total 0 Votes:
0

Tell us how can we improve this post?

+ = Verify Human or Spambot ?

Leave a Reply

Your email address will not be published. Required fields are marked *

Facebook
Twitter
INSTAGRAM
LinkedIn