ekazaev / route-composer

Protocol oriented, Cocoa UI abstractions based library that helps to handle view controllers composition, navigation and deep linking tasks in the iOS application. Can be used as the universal replacement for the Coordinator pattern.
MIT License
896 stars 63 forks source link

Pass a different type of context to children #57

Closed clementleys closed 3 years ago

clementleys commented 4 years ago

Hello, I would like to push a viewController from another but with different contexts. I can't figure out how to do this. I have a first screen embedded in my tabBar:

    var firstScreen: DestinationStep<FirstViewController, FirstContext> {
        StepAssembly(
            finder: FirstViewControllerFinder(),
            factory: FirstViewControllerFactory()
        )
        .using(UITabBarController.add())
        .from(homeScreen.expectingContainer())
        .assemble()
    }

And I am trying to push a second viewController from it:

    var secondScreen: DestinationStep<SecondViewController, SecondContext> {
        StepAssembly(
            finder: SecondViewControllerFinder(),
            factory: SecondViewControllerFactory()
        )
        .using(UINavigationController.push())
        .assemble(from: firstScreen.expectingContainer())
    }

But I have an error because FirstContext and SecondContext are not the same. Is there a way to solve this case ?

Thank you.

ekazaev commented 4 years ago

Hi @clementleys .

This is a quite an often question. I probably should put it in to FAQ. So basically you need to understand how much type safety you need and how your screens are being build in dynamics.

  1. If the second view controller is never being build dynamically with the first one you can use firstScreen.unsafelyRewrapped() and it will allow you to avoid the type check. According to your example it is a right way to go as you don't care about the firstScreen context, you are interested only in its UINavigationController. Do not worry about the word unsafely here. It just mean that you know what you are doing and you do not want to rely on swift type checking.

  2. What I mean when I say dynamically build, I is easier to explain it on the deeplink example.

Lets say you have a booking app that has a home screen, a hotel screen and the room screen.

Chain will look like: Home -> Hotel -> Room

So the correct way to navigate to all of this screen will be to descibe the common context for the hotel and a room screen Something like:

struct BookingContext {
    let hotelId: String
    let roomId: String?
}

And keep the Home context type as Any? as home can be presented without any context.

So when user taps on a deep-link you can to show him either a hotel or a room in the hotel after the hotel view controller as those two screens are dependent and you need to have a common context for them. And here it is actually important fo your business logic that swift checks the type.

Please let me know if you have any other questions or you need an advice.

ekazaev commented 4 years ago

@clementleys Some other examples can be found here

ekazaev commented 4 years ago

@clementleys Basically, for the Router all the contexts are Any?. So the Router itself does not care about the type. But when it comes to passing the context back to your Factory or UIViewController the Router will try to convert that context back to the type required for that entity. If it fails to do so - navigation stops. Please see the documentation for DestinationStet

clementleys commented 4 years ago

Ok, thank you for your reactivity ! :-) It makes sense, but in my case I just need to push the second view form the first.

I already tried firstScreen.unsafelyRewrapped(), it compiles but when I call navigate(to:) it does nothing. If I put Any? as Context type for firstScreen it works like a charm but it does not with FirstContext...

To be more precise: I am using Context to pass a viewModel to my viewController, not sure I am doing well. I am using a MVVM pattern so I need to initialise my viewController with a viewModel.

ekazaev commented 4 years ago

@clementleys No problem at all. Can you please switch logging to the verbose mode and copy/paste the logs here - so I can tell you what is the issue there. Router logs every step it does and we will be able to see the issue. It seems that you have the second case and you are trying to build both view containers in one go.

But you need to bear in mind that Context is not for view models. Its a payload that you need to pass to be able to build the next screen. Like if in product array screen I select a product I will pass productID to the next screen. It is a context of the Product Screen. You need to Build the view model within the factory or use the ContextTask and pass that productID to the view model.

clementleys commented 4 years ago

Ok, so I have created contexts and I use them to create the viewModel in the factory.

The error is:

StudyRouteComposer[4402:74366] Type Mismatch Error: Type SecondContext is not equal to the expected type FirstContext. FirstViewControllerFinder does not accept SecondContext() as a context.

So it compiles and run but the problem is still there.

ekazaev commented 4 years ago

