DEV Community

Cover image for Create, Push, and Present Any View Controller in 1 LOC using Metaprogramming
Ivan Goremykin
Ivan Goremykin

Posted on

Create, Push, and Present Any View Controller in 1 LOC using Metaprogramming

TL;DR

We make view controller instantiation more type-safe by eliminating string IDs and use metaprogramming to generate functions that let you create, push, and present any view controller in 1 LOC, so you can save ~15 LOC whenever you want to perform push or present. It works both with storyboard-based view controllers and code-only ones. The generated code is autocomplete-friendly. Here is the full source code example.

Foreword

Although SwiftUI is gaining popularity, the traditional UIKit workflow will remain with us for some time. I'd like to share our approach to managing boilerplate code for UIViewController instantiation and navigation.

Motivation

Storyboards are great when you need to design the appearance of your screen. They are not so great when it comes to writing code required for view controller instantiation or setting up transitions via segue mechanism, especially when you need to

  • change the existing screen sequence,
  • implement dynamic screen sequence,
  • instantiate an array of view controllers.

Here is the vanilla approach to dealing with view controller instantiation, setting up its parameters, and pushing onto the navigation stack:

func preCreateMyViewController()
{
    // Create using a string identifier "MyStoryboard"
    let storyboard = UIStoryboard(name: "MyStoryboard", bundle: nil)

    // Create using a string identifier "MyViewController"
    self.myViewController = storyboard.instantiateViewController(withIdentifier: "MyViewController") as! MyViewController

    // Set parameters known at instantiation time
    self.myViewController.param0 = "I may be paranoid, but not an android."
    self.myViewController.param1 = 42
}

func pushMyViewController()
{
    // Push pre-created view controller onto navigation stack
    navigationController?.pushViewController(self.myViewController, animated: true)
}

override func prepare(for segue: UIStoryboardSegue, sender: Any?)
{
    if let myViewController = segue.destination as? MyViewController
    {
        // Set parameters known at prepare-for-segue time
        myViewController.param2 = .white
    }
}
Enter fullscreen mode Exit fullscreen mode

There are several problems with this approach.

Strings as identifiers

Since both storyboard and view controller are accessed by string IDs ("MyStoryboard" and "MyViewController"), you can simply misspell them and get no compile-time error at all. For example, you could change the storyboard’s file name and forget to change corresponding string IDs in every call of UIStoryboard(name: "MyStoryboard", bundle: nil), and no compile-time error will occur.

Non-atomic instantiation

By non-atomic, I mean that a view controller is not instantiated by a single function call. In order to have a fully functional view controller you’ll have to do these steps:

  1. Instantiate view controller from storyboard,
  2. Setup parameters that are known at instantiation time,
  3. Setup parameters that are known at prepare-for-segue time.

Hence the full setup process is spread across several files, that leads us to a plethora of problems:

  • you might forget to do any of these steps and still get no compilation error,
  • you might copy and paste only 2 of the 3 required steps and still get no compilation error,
  • making any changes (e.g., adding to the view controller a new parameter known at prepare-for-segue time) has to be done in every prepare(for:sender:) implementation, and if you forget — you know what? You don’t get a compilation error! All these situations will silently leave you with a half-initialized view controller, which is a splendid thing to discover at runtime.

Lots of boilerplate and code duplication

It costs us ~15 LOC for view controller instantiation, preparing for a segue, and pushing a view controller onto the navigation stack. It’s just annoying to repeat those 15 lines in any other place that leads to your view controller. It should be only 1 LOC.

Solution

Over the years we have shifted to the “design in a storyboard, compose in code” approach. We do not use segues for transitioning between screens, hence we do not pass parameters between screens in prepare(for:sender:). Instead, we do our design in a storyboard and instantiate, compose, and route the screens in code.

Another important aspect of our programming approach is to move as much of runtime errors to compile-time as possible, so we could get closer to the holy grail of the “if no errors at build time, then no errors at runtime” paradigm.

There are many possible types of errors that might occur at runtime. There are not so many mechanics to detect and prevent those runtime errors at the build time. There is one tool at our disposal that flawlessly works at the build time — the type system. Hence we try to express our runtime problems as compile-time type problems in order to prevent runtime errors by getting compile-time errors. Let the compiler help you.

We will modify our vanilla example in 5 easy steps:

  1. Eliminate String Identifiers
  2. Make Instantiation Atomic
  3. Create 1 LOC Navigation
  4. Eliminate Boilerplate with Metaprogramming
  5. Add Resource Consistency Tests using Metaprogramming

Eliminate String Identifiers

Let’s get rid of those string identifiers. Instead of

let storyboard = UIStoryboard(name: "MyStoryboard", bundle: nil)
let myViewController = storyboard.instantiateViewController(withIdentifier: "MyViewController") as! MyViewController
Enter fullscreen mode Exit fullscreen mode

