UIViewController composition and navigation between them (and not only)


In this article I want to share the experience that we have successfully used for several years in our iOS applications, 3 of which are currently in the Appstore. This approach has proven itself and recently we have segregated it from the rest of the code and designed it into a separate library RouteComposer , which will be discussed here.


https://github.com/saksdirect/route-composer


But, for a start, let's try to figure out what is meant by the composition of the controller view in iOS.


Before proceeding to the explanations, I remind you that in iOS most often is meant by the controller view or UIViewController. This is a class inherited from the standard UIViewController, which is the basic controller of the MVC pattern, which Apple recommends using for developing iOS applications.


You can use alternative architectural patterns such as MVVM, VIP, VIPER, but they UIViewControllerwill also be used in one way or another, which means that this library can be used with them. The essence is UIViewControllerused to control UIView, which most often represents a screen or a significant part of the screen, process events from it and display some data in it.



All UIViewControllers can be divided into Conventional View Controllers , which are responsible for some visible area on the screen, and Container View Controllers , which, in addition to displaying themselves and some of their controls, can also display child view controllers integrated into them in one way or another.


Standard container twist controller comes with Cocoa Touch, you can consider: UINavigationConroller, UITabBarController, UISplitController, UIPageControllerand others. Also, the user can create his own custom container view view controllers following the Cocoa Touch rules described in the Apple documentation.


The process of introducing standard view viewers into a container view view controllers, as well as integrating view view controllers into the view view stack, will be called composition in this article.


Why the standard solution for the composition of the controllers turned out to be not optimal for us and we developed a library to facilitate our work.


Let's consider to begin with the composition of some standard container view view controllers for example:


Composition examples in standard containers


UINavigationController



let tableViewController = UITableViewController(style: .plain)
// Вставка первого вью контролера в контролер навигации
let navigationController = UINavigationController(rootViewController: tableViewController)
// ...// Вставка второго вью контролера в контролер навигации
let detailViewController = UIViewController(nibName: "DetailViewController", bundle: nil)
navigationController.pushViewController(detailViewController, animated: true)
// ...// Вернуться к первому контроллеру
navigationController.popToRootViewController(animated: true)

UITabBarController



let firstViewController = UITableViewController(style: .plain)
let secondViewController = UIViewController()
// Создание контейнера
let tabBarController = UITabBarController()
// Вставка двух вью контролеров в таб бар контролер
tabBarController.viewControllers = [firstViewController, secondViewController]
// Один из программных способов переключения видимого контролера
tabBarController.selectedViewController = secondViewController

UISplitViewController



let firstViewController = UITableViewController(style: .plain)
let secondViewController = UIViewController()
// Создание контейнера
let splitViewController = UISplitViewController()
// Вставка первого вью контролера в сплит контролер
splitViewController.viewControllers = [firstViewController]
// Вставка второго вью контролера в сплит контролер
splitViewController.showDetailViewController(secondViewController, sender: nil)

Examples of integration (composition) twist controllers into the stack


Installing a view of the controller by the root


letwindow: UIWindow = //...window.rootViewController = viewController
window.makeKeyAndVisible()

Modal presentation of the view controller


window.rootViewController.present(splitViewController, animated: animated, completion: nil)

Why we decided to create a library for composition


As can be seen from the examples above, there is no single way to integrate normal viewers of controllers into containers, just as there is no single way to build a stack of viewers of controllers. And, if you want to slightly change the layout of your application or its navigation method, as you need significant changes to the application code, you also need links to container objects, so that you can insert your view controllers, etc. into them. That is, the standard way itself implies a fairly large amount of work, as well as the existence of references to twist checkers for generating actions and the presentation of other checkers.


All this is given a headache by different ways of deep linking to the application (for example, using Universal links), since you have to answer the question: what if the controller of which you need to show the user as he clicked the link in the safari is already shown, or I twist the controller who has to show it has not yet been created , forcing you to walk through the tree with the viewers of the controllers and write code from which sometimes the eyes bleed and which any iOS developer tries to hide. In addition, unlike the Android architecture where each screen is built separately, in iOS, to show some part of the application immediately after launching, you may need to build a sufficiently large stack of view controllers that will be hidden under the one that you show on request.


It would be great to simply call methods like goToAccount(), goToMenu()or goToProduct(withId: "012345")when a user clicks on a button or when the application receives a universal link from another application and not think about integrating this controller view onto the stack, knowing that the creator of this view controller has already provided this implementation.


In addition, often, our applications consist of a huge number of screens developed by different teams, and to get to one of the screens in the development process, you need to go through another screen that may not yet have been created. In our company, we used the approach we call the Petri dish . That is, in the development mode, the developer and the tester can see a list of all application screens and he can go to any of them (of course, some of them may require some input parameters).



