📜 ⬆️ ⬇️

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 well 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 a view controller or a UIViewController . This is a class inherited from the standard UIViewController , which is the basic MVC pattern controller that Apple recommends using for developing iOS applications.


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



All UIViewController can be conditionally divided into Regular 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. .


The standard container view controllers supplied with Cocoa Touch can be considered: UINavigationConroller , UITabBarController , UISplitController , UIPageController and some 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 viewers into a view view stack, we will call the composition in this article.


Why the standard solution for the composition of the view controllers was 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


 let window: 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, as there is no single way to build a stack of viewers. 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 will 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 presence of references to twist checkers for generating actions and the presentation of other checkers.


All this is given a headache by the various 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 a view 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 large enough 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 a button or when an application goToProduct(withId: "012345") universal link from another application and does not think about integrating this controller view into the stack, knowing that the creator of this twist 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 in the code several ways of integrating the controller's twist into the stack.


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?



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 composition and navigation, we tried to abstract the code given in the examples above and identified 3 main entities of which the RouteComposer library — Factory , Finder , Action — consists and operates. In addition, the library contains 3 auxiliary entities that are responsible for the small tuning that may be required during the navigation process - RoutingInterceptor , ContextTask , PostRoutingTask . All these entities must be configured in a chain of dependencies and transferred to Router y - the object that will build your stack of view controllers.


But, about each of them in order:


Factory


As the name implies Factory is responsible for creating a view controller.


 public protocol Factory { associatedtype ViewController: UIViewController associatedtype Context func build(with context: Context) throws -> ViewController } 

It is also important to discuss 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 the controller a view showing the details of the product, it is necessary to transfer some productID to it, for example, as a 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? and set to nil .


For example:


 class ProductViewControllerFactory: Factory { func build(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 Factory protocol, 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 of controllers and containers, as well as ways to configure them.


Action


The essence of Action is a description of how to integrate the view of the controller, which will be built by the factory, onto the stack. After the creation, the controller cannot simply hang in the air and, therefore, each factory must contain an Action as can be seen from the example above.


The most commonplace implementation of Action is a 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 of the 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 creating custom actions should not cause problems if you look at examples.


Finder


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


 public protocol Finder { associatedtype ViewController: UIViewController associatedtype Context func findViewController(with context: Context) -> ViewController? } 

If you store references to all the controllers you create with a view, then in your Finder implementation you 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), therefore the Finder implementation may require custom implementation and search for the corresponding controller twist. The library facilitates this task by providing StackIteratingFinder as an extension to Finder , a protocol with the appropriate settings to simplify this task. In the implementation of StackIteratingFinder you only need to answer the question - is this controller view the controller that the router is looking for at your request?


An example of such an implementation:


 class ProductViewControllerFinder: StackIteratingFinder { let options: SearchOptions init(options: SearchOptions = .currentAndUp) { self.options = options } func isTarget(_ productViewController: ProductViewController, with productID: UUID) -> Bool { return productViewController.productID == productID } } 

Helper Entities


RoutingInterceptor


RoutingInterceptor allows you to perform some actions with the RoutingInterceptor before starting the composition and tell the router whether the view 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 RoutingInterceptor and add it to the configuration of the controller of user details 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 her if the user refuses to login.


 class LoginInterceptor: RoutingInterceptor { func execute(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 such a RoutingInterceptor with comments is contained in the example supplied with the library.


ContextTask


The ContextTask , if you provide it, can be applied separately to each controller 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 of PostRoutingTask will be called by the router after successfully completing the integration of the controller PostRoutingTask have PostRoutingTask stack. In its implementation, it is convenient to add various 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 entities described are good in that they break up the composition process into small, interchangeable and well-trusting blocks.


We now turn to the most important thing - to the configuration, that is, the connection of these blocks together. 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 thread, 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 trivial 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 a UINavigationController , so that it can show its name and close the button on its control panel. 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:


 class ProductArrayViewController: UITableViewController { let products: [UUID]? let analyticsManager = AnalyticsManager.sharedInstance //  UITableViewControllerDelegate override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let productID = products[indexPath.row] else { return } //   LoginInterceptor guard !LoginManager.sharedInstance.isUserLoggedIn else { //    LoginViewController         `showProduct(with: productID)` return } showProduct(with: productID) } func showProduct(with productID: String) { //   ProductViewControllerFactory let productViewController = ProductViewController(nibName: "ProductViewController", bundle: nil) //   ProductViewControllerContextTask productViewController.productID = productID //   NavigationControllerStep  PushToNavigationAction let navigationController = UINavigationController(rootViewController: productViewController) //   GenericActions.PresentModally present(alertController, animated: navigationController) { [weak self]   . ProductViewControllerPostTask self?.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 has already been 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 this into human language:



Configuration requires some study as well as many complex solutions, such as the concept of AutoLayout and, at first glance, it may seem complicated and redundant. However, the number of tasks 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 of StepAssembly will help you avoid problems with an unfinished chain of steps, and type control will help you with problems with the incompatibility of input parameters for different controller views.


Consider the pseudo-code of the complete application in which in a certain ProductArrayViewController list of products is displayed and, if the user selects this product, shows it depending on whether the user is logged in or not, or 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) } } 


 class ProductArrayViewController: UITableViewController { let products: [UUID]? //... // DefaultRouter -  Router   ,   UIViewController   let router = DefaultRouter() override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let productID = products[indexPath.row] else { return } router.navigate(to: Configuration.productDestination(with: productID)) } } 


 @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { //... func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { guard let productID = UniversalLinksManager.parse(url: url) else { return false } return DefaultRouter().navigate(to: Configuration.productDestination(with: productID)) == .handled } } 

.


. , , , — ProductArrayViewController, UINavigationController HomeViewController — StepAssembly from() . RouteComposer , ( ). , Configuration . , A/B , .


Instead of conclusion


, 3 . , , . Fabric , Finder Action . , — , , . , .


, , objective c Cocoa Touch, . iOS 9 12.


UIViewController (MVC, MVVM, VIP, RIB, VIPER ..)


, , , . . .


.


')

Source: https://habr.com/ru/post/421097/


All Articles