… we will write

let myViewController = UIViewController.instantiate(.myStoryboard, MyViewController.self)
Enter fullscreen mode Exit fullscreen mode

We assume that view controller’s Storyboard ID is equal to its type name.
UIViewController.instantiate is a utility function. It creates a view controller and casts it to the specified type, hence the client doesn’t have to write cast herself.

extension UIViewController
{
    static func instantiate<ViewController: UIViewController>(_ storyboardName: UIStoryboard.Name,
                                                              _ viewControllerType: ViewController.Type) -> ViewController
    {
        let storyboard = UIStoryboard(name: storyboardName.rawValue, bundle: nil)
        return storyboard.instantiateViewController(withIdentifier: String(describing: ViewController.self)) as! ViewController
    }
}
Enter fullscreen mode Exit fullscreen mode

UIStoryboard.Name is a project-specific enum:

extension UIStoryboard
{
    enum Name: String
    {
        case main
        case myStoryboard
        case yourStoryboard
    }
}
Enter fullscreen mode Exit fullscreen mode

If you misspell the storyboard name when calling UIViewController.instantiate, then it just won’t compile. If you type the wrong view controller type name, then it just won’t compile. Type system to the rescue!

This approach is not persistent to changes of a storyboard file name or changes of a view controller type name. In order to guarantee correct naming at runtime, we generate resource consistency tests. We will cover this later in the corresponding section.

Make Instantiation Atomic

Since we want our view controllers to be instantiated by a single function call we completely drop segues, and instead of this:

let myViewController = UIViewController.instantiate(.myStoryboard, MyViewController.self)

// Set parameters known at instantiation time
self.myViewController.param0 = "I may be paranoid, but not an android."
self.myViewController.param1 = 42

override func prepare(for segue: UIStoryboardSegue, sender: Any?)
{
    if let myViewController = segue.destination as? MyViewController
    {
        // Set parameters known at prepare-for-segue time
        myViewController.param2 = .white
    }
}
Enter fullscreen mode Exit fullscreen mode

… we write

let myViewController = createMyViewController("I may be paranoid, but not an android.", 42, .white)
Enter fullscreen mode Exit fullscreen mode

For each view controller, there is a createMyViewController function that takes all parameters required for the view controller to be 100% initialized and operational.

We oblige all our view controllers to implement an initialize function that takes all parameters required for this view controller:

class MyViewController: UIViewController
{
    // MARK:- Parameters
    var param0: String!
    var param1: Int!
    var param2: UIColor!

    // MARK:- Initialization
    func initialize(param0: String, param1: Int, param2: UIColor)
    {
        self.param0 = param0
        self.param1 = param1
        self.param2 = param2
    }
}
Enter fullscreen mode Exit fullscreen mode

Hence the createMyViewController function will look like this:

func createMyViewController(_ param0: String, _ param1: Int, _ param2: UIColor) -> MyViewController
{
    let myViewController = UIViewController.instantiate(.myStoryboard, MyViewController.self)

    myViewController.initialize(param0: param0, param1: param1, param2: param2)

    return myViewController
}
Enter fullscreen mode Exit fullscreen mode

The initialize should only be called once and only by a create function.

The initialize is written by hand, but createMyViewController is generated by a metaprogramming template. We will discuss code generation later.

Now we have an atomic instantiation of our view controller provided by createMyViewController functions. These functions are safer for copy-pasting since you can’t copy half of a function call and deal with a half-initialized view controller as a result.

If you forget to pass some parameters to createMyViewController function, then the compiler will be happy to let you know. Once again, type system to the rescue!

Create 1 LOC Navigation

Now that we have createMyViewController, we want to push or present our view controller. In many cases we create a view controller only to pass it to the navigation controller:

let myViewController = createMyViewController("I may be paranoid, but not an android.", 42, .white)
navigationController.pushViewController(myViewController, animated: animated)
Enter fullscreen mode Exit fullscreen mode

It would be much nicer if we could just write:

navigationController.pushMyViewController("I may be paranoid, but not an android.", 42, .white, animated)
Enter fullscreen mode Exit fullscreen mode

For each view controller, we have pushMyViewController and presentMyViewController, which are generated by a metaprogramming template that will be discussed later in the corresponding section.

Don’t Pollute the Global Namespace

If you work on a moderate-size project, then you are probably going to have 50+ screens. That means that you will have 50+ create, 50+ push, and 50+ present functions generated for you. Since we don’t want to pollute the global namespace with these functions, we need a place to store all these functions. Where do we put them?

A hint might be found in the essay Learnable Programming by Bret Victor, where he writes:

Strangely, I don’t actually know of any APIs that are intentionally designed with autocomplete in mind.

We are going to fix that. This is how our create functions will look like:

UIViewController.create.myViewController(param0, param1, param2)
UIViewController.create.yourViewController(param0, param1)
Enter fullscreen mode Exit fullscreen mode

The implementation is trivial:

extension UIViewController
{
    static let create = UIViewControllerCreate()

    class UIViewControllerCreate
    {
        func myViewController(param0, param1, param2) -> MyViewController {  }
        func yourViewController(param0, param1) -> YourViewController {  }
    }
}
Enter fullscreen mode Exit fullscreen mode

Same for navigation, we will write our push and present functions just like this:

navigationController.push.myViewController(param0, param1, param2, animated)
yourViewController.present.myViewController(param0, param1, param2, animated)
Enter fullscreen mode Exit fullscreen mode

This implementation is a bit different since we want our functions to be called on an instance of UINavigationController (in case of push) and on an instance of UIViewController (in case of present):

extension UINavigationController
{
    var push: UINavigationControllerPush
    {
         return UIViewControllerFactory(self)
    }

    class UINavigationControllerPush
    {
        weak var navigationController: UINavigationController?

        init(_ navigationController: UINavigationController)
        {
            self.navigationController = navigationController
        }

        func myViewController(_ param0: String, _ param1: Int, _ param2: UIColor, _ animated: Bool) {  }
        func yourViewController(_ param0: String, _ param1: Double, _ animated: Bool) {  }
    }
}
Enter fullscreen mode Exit fullscreen mode

We have just added a single extension property to UINavigationController class and 2 extension properties to UIViewController. Each of those extension properties has 50+ functions.

Eliminate Boilerplate with Metaprogramming

Now that we have designed our APIs we can generate the code. For each view controller in our project, we are going to generate 3 functions: create, push, and present. We are going to generate code with Sourcery — a tool developed by Krzysztof Zabłocki.

Despite the recent announcement of Swift Macros, the development workflow built on Sourcery has proven to be efficient, and we've only just begun discussions about transitioning to Swift Macros.

Sourcery operates as follows: you provide a template file, Sourcery parses your source code, and then generates code based on your template and the parsed source code. Sourcery can be used as a standalone executable or embedded right into the Xcode building process as a Run Script phase. It automatically regenerates code on any changes in your template file or in the project source files.

We write our templates in Swift. Sourcery’s API is way richer than Swift’s native reflection. You can iterate over types presented in your project, filter them, and access all their functions, variable names, template arguments and so on. This is how you iterate over all view controllers that have an initialize method:

types.classes
    .filter { $0.inheritedTypes.contains("UIViewController") && $0.hasMethod(named: "initialize") }
    .map { $0.name }
    .sorted(by: <)
    .forEach { print($0) }
Enter fullscreen mode Exit fullscreen mode

The functions to be generated — create, push, and present, — have almost the same signature as initialize, the only difference is the animated parameter in the latter two:

// Handwritten
myViewController.initialize(param0, param1, param2)

// Generated
UIViewController.create.myViewController(param0, param1, param2)
navigationController.push.myViewController(param0, param1, param2, animated)
yourViewController.present.myViewController(param0, param1, param2, animated)
Enter fullscreen mode Exit fullscreen mode

In our Sourcery template, we iterate over all view controllers, look at their initialize methods, and replicate the same signature in create, push, and present and add an animated parameter at the end of push and present. If a view controller doesn’t need parameters to be set and hence doesn’t have an initialize method, then its create function will not have parameters, and push and present will get only the animated parameter.

There is one more thing to be considered regarding the create function. We need to know UIStoryboard.Name for those view controllers that are instantiated from a storyboard. We make all such view controllers conform to StoryboardInstantiatable protocol:

protocol StoryboardInstantiatable: UIViewController
{
    static var storyboardName: UIStoryboard.Name { get }
}
Enter fullscreen mode Exit fullscreen mode

We could use Sourcery annotations to specify the UIStoryboard.Name, but it wouldn’t work with the Xcode refactoring tool when we need to rename those UIStoryboard.Name cases.

This is how MyViewController conforms to StoryboardInstantiatable protocol:

extension MyViewController: StoryboardInstantiatable
{
    static var storyboardName: UIStoryboard.Name { return .myStoryboard }
}
Enter fullscreen mode Exit fullscreen mode

It is extremely valuable when you want to add a new parameter to the initialize function and those changes are instantly reflected in create, push, and present, since Sourcery automatically regenerates them after every change of initialize. No need to manually forward a new parameter to 3 functions!

Add Resource Consistency Tests using Metaprogramming

Now that we have dropped string identifiers, the instantiation of a storyboard-based view controller looks like this:

let myViewController = UIViewController.instantiate(.myStoryboard, MyViewController.self)
Enter fullscreen mode Exit fullscreen mode

