There are hundreds of complex routing frameworks and libraries in iOS. Usually they’re overly complex to retrofit into an existing app or they completely bypass Storyboards. In this post, I’d like to offer a simple, native-like routing mechanism that leverages Storyboards like a boss to handle navigation.
The “Normal” Way
Let’s examine the “normal” way of handling navigation between view controllers. First, avoid segue’s at all costs since they lock you into a certain navigation flow that’s rigid and inflexible. Instead, we’ll create an instance of the target view controller and then use the show or present API’s of UIViewController
against it to handle the navigation.
To do this, let’s stick with a feature-based app structure and create one storyboard-per-view-controller. Here’s what our sample app would look like:
Once we add the view controller onto the storyboard via Interface Builder, assign the class to it, and checkmark “Is Initial View Controller”, we can create an instance of the view controller by first getting a reference to the storyboard and calling the instantiateInitialViewController API from it:
let storyboard = UIStoryboard(name: "Login", bundle: nil) guard let controller = storyboard.instantiateInitialViewController() else { fatalError("Invalid controller for storyboard.") } show(controller, sender: nil)
Since we have to route the user several times within the app life cycle, the above code can get verbose and it isn’t compile-safe either.
The Routable Protocol Way
In the WWDC 2015 talk called “Swift in Practice“, Apple engineers outlined how to make segue identifiers strongly-typed by creating a protocol with an associated RawRepresentable
type that others must conform to:
protocol SegueHandlerType { associatedtype SegueIdentifier: RawRepresentable }
We’re throwing segues out the window, but we can still use this clever implementation to handle the storyboard routing:
protocol Routable { associatedtype StoryboardIdentifier: RawRepresentable }
Let’s move our original “normal” routing code above to a protocol extension to abstract it away:
extension Routable where Self: UIViewController, StoryboardIdentifier.RawValue == String { func show(storyboard: StoryboardIdentifier) { let storyboard = UIStoryboard(name: storyboard.rawValue, bundle: nil) guard let controller = storyboard.instantiateInitialViewController()) else { return assertionFailure("Invalid controller for storyboard \(storyboard).") } show(controller, sender: self) } }
Now we can make our view controller conform to the Routable
protocol and provide its enum of storyboards, then feed the enum case to the show
API:
class LoginViewController: UIViewController { @IBAction func loginTapped() { show(storyboard: .profile) } } extension LoginViewController: Routable { enum StoryboardIdentifier: String { case profile = "Profile" case more = "More" } }
You can use `show(storyboard: .profile)` a dozen of times and is compile-safe plus sleek.
Routable Micro-Library
Let’s add sugar and spice to make this more reusable and flexible:
public protocol Routable { associatedtype StoryboardIdentifier: RawRepresentable func present<T: UIViewController>(storyboard: StoryboardIdentifier, identifier: String?, animated: Bool, modalPresentationStyle: UIModalPresentationStyle?, configure: ((T) -> Void)?, completion: ((T) -> Void)?) func show<T: UIViewController>(storyboard: StoryboardIdentifier, identifier: String?, configure: ((T) -> Void)?) func showDetailViewController<T: UIViewController>(storyboard: StoryboardIdentifier, identifier: String?, configure: ((T) -> Void)?) } public extension Routable where Self: UIViewController, StoryboardIdentifier.RawValue == String { /** Presents the intial view controller of the specified storyboard modally. - parameter storyboard: Storyboard name. - parameter identifier: View controller name. - parameter configure: Configure the view controller before it is loaded. - parameter completion: Completion the view controller after it is loaded. */ func present<T: UIViewController>(storyboard: StoryboardIdentifier, identifier: String? = nil, animated: Bool = true, modalPresentationStyle: UIModalPresentationStyle? = nil, configure: ((T) -> Void)? = nil, completion: ((T) -> Void)? = nil) { let storyboard = UIStoryboard(name: storyboard.rawValue) guard let controller = (identifier != nil ? storyboard.instantiateViewController(withIdentifier: identifier!) : storyboard.instantiateInitialViewController()) as? T else { return assertionFailure("Invalid controller for storyboard \(storyboard).") } if let modalPresentationStyle = modalPresentationStyle { controller.modalPresentationStyle = modalPresentationStyle } configure?(controller) present(controller, animated: animated) { completion?(controller) } } /** Present the intial view controller of the specified storyboard in the primary context. Set the initial view controller in the target storyboard or specify the identifier. - parameter storyboard: Storyboard name. - parameter identifier: View controller name. - parameter configure: Configure the view controller before it is loaded. */ func show<T: UIViewController>(storyboard: StoryboardIdentifier, identifier: String? = nil, configure: ((T) -> Void)? = nil) { let storyboard = UIStoryboard(name: storyboard.rawValue) guard let controller = (identifier != nil ? storyboard.instantiateViewController(withIdentifier: identifier!) : storyboard.instantiateInitialViewController()) as? T else { return assertionFailure("Invalid controller for storyboard \(storyboard).") } configure?(controller) show(controller, sender: self) } /** Present the intial view controller of the specified storyboard in the secondary (or detail)€ context. Set the initial view controller in the target storyboard or specify the identifier. - parameter storyboard: Storyboard name. - parameter identifier: View controller name. - parameter configure: Configure the view controller before it is loaded. */ func showDetailViewController<T: UIViewController>(storyboard: StoryboardIdentifier, identifier: String? = nil, configure: ((T) -> Void)? = nil) { let storyboard = UIStoryboard(name: storyboard.rawValue) guard let controller = (identifier != nil ? storyboard.instantiateViewController(withIdentifier: identifier!) : storyboard.instantiateInitialViewController()) as? T else { return assertionFailure("Invalid controller for storyboard \(storyboard).") } configure?(controller) showDetailViewController(controller, sender: self) } } public extension UIStoryboard { /** Creates and returns a storyboard object for the specified storyboard resource file in the main bundle of the current application. - parameter name: The name of the storyboard resource file without the filename extension. - returns: A storyboard object for the specified file. If no storyboard resource file matching name exists, an exception is thrown. */ convenience init(name: String) { self.init(name: name, bundle: nil) } }
Notice I’ve added show
and present
API’s and a trailing closure to configure the controller before and after its loaded so I can use it like this:
class ProfileViewController: UIViewController { @IBAction func moreTapped() { show(storyboard: .more) { (controller: MoreViewController) in controller.someProperty = "\(Date())" } } } extension ProfileViewController: Routable { enum StoryboardIdentifier: String { case more = "More" case login = "Login" } }
I pushed this into another library so it will be maintained there going forward. For a complete sample app, you can download a working demo here.
Happy Coding!!
How would you handle the case when the viewController that I’d like to navigate to has a NavigationController as InitialViewController?
Good question! This is what I do:
show(storyboard: .showMyScene) {
guard let controller = ($0 as? UINavigationController)?
.topViewController as? MyViewController
else { return }
controller.productID = 123
}
Or you can set a storyboard identifier in the IB for that view controller and use `show(storyboard: .showMyScene, identifier: “MyViewController”)`.
Did you hear about using .xib instead of a storyboard? It’s much more simple and created directly for this.
Then, you can call YourViewController(nibName: “name”, bundle: nil). And if the filename of your .xib file is same as of XourViewController class, you can nibName: nil and it;s working automatically like a charm.
You can even create your own constructor:
init(data: Data) {
self.data = data
super.init(nibName: nil, bundle: nil)
}
Then you are using ViewControllers like any other objects:
let vc = YourViewController(data: someData)
vc.someOtherSetup()
push(vc)
Hi Jakub :), I leverage Interface Builder much more comprehensively so I use actual storyboards instead of views. This way, I have more power in IB to create the scene, instead of doing this programmatically in the view controller which tends to get crowded. I certainly see the benefits of your approach and seems to boil down to a code vs IB preference.
No, I mean (and I’m using) simple .xib file instead of the whole storyboard. Still using interface builder, but init of VC is more simple 🙂
The manually created view in the comment for the first post was just “extreme” example 🙂
Ah I see now, that sounds interesting, especially for certain screens where the whole storyboard isn’t needed. Though I’d just be missing some benefits of storyboard like richer auto layout capabilities and allowing fuller scenes (nav, dynamic tables, etc), which is true isn’t needed for all kinds of screens. Thanks for sharing, will have to give that a try 👍
Just as a comment, this link –> “https://github.com/ZamzamInc/ZamzamKit/blob/master/Sources/Protocols/Routable.swift” should be updated to “https://github.com/ZamzamInc/ZamzamKit/blob/master/Sources/Protocols/iOS/Routable.swift”
Thank you, link updated!