@clementleys It seems that you have the second case. Router clearly says FirstViewControllerFinder does not accept SecondContext() as a context. Here you need to understand if your contexts are connected or not. The way your configuration is written now mean that they are connected. It is easier to think about the configuration from the position of the universal links, it gives more clear view on the configuration as user can be at this moment anywhere in the app.

  1. Let's assume that contexts are connected as I am not familiar with your task. Let's say that the first view controller is HotelViewController. And is context is hotelID: UUID and the second view controller is RoomViewController and its context is roomID: String. Lets assume the situation that non of them are present on the screen. And the user taps on the deeplink in the email and is being redirected to your app and you tell the Router navigate(to: roomViewConfiguration, with: "12345") where "12345" is roomID. According to the way your configuration is written - you specifically say router my RoomViewController (the second one) can be presented only after you build the HotelViewController. The string "12345" is not enough to build the HotelViewController as it is clearly declares that it can be build only with some UUID which is the hotelID. This is why type check is there and it mean. So in this situation you need to create common context fro these 2 view controllers like

    struct BookingContext {
    let hotelId: UUID
    let roomId: String?
    }

    And use it in both configurations.

  2. Lets assume that SecondViewController does not really care about the first one. When we say "doesnt really care" it mean that we do not really care what is before your SecondViewController in the navigation stack and the only thing we care about is that there should be some UINavigationController to push your second view controller to. In this case you need to write your configuration this way:

    var secondScreen: DestinationStep<SecondViewController, SecondContext> {
        StepAssembly(
            finder: SecondViewControllerFinder(),
            factory: SecondViewControllerFactory()
        )
        .using(UINavigationController.push())
        .assemble(from: GeneralStep.custom(using: ClassFinder<UINavigationController, SecondContext>(options: .currentVisibleOnly)))
    }

This way Router does not care about what is before the SecondViewController and will be ablre to build it as soon as there any UINavigation controller is visible.

Lets assume that you need not just any UINavigationController that is visible. But you want a specific one that contains FirtsViewController. You can write the configuration this way:

    var secondScreen: DestinationStep<SecondViewController, SecondContext> {
        StepAssembly(
            finder: SecondViewControllerFinder(),
            factory: SecondViewControllerFactory()
        )
        .using(UINavigationController.push())
        .assemble(from: GeneralStep.custom(using: ClassFinder<UINavigationController, SecondContext>(options: .currentVisibleOnly, startingPoint: .custom(ClassFinder<FirstViewController, Any?>().getViewController())))))
    }