You can interact with them and test them individually, and then assemble them into the final application for production. Such an approach greatly facilitates development, but, as you have seen from the examples above, the composition hell begins when you need to keep several ways of integrating the controller's twist into the code in the code.


It remains to add that this will all be multiplied by N as soon as your marketing team expresses a desire to conduct A / B testing on live users and check which navigation method works better, for example, a tab bar or a hamburger menu?


  • Let's cut off Susanin's legs Let's show 50% of Tab Bar users and the Hamburger menu to others and in a month we will tell you which users see more of our special offers?

I will try to tell you how we approached the solution of this problem and eventually allocated it to the RouteComposer library.


Susanin Route composer


After analyzing all the scenarios of the composition and navigation we tried to abstract the examples given in the above code and identified 3 main essence of which is, and which operates the library RouteComposer - Factory, Finder, Action. In addition, in the library there are 3 subsidiary entities which are responsible for a small tuning that may be required in the process of navigation - RoutingInterceptor, ContextTask, PostRoutingTask. All these entities must be configured in a chain of dependencies and transferred to the Routery object, which will build your stack of view controllers.


But, about each of them in order:


Factory


As the name implies Factoryis responsible for creating a view viewer.


publicprotocolFactory{
    associatedtypeViewController: UIViewControllerassociatedtypeContextfuncbuild(with context: Context)throws -> ViewController
}

It is also important to make a reservation about the concept of context . The context within the library, we call all that I might need to twist the controller in order to be created. For example, in order to show a controller view showing the details of the product, it is necessary to transfer some productID to it, for example, as String. The essence of the context can be anything: an object, a structure, a block, or a tuple. If your controller does not need anything to be created - the context can be specified as Any?set in nil.


For example:


classProductViewControllerFactory: Factory{
    funcbuild(with productID: UUID)throws -> ProductViewController {
        let productViewController = ProductViewController(nibName: "ProductViewController", bundle: nil)
        productViewController.productID = productID // В целом, данное действие лучше переложить на `ContextAction`, но об этом далееreturn productViewController
    }
}

From the implementation above, it becomes clear that this factory will load the controller image from the XIB file and set the transferred productID to it. In addition to the standard protocol Factory, the library provides several standard implementations of this protocol in order to save you from writing banal code (in particular, given in the example above).


Further, I will refrain from giving descriptions of the protocols and examples of their implementations, since you can get acquainted with them in detail by downloading the example supplied with the library. There are various implementations of factories for ordinary viewers and containers, as well as ways to configure them.


Action


An entity Actionis a description of how to integrate the view of a controller, which will be built by the factory, onto a stack. After the creation, the controller cannot simply hang in the air after creation and, therefore, each factory should contain Actionas can be seen from the example above.


The most commonplace implementation Actionis the modal controller presentation:


class PresentModally: Action {
    func perform(viewController: UIViewController, on existingController: UIViewController, animated: Bool, completion: @escaping (_: ActionResult) -> Void) {
        guard existingController.presentedViewController == nil else {
            completion(.failure("\(existingController) is already presenting a view controller."))
            return
        }
        existingController.present(viewController, animated: animated, completion: {
            completion(.continueRouting)
        })
    }
}

The library contains the implementation of most standard methods for integrating view controllers onto a stack, and you probably don’t have to create your own until you use a custom container view view controller or presentation method. But the creation of custom Actionshould not cause problems if you familiarize yourself with the examples.


Finder


The entity Finderanswers the router to the question - Has the controller already created such a twist and is it already on the stack? Perhaps nothing is needed and it is enough to show what is already there? .


publicprotocolFinder{
    associatedtypeViewController: UIViewControllerassociatedtypeContextfuncfindViewController(with context: Context) -> ViewController?
}

If you store references to all the controllers you create with a view, then in your implementation Finderyou can simply return a reference to the controller view you want. But more often it is not so, because the application stack, especially if it is large, changes quite dynamically. In addition, you can have several identical controllers twist in a stack showing different entities (for example, several ProductViewControllers showing different products with different productID), so an implementation Findermay require custom implementation and search for the corresponding controller twist. The library facilitates this task by providing it StackIteratingFinderas an extension Finder— a protocol with the appropriate settings to simplify this task. In implementationStackIteratingFinder you only need to answer the question - is this controller the one that the router is looking for at your request?


An example of such an implementation:


classProductViewControllerFinder: StackIteratingFinder{
    let options: SearchOptionsinit(options: SearchOptions = .currentAndUp) {
        self.options = options
    }
    funcisTarget(_ productViewController: ProductViewController, with productID: UUID) -> Bool {
        return productViewController.productID == productID
    }
}

