Direkt - DRYNavigationManager made Swifty


Back in 2016, we published NavigationManager as an in-code alternative for Storyboard, where my colleague Jens Goeman introduced DRYNavigationManager, an in-code navigation framework for iOS. Since then the framework's been successfully used in multiple projects here at AppFoundry. Nowadays though, Swift is our language of choice when working on new iOS applications. Both Objective-C and Swift share many similarities, but their leading programming paradigms differ distinctly. That's why we'd like to introduce you to the DRYNavigationManager successor - Direkt, implementation of the navigation concept in Swift.

The concept

The goal of the framework was to define a consistent and clean way of handling navigation between application screens without using storyboards and segues, from within the code.

Requirements

Core requirements that we had in mind for the original framework stood strong when we approached Swift implementation:
  • Code separation - have a clear place in the code where navigation is handled
  • Maximum extendability - there's no silver bullet for handling navigation, as it highly depends on your application. Direkt isn't trying to cover all possible scenarios instead, it simply shows where that logic should take place

Reinventing the wheel

If the requirements are the same, one might ask why reinvent the wheel? Well, we've got a pretty good reason for that, meaning type safety. Objective-C, contrary to Swift, is a very dynamic language. In the original implementation raw strings were used to determine which class will be responsible for executing the navigation logic. Another thing is the parameters that we need to pass to the next view controller on our navigation stack. Using heterogenous dictionary doesn't fit very well into Swift paradigms and makes it impossible to leverage its type system for compile-time code validation. Therefore our goal for Swift implementation was to build upon the defined concept and leverage modern programming features to make our code safer and more predictable.

Implementation

Knowing the requirements and goal of the library, we can dive deep into the implementation details and see how solved the task. Direkt defines three core protocols Navigator, NavigationManager and a Resolver.

Resolver

The protocol is a minimal interface dependency injection. It is expected to be able to provide an instance of any type given some or none input. During navigation, it is used to create instances of navigators and target view controllers.
func resolve<T>(_ type: T.Type, input: Input?) throws -> T
The somewhat similar concept is used e.g. in Swinject - Swift DI framework. Due to lack of variadic generics in Swift, this approach is somewhat limited as we're able to pass only one input parameter. In most cases, it'd make sense to define a dedicated Input structure that encapsulates various input parameters.

Navigator

From library user perspective Navigator is the type that will be most often used. Basically, every view controller in the application that there's a need to navigate to, will have to have at least one Navigator type defined. Navigators will have to take care of the navigation logic and passing of the Input parameters. The protocol requires implementation of one method:
public protocol Navigator {

 associatedtype Input

 func navigate(using input: Input, from hostViewController: UIViewController, resolver: Resolver) throws
}
Associated type Input allows for a type safe definition of input parameters for given navigator. Some of the logic is common between multiple screens, for instance being able to dismiss the currently presented view controller. In order to implement this, it's sufficient to define a navigator whom Input is of type Void. Example of navigator that dismisses view controller:
class DismissingNavigator: Navigator {

 func navigate(using input: Void, from hostViewController: UIViewController, resolver: Resolver) {
 let presentingViewController = hostViewController.presentingViewController

 presentingViewController?.dismiss(animated: true)
 }
}

NavigationManager

As one might guess by its name, NavigationManager manages the process of navigation. Its role is to abstract the way given Navigator type is instantiated and how input parameters are passed further. One of the core requirements of the library being extendability, the manager object has to implement only one method:
func navigate<T: Navigator>(to navigator: T.Type, using: T.Input, from hostViewController: UIViewController)
Direkt provides a builtin base BasicNavigationManager type that provides one customisable point of handling failed navigation requests. This could, for instance, be extended to provide immediate success/failure feedback to the callee instead.

Extending NavigationManager

The method required to implement by the NavigationManager might seem somewhat limited at the first sight. In trivial cases, view controllers must know concrete type of a navigator that should be used, but this can be extended in various ways. For instance, if dedicated types were used to distinguish input of view controllers, it'd be possible to type erase navigators and perform navigation logic purely based on input parameters.
// 1. Type erased navigator, that expects given Input type
public struct AnyNavigator: Navigator {

 private let _navigate: (Input, UIViewController, Resolver) throws -> Void

 public init<NavigatorType: Navigator>(
 _ navigatorSource: @escaping @autoclosure () -> NavigatorType,
 input: Input
 ) where NavigatorType.Input == Input {
 self._navigate = {
 try navigatorSource()
 .navigate(using: $0, from: $1, resolver: $2)
 }
 }

 public func navigate(
 using input: Input, 
 from hostViewController: UIViewController, 
 resolver: Resolver
 ) throws {
 try self._navigate(input, hostViewController, resolver)
 }
}

// 2. Extend navigation manager to resolve navigator type based on a given input
public extension NavigationManager {

 func navigate(
 using input: Input, 
 from hostViewController: UIViewController
 ) {
 self.navigate(
 to: AnyNavigator.self, 
 using: input, 
 from: hostViewController
 )
 }
}
With navigation manager implementation that is able to resolve such dependencies, it'd be possible to only pass input on the callee site:
navigationManager?.navigate(using: HelloViewControllerInput(), from: self)
If you're already familiar with the DRYNavigationManager this little cheat sheet might help you understand how Direkt works.
DRYNavigationManager Direkt Summary
Navigator Navigator Protocol type that navigator objects conform to and execute the navigation logic
NavigationManager NavigationManager Single object that encapsulates navigators and abstracts the way they are created. BaseNavigationManager is a simple implementation that
NavigationDescriptor N/A Swifts type system and generics are used to resolve navigation parameters, there's no need for a dedicated object to handle them
NavigationTranslator Resolver Instead of using raw strings to determine the target of navigation, Swift types are used

Conclusion

We've managed to meet the core requirements posed to the DRYNavigationManager by applying similar techniques as those used in the Objective-C implementation. On top of that, we improved the safety of our code by leveraging Swift programming paradigms. Yet as shown, the implementation defining basic building blocks remains extendable, even for more complex scenarios like dynamic navigation.
DRYNavigationManager consists of 395 lines of code and 32 test cases, Direkts implementation, largely thanks to Swifts type system, fits just in 48 lines and needs around 7 tests to guarantee 100% code coverage.
Hopefully reading this article made it more clear how Direkt can be used and how it can potentially be extended. If you'd like to contribute or use the library in your own project go ahead and checkout Direkt GitHub repository.