You can even make it more flexible as you know that your app has some welcome screen or whatever where definitely no any UINavigationController present. But universal links must work anyway. You can write configuration the way that if there is no any UINavigationController visible - then build one and present it modally. Like this:

    var secondScreen: DestinationStep<SecondViewController, SecondContext> {
        StepAssembly(
            finder: SecondViewControllerFinder(),
            factory: SecondViewControllerFactory()
        )
        .using(UINavigationController.push())
        .assemble(from: SwitchAssembly<UINavigationController, SecondContext >()
        // If some UINavigationController is visible on the screen - just push
        .addCase(from: ClassFinder<UINavigationController, SecondContext>(options: .currentVisibleOnly, startingPoint: .custom(ClassFinder<FirstViewController, Any?>().getViewController())))
        // Otherwise - create new UINavigationController and present it modally
        .assemble(default: ChainAssembly.from(NavigationControllerStep<UINavigationController, SecondContext>())
            .using(GeneralAction.presentModally())
            .from(GeneralStep.current())
            .assemble())
    }

Please let me know if it is what you expect and if I can help you any further.

ekazaev commented 4 years ago

@clementleys I would also recommend to review your contexts in general. It is not clear for me why the very first view controller within tab bar has some context. I can not properly imagine such situation. But if it is lest say some currentlyLoggedUserID then it in not a context of such view controller and it is incorrect. It is not the information that is being passed between the navigation stacks. It is something that sits in the layers below the presentation and should be propagated to the FirstViewController from some LoginManager but not using the router and the context of such FirstViewController should be Any? as it clearly does not have any context

clementleys commented 4 years ago

Ok I understand. If router needs to create the first screen, it needs to know his context. It makes sense.

Not sure to understand your last message. You mean the FirstViewController is already in the TabBar so it just needs to be initialised when added but we don't necessarily need to propagate context in the navigation to display it. Am I right ? But if I want to navigate to this tab, I could add a context to update it according to this context, right ?

Thank you very much for your very detailed answer !

ekazaev commented 4 years ago

@clementleys About my second answer. Think about next situation. You need to build a tab bar with 4 tabs in it

  1. HomeViewController
  2. MenuViewController
  3. AccountViewController
  4. BagViewController

If they all have different contexts e.g. HomeViewController has homeID: String and AccountViewController has accountId: UUID. Imagine your user is on some OnboardingViewController and he taps on the button Done there. And you need to build this TabViewController with all 4 view controllers within it. You wont be able to group them together and navigate/build to such TabController just because you wont be able to transfer (or pass - not sure which word is better to use in english) so many different contexts at once it to those view controllers. But the keyword here is "transfer". Clearly the homeID: String and accountId: UUID are not the information that belong to the OnboardingViewController and for you app to function they must be transferred from the onboarding screen to the main app module. homeID: String belongs to some lower layer responsible for the customised home screen for this particular user, and accountId: UUID belongs to authorisation subsystem. They are not payloads that you need to cary from one place to another. If you think about your contexts from this point of view - most-likely you will see that all this root view controllers on practice has no context and it should be declared as Any? so Router would stop to care about them. I do my assumptions of course. I do not know what you are doing.

There can be a question then When the context has sense?. Imagine that you have 'ProductListViewController' that shows the list of the products you sell and user taps on one of them - so you need to should ProductDetailViewController with corresponding ProductID. Here your 'ProductListViewController' is the only place in the app that "knows" which productID was selected. ProductID is not persisted any where. 'ProductListViewController' is the only one who can transfer it to the ProductDetailViewController that can not function without it. In this situation it is clear that productID: String is the navigation context that must be passed to the router.

Hope that clarifies my second comment.

ekazaev commented 4 years ago

@clementleys Think about it this way: For the Router your app is a black box. It doesnt know what is inside. It can not check or predict anything. It can not see what is inside your story boards for example. This is why it is able to work in parallel with any other navigation that you may have in your app.

Each navigation is the single isolated process. Router requires only 2 things to make it happen. The configuration that describes what needs to be build and the context that has the information hta router has to pass to each step of the configuration to build it.

The trick is that configuration can be partly build already. So the Router uses Finder instances to understand what was build . But finders are also consuming the context. They are as valuable as factories. For example for the Rotuter to understand that ProductDetailViewController with productID = "12345" is already build and router does not need to do anything for this.

So if you do not rely on context for the first screen and you do not need to build it if it is not present - just mention it as a starting point of your navigation using startingPoint: .custom(ClassFinder<FirstViewController, Any?>().getViewController()). Why Any?. Just because you do not care what is the value of the context of the FirstViewController. The only thing you care about is that this view controller should exist and should be considered as a starting point of the branch you want to build in the view controller stack that is currently on the screen.

This is why the Router uses swift type system to check that 2 screens within the configuration are compatible. You can avoid that check by using unsafelyRewrapped but it mean that you know what you are doing and router will have to check the rest in runtime. Example of such configuration is when you need to build 2 screens where context of one screen is UUID and context of another screen is UUID?. There are no ways to express that this contexts are the same type (at least with the current development of the swift type check system). So here you can use unsafelyRewrapped as you know that as soon as there any UUID - router will be able to build both screen in that chain.

For all the screens that have context set as Any? there is another method available to include them into the configuration and avoid type checks - adaptingContext. It is available only for such screens because they clearly say that they can work with any context, no matter if it is UUID or String or whatever.

For all the rest you need to think if it worth to mention them in the configuration. Are they really important or we actually interested in them only as a starting poing as we do not have any information to provide them if they are not build. Remember that in configuration can be other entities like ContextTasks or Interceptors that may also be interested in the context. Like you may have an Interceptor that blocks the navigation or changes the navigation if productId < 0, so they can work only with Int context.

clementleys commented 4 years ago

Ok thank you very much. I understand the way it works now ! Congratulations for this library, this is a very interesting concept 💯

ekazaev commented 4 years ago

@clementleys Thank you very much. Really appreciated. I can also add that I know two companies that are using this library and they download their navigation configuration from the kind of CMS. So their nvigation pattern is dynamic. So they consider everything to be just a UIViewControler without type and keep the context as dictionary. But I assume that your business needs are not as complicated.

I will close the issue then. Please let me know if you have any other questions.

bimawa commented 3 years ago

@ekazaev We got the same problems. In our case, we should open any screens from any other screens. But with context. And its looks like our screens depends on context hierarchy chain.
Maybe can we use something for open second screen with second context from any first screen/context? Or it is feature request?

ekazaev commented 3 years ago

Thank you for the question @bimawa I think I dont fully understand the context of the question

In our case, we should open any screens from any other screens. But with context.

Do you mean that you want to be able to randomly chain anything with anything and pass different payload to each of those view controllers?

Well, you dont need a feature request for it. I know a company that downloads the entire layout of the app from the cloud. So they already doing that. Just make the context of all of the steps a dictionary for example. And you ll be able to put there the context for all the screens in the chain. Of course you are immediately loosing the type checking in the compile time. But when you say anything with anything you are getting some kind of duck typing. You'll probably will need to tweak the finders a bit. But it is not hard. And I would recommend to use a prepare method in each factory to check that the context for this particular view controller is present in the said dictionary before the router starts any navigation, so it wont stop in the middle because you forgot to put some context required for some intermediate view controller.

But my final recommendation would be to reconsider that approach. I am sure if your configuration is not completely dynamic like for the company I mentioned above and you still have limited amount of combinations, I would strongly recommend you to bring some concrete context type. Or, it is possible, that you are trying to give context some function that is actually should belong to some kind of broadcasting pattern. But I am not fully aware of what you are doing to make this assumption.

ekazaev commented 3 years ago

Issue is closed after the private conversation. In general case from(GeneralStep.custom(using: ClassFinder<UINavigationController, Any?>()).adaptingContext()) should be used. There are few screens where composite context should be used.