If a storyboard file name or a view controller type name is changed, then the compile-time error will not occur. If we can’t catch errors at compile-time, then we catch them at test time. Once again Sourcery comes to the rescue. In order to guarantee the correct naming of all related resources (storyboard file names, view controllers’ storyboard IDs), we generate resource consistency tests.

There are 2 groups of these tests: storyboard tests and view controller tests.

Storyboard tests are intended to check whether every case from UIStoryboard.Name enum successfully loads a storyboard by its rawValue. Here is an example of such a test for one case:

class StoryboardNameTests: XCTestCase
{
    func testStoryboardA()
    {
        XCTAssertNotNil(getPath(for: .storyboardA))
    }

    private func getPath(for storyboardName: UIStoryboard.Name) -> String?
    {
        return Bundle.main.path(forResource: storyboardName.fileName, ofType: "storyboardc")
    }
}
Enter fullscreen mode Exit fullscreen mode

View controller tests are intended to check whether every view controller that conforms to StoryboardInstantiatable protocol can be instantiated from its storyboard (their type names must equal their Storyboard IDs). Here is an example of such a test:

class InstantiateUIViewControllerTests: XCTestCase
{
    func testMyViewController()
    {
        XCTAssertTrue(canBeInstantiated(MyViewController.self))
    }

    private func canBeInstantiated<ViewController: StoryboardInstantiatable>(_ viewControllerType: ViewController.Type) -> Bool
    {
        return UIViewController.instantiate(storyBoardName:   ViewController.storyboardName.fileName,
                                            viewControllerId: String(describing: ViewController.self)) is ViewController
    }
}
Enter fullscreen mode Exit fullscreen mode

It is important to say that there is an elegant solution to the resource consistency problem that we haven’t adapted yet. Instead of testing raw values of UIStoryboard.Name cases, we could just generate the UIStoryboard.Name enum. Please refer to SwiftGen for the details.

Discussion

In order to eliminate error-prone and boilerplate code, we have developed a type-safe solution using metaprogramming. We don’t pollute the global namespace and design our API with autocomplete in mind. In every place where you want to push a view controller, instead of 15 LOC now you can write only 1:

navigationController.pushMyViewController("I may be paranoid, but not an android.", 42, .white, animated)

Enter fullscreen mode Exit fullscreen mode

The less code, the more opportunities for new pattern discovery and further improvements.

For the sake of simplicity, we have been working with vanilla UINavigationController. The presented metaprogramming approach can be adapted to more sophisticated navigation architectures, such as coordinators, and other forms of user interactions.

We prefer functional programming over object-oriented, that’s why UIViewController multilevel inheritance is not supported in our templates.

One of the drawbacks of our approach is that initialize is not a private method. If we want to have our view controller in one file and all generated code in another, then we can’t make the initialize function private since createMyViewController must access initialize. At the same time, Sourcery supports inlining generated code right into the source files instead of separate files, but we just don’t even want to look at this boilerplate while programming our view controllers. Therefore, there is a tacit convention: “don’t manually call an initialize method”; luckily, our programmers are intelligent enough to follow it.

The full example Xcode project with a typical project structure including all discussed Sourcery templates can be found here.

Acknowledgments

I would like to thank Dmitry Cherednikov and Vyacheslav Shakaev for their constructive criticism and valuable comments on the draft version of this text. I would also like to thank Michael Goremykin for his contribution to the templates’ source code.

Top comments (8)

Collapse
 
mtmorozov profile image
Dmitrii Morozov

Neat and clean, will use try it for sure, thanks!

Collapse
 
ivangoremykin profile image
Ivan Goremykin

Thank you @mtmorozov 🙌

Collapse
 
rptoma profile image
Toma Radu-Petrescu

Great article as always!

Collapse
 
ivangoremykin profile image
Ivan Goremykin

Thank you @rptoma 🙇‍♂️

I intend to delve deeper into this idea 🤿

The notion of passing along the same list of arguments from the system to the sub-system is a universal concept. I'm considering creating meta-code that can accomplish this, not only for view controllers but for any scenario.

I'm very excited about the recent announcement of Swift macros, so I'm going to explore them in my next article 🤓

By the way, make sure to take a look at this curated list covering everything related to Swift macros 🚀

Collapse
 
g00dm0us3 profile image
Dmitry

Very neat solution to a very treacherous problem! IMHO better not use "atomic" term unless it's about threads, creates a little confusion while skimming through.

Collapse
 
ivangoremykin profile image
Ivan Goremykin

Thank you @g00dm0us3!

I struggled to come up with a less ambiguous term, but didn't succeed 😔

Collapse
 
pilar_fernandez_0b2c503f3 profile image
Pilar Fernandez

Great article. Will implement !

Collapse
 
ivangoremykin profile image
Ivan Goremykin

Thank you @pilar_fernandez_0b2c503f3!

Remember that the whole source code is available here, so you can adapt it to you architecture 😎