Navigator
public class Navigator
Main class that is handling the navigation in a Tempura app.
Overview
When it comes to creating a complex app, the way you handle the navigation between different screens is an important factor on the final result.
We believe that relying on the native iOS navigation system, even in a Redux-like environment like Katana, is the right choice for our stack, because:
there is no navigation code to write and maintain just to mimic the way native navigation works
native navigation gestures will come for free and will stay up to date with new iOS releases
the app will feel more “native”
For these reasons we found a way to reconcile the redux-like world of Katana with the imperative world of the iOS navigation.
The Routable protocol
If a Screen (read ViewController) takes an active part on the navigation
(i.e. needs to present another screen) it must conform to the RoutableWithConfiguration
protocol.
protocol RoutableWithConfiguration: Routable {
var routeIdentifier: RouteElementIdentifier { get }
var navigationConfiguration: [NavigationRequest: NavigationInstruction] { get }
}
typealias RouteElementIdentifier = String
Each RoutableWithConfiguration
can be asked by the Navigator navigation to
perform a specific navigation task (like present another ViewController)
based on the navigation action (Show
, Hide
) you dispatch.
The route
A Route
is an array that represents a navigation path to a specific screen.
typealias Route = [RouteElementIdentifier]
How the navigation works
Suppose we have a current Route
like “screenA”, “screenB”
the topmost ViewController/RoutableWithConfiguration in the visible hierarchy.
Tempura will expose two main navigation actions:
Show
Show("screenC", animated: true)
When this action is dispatched, the Navigator
will ask “screenB” (the topmost
RoutableWithConfiguration
to handle that action, looking at its
navigationConfiguration
).
In order to allow “screenB” to present “screenC”, we need to add a NavigationRequest
inside the navigationConfiguration
of “screenB” that will match the Show("screenC")
action
with a .show("screenC")
NavigationRequest
.
extension ScreenB: RoutableWithConfiguration {
// needed by the `Routable` protocol
// to identify this ViewController in the hierarchy
var routeIdentifier: RouteElementIdentifier {
return "screenB"
}
// the `NavigationRequest`s that this ViewController is handling
// with the `NavigationInstruction` to execute
var navigationConfiguration: [NavigationRequest: NavigationInstruction] {
return [
.show("screenC"): .presentModally({ [unowned self] _ in
let vc = ScreenC(store: self.store)
return vc
})
]
}
Inside the .presentModally
NavigationInstruction, we create and return the ViewController
that is implementing the “screenC”.
This will be used by the Navigator
to call the appropriate UIKit navigation action (
a UIViewController.present(:animated:completion:)
in this case).
If the Navigator will not find a matching NavigationRequest in the navigationConfiguration of “screenB”, it will ask to the next RoutableWithConfiguration in the visible hierarchy, in this case “screenA”. If nobody is matching that NavigationRequest, a fatalError is thrown.
Hide
Same will happen when a Hide
function is dispatched.
Hide("screenC", animated: true)
Looking at the current Route
[“screenA”, “screenB”, “screenC”], the Navigator will ask the
topmost RoutableWithConfiguration (“screenC” in this case) to match for a
.hide("screenC")
NavigationRequest
.
Again, if we want “screenC” to be responsible for dismissing itself, we just need to implement
the matching NavigationRequest
in the navigationConfiguration
:
extension ScreenC: RoutableWithConfiguration {
// needed by the `Routable` protocol
// to identify this ViewController in the hierarchy
var routeIdentifier: RouteElementIdentifier {
return "screenC"
}
// the `NavigationRequest`s that this ViewController is handling
// with the `NavigationInstruction` to execute
var navigationConfiguration: [NavigationRequest: NavigationInstruction] {
return [
.hide("screenC"): .dismissModally(behaviour: .hard)
]
}
If the Navigator will not find a matching NavigationRequest in the navigationConfiguration of “screenC”, it will ask to the next RoutableWithConfiguration in the visible hierarchy, in this case “screenB”. If nobody is matching that NavigationRequest, a fatalError is thrown.
Initializing the navigation
In order to use the navigation system you need to start the Navigator
(typically in your AppDelegate) using the start(using:in:at:)
method.
In this method you need to specify a RootInstaller
and a starting screen.
The RootInstaller (typically your AppDelegate) will be responsible to handle the installation
of the screen you specified before.
Doing so, the Navigator will call the RooInstaller.installRoot(identifier:context:completion:)
method that will handle the setup of the screen to be shown.
class AppDelegate: UIResponder, UIApplicationDelegate, RootInstaller {
func application(_ application: UIApplication, didFinishLaunchingWithOptions ...) -> Bool {
...
// setup the root of the navigation
// this is done by invoking this method (and not in the init of the navigator)
// because the navigator is instantiated by the Store.
// this in turn will invoke the `installRootMethod` of the rootInstaller (self)
navigator.start(using: self, in: self.window, at: "screenA")
return true
}
// install the root of the app
// this method is called by the navigator when needed
// you must call the `completion` callback when the navigation has been completed
func installRoot(identifier: RouteElementIdentifier, context: Any?, completion: () -> ()) -> Bool {
let vc = ScreenAViewController(store: self.store)
self.window.rootViewController = vc
completion()
return true
}
}
-
Completion closure typealias, needed by the navigator to know when a navigation has been handled.
Declaration
Swift
public typealias Completion = () -> Void
-
Initializes and return a Navigator.
Declaration
Swift
public init()
-
Start the navigator.
In order to use the navigation system, you need to start the navigator specifying a
RootInstaller
and the first screen you want to install.class AppDelegate: UIResponder, UIApplicationDelegate, RootInstaller { func application(_ application: UIApplication, didFinishLaunchingWithOptions ...) -> Bool { ... // setup the root of the navigation // this is done by invoking this method (and not in the init of the navigator) // because the navigator is instantiated by the Store. // this in turn will invoke the `installRootMethod` of the rootInstaller (self) navigator.start(using: self, in self.window, at: "screenA") return true } // install the root of the app // this method is called by the navigator when needed // you must call the `completion` callback when the navigation has been completed func installRoot(identifier: RouteElementIdentifier, context: Any?, completion: () -> ()) -> Bool { let vc = ScreenAViewController(store: self.store) self.window.rootViewController = vc completion() return true } }
Declaration
Swift
public func start( using rootInstaller: RootInstaller, in window: UIWindow, at rootElementIdentifier: RouteElementIdentifier )
-
Generic version of the same method.
Declaration
Swift
public func start<K: RawRepresentable>( using rootInstaller: RootInstaller, in window: UIWindow, at rootElementIdentifier: K ) where K.RawValue == RouteElementIdentifier