Helper Entities


RoutingInterceptor


RoutingInterceptorBefore starting the composition, I twist the controllers to perform some actions and tell the router whether the controllers can be integrated into the stack. The most commonplace example of such a task is authentication (but not at all commonplace in implementation). For example, you want to show a controller view with user account details, but for this, the user must be logged in to the system. You can implement RoutingInterceptorand add it to the configuration view of the user details controller and check inside: if the user is logged in - allow the router to continue navigation, if not - show the controller view that prompts the user to login and if this action is successful - allow the router to continue navigation or cancel it if the user refuses to login.


classLoginInterceptor: RoutingInterceptor{
    funcexecute(for destination: AppDestination, completion: @escaping (_: InterceptorResult) -> Void) {
        guard !LoginManager.sharedInstance.isUserLoggedIn else {
            // ...// Показать LoginViewController и по резульату действий пользователя вызвать completion(.success) или completion(.failure("User has not been logged in."))// ...return
        }
        completion(.success)
    }
}

The implementation of this RoutingInterceptorwith comments is contained in the example supplied with the library.


ContextTask


An entity ContextTask, if you provide it, can be applied separately to each controller view in the configuration, regardless of whether it was just created by the router or was found on the stack, and you just want to update the data in it and set some default parameters (for example, show the close or not show button).


PostRoutingTask


The implementation PostRoutingTaskwill be invoked by the router after successfully completing the integration of the controller I have twisted onto the stack. In its implementation, it is convenient to add different analytics or pull various services.


In more detail with the implementation of all the described entities can be found in the documentation for the library as well as in the attached example.


PS: The number of auxiliary entities that can be added to the configuration is unlimited.


Configuration


All the described entities are good in that they break the composition process into small, interchangeable and well-crafted blocks.


We now turn to the most important thing - to the configuration, that is, the connection of these blocks between them. In order to collect these blocks among themselves and combine them into a chain of steps, the library provides the builder class StepAssembly(for containers - ContainerStepAssembly). Its implementation allows stringing the composition blocks into a single configuration object as beads on a string, as well as specifying dependencies on the configurations of other controllers. What to do with the configuration in the future is up to you. You can feed it to the router with the necessary parameters and it will build a stack of controllers for you, you can save it to the dictionary and later use it by key - it depends on your specific task.


Consider a simple example: Suppose that by clicking on a cell in the list or when the application receives a universal link from a safari or email client, we need to show the product controller with a certain productID in a modal view. At the same time I twist the product controller to be built inside UINavigationController, so that on his control panel he could show his name and close the button. In addition, this product can only be shown to users who are logged in, otherwise invite them to log in.


If you analyze this example without using the library, it will look something like this:


classProductArrayViewController: UITableViewController{
    let products: [UUID]?
    let analyticsManager = AnalyticsManager.sharedInstance
    // Методы UITableViewControllerDelegateoverridefunctableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guardlet productID = products[indexPath.row] else {
            return
        }
        // Уйдет в LoginInterceptorguard !LoginManager.sharedInstance.isUserLoggedIn else {
            // Много кода показывающего LoginViewController и обрабатывающего его результаты и в последствии вызывающего `showProduct(with: productID)`return
        }
        showProduct(with: productID)
    }
    funcshowProduct(with productID: String) {
        // Уйдет в ProductViewControllerFactorylet productViewController = ProductViewController(nibName: "ProductViewController", bundle: nil)
        // Уйдет в ProductViewControllerContextTask
        productViewController.productID = productID
        // Уйдет в NavigationControllerStep и PushToNavigationActionlet navigationController = UINavigationController(rootViewController: productViewController)
        // Уйдет в GenericActions.PresentModally
        present(alertController, animated: navigationController) { [weakself]
            Уйдет в См. ProductViewControllerPostTaskself?.analyticsManager.trackProductView(productID: productID)
        }
    }
}

This example does not include the implementation of universal links, which will require isolating the authorization code and saving the context where the user should be sent after, as well as the search, suddenly the user clicked the link, and this product is already shown to him, which ultimately makes the code quite hard to read.


Consider the configuration of this example using the library:


let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory())
        // Вспомогательные сущности:
        .adding(LoginInterceptor())
        .adding(ProductViewControllerContextTask())
        .adding(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance))
        // Цепочка зависимостей:
        .using(PushToNavigationAction())
        .from(NavigationControllerStep()) 
        // NavigationControllerStep -> StepAssembly(finder: NilFinder(), factory: NavigationControllerFactory())
        .using(GeneralAction.presentModally())
        .from(GeneralStep.current())
        .assemble()

To translate it into human language:


  • Check that the user is logged in, and if not offer him a login
  • If the user is successfully logged in, continue
  • Search for product view controller provided Finder
  • If found, make visible and finish.
  • If not found - create UINavigationController, integrate into it the view controller created ProductViewControllerFactoryusingPushToNavigationAction
  • Embed the received UINavigationControllerusing GenericActions.PresentModallyfrom the current view controller

Configuration requires some study as well as many complex solutions, for example, a concept AutoLayoutand, at first glance, it may seem complicated and redundant. However, the number of tasks to be solved by the given code fragment covers all aspects from authorization to deep linking, and the breakdown into sequence of actions makes it possible to easily change the configuration without the need to make changes to the code. In addition, the implementation StepAssemblywill help you avoid problems with an unfinished chain of steps, and type control - problems with the incompatibility of input parameters for different view controllers.


Consider the pseudo-code of the full application in which a ProductArrayViewControllerlist of products is displayed in a certain way and, if the user selects this product, shows it depending on whether the user is logged in or not, offers to log in and shows after successful login:


Configuration objects


// `RoutingDestination` протокол обертка для роутера. Можно добавить туда дополнительные параметры при желании для ваших обработчиков.struct AppDestination: RoutingDestination {
    let finalStep: RoutingStep
    let context: Any?
}
struct Configuration {
    // Является статическим только для примера, вы можете создать протокол и подменять его реализации в зависимости от задачиstatic func productDestination(with productID: UUID) -> AppDestination {
        let productScreen = StepAssembly(finder: ProductViewControllerFinder(), factory: ProductViewControllerFactory())
                .add(LoginInterceptor())
                .add(ProductViewControllerContextTask())
                .add(ProductViewControllerPostTask(analyticsManager: AnalyticsManager.sharedInstance))
                .using(PushToNavigationAction())
                .from(NavigationControllerStep())
                .using(GenericActions.PresentModally())
                .from(CurrentControllerStep())
                .assemble()
        return AppDestination(finalStep: productScreen, context: productID)
    }
}

Implementing a list of products


classProductArrayViewController: UITableViewController{
    let products: [UUID]?
    //...// DefaultRouter - реализация Router класса предоставляемая библиотекой, создается внутри UIViewController для примераlet router = DefaultRouter()
    overridefunctableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guardlet productID = products[indexPath.row] else {
            return
        }
        router.navigate(to: Configuration.productDestination(with: productID))
    }
}

Implementing universal links


@UIApplicationMainclassAppDelegate: UIResponder, UIApplicationDelegate{
    //...funcapplication(_ application: UIApplication,
                     open url: URL,
                     sourceApplication: String?,
                     annotation: Any) -> Bool {
        guardlet productID = UniversalLinksManager.parse(url: url) else {
            returnfalse
        }
        returnDefaultRouter().navigate(to: Configuration.productDestination(with: productID)) == .handled
    }
}

That is, in essence, all that was required to implement all the conditions from the set example.


It should also be mentioned that the configuration can be much more complicated and consist of dependencies. For example, if you need to show the product not just from the current controller, but if the user came to it via a universal link, then it should definitely be ProductArrayViewController, which must be inside UINavigationController after the conditional HomeViewController, then all this can be specified in configuration StepAssemblyusing from(). If your application is covered RouteComposercompletely, it will not be difficult to do (See the application in the example for the library). In addition, you can create multiple implementations.Configurationand transfer them to the same controller view for implementing different composition options. Or choose one of them, if A / B testing is carried out in your application, depending on which focus group your user belongs to.


Instead of conclusion


At the moment, the approach described above is used in 3 applications in production and has proven itself well. Splitting the task of composition into small, easy to read, interchangeable units makes it easier to understand and search for bugs. The default implementation Fabric, Finderand Actionallows for most tasks to start immediately with the configuration without the need to create your own. And the most important thing that gives this approach is the possibility of autonomous creation of twist controllers without the need to enter into their code knowledge of how they will be built, and how the user will move in the future. I twist the controller only according to the user's action to call the desired configuration, which can also be abstracted.


The library, as well as the implementation of the router provided to it, does not use any tricks with objective runtime and fully follows all the concepts of Cocoa Touch, only helping to break up the composition process into steps and performs them in a predetermined sequence. Library tested with iOS versions 9 through 12.


This approach fits into all architectural patterns that involve working with a UIViewControllerstack (MVC, MVVM, VIP, RIB, VIPER, etc.)


The library is in active development and, nevertheless, as I wrote above, it is used in production. I would recommend trying out this approach and sharing your impressions. In our case, this allowed us to get rid of a large number of headaches.


I will be glad to your comments and suggestions.


Also popular now: