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
902 stars 64 forks source link

Why can't the second step in a UINavigationController use a different context than the first step? #86

Closed bennnjamin closed 2 years ago

bennnjamin commented 2 years ago

My goal is to setup a navigation controller and have a series of view controllers that I can push onto the navigation controller stack. I feel like I'm missing something obvious because this should be very simple. I'd like to pass a unique identifier Int? to the first view controller, instead of using Any? as the context.

    static var firstStep = StepAssembly(
        finder: ClassFinder(),
        factory: ClassFactory<FirstViewController, Int?>())
        .using(UINavigationController.push())
        .from(NavigationControllerStep<UINavigationController, Int?>())
        .using(GeneralAction.replaceRoot())
        .from(GeneralStep.root())
        .assemble()

    static var secondStep = StepAssembly(
        finder: ClassFinder(),
        factory: ClassFactory<SecondViewController, Any?>())
        .using(UINavigationController.push())
        .from(firstStep.expectingContainer())
        .assemble()

This does not compile, the compiler complains that it can't convert Int? to Any? and that Generic parameter 'VC' could not be inferred.

ekazaev commented 2 years ago

@bennnjamin Hi

Partially I covered that part in my previous answer. You must use the same context if you are building both view controllers simultaneosly. Like lets say you need to build a hotel view controller and a room view controller within the same navigation controller simulateneously. So in this case youll need both hotelId and roomId to be present in the context. It is not a limitation of the root composer. It is something that basic logic dictates.

But if you dont, then you dont need to have the same context there. Use firstStep.expectingContainer().adaptingContext() or firstStep.expectingContainer().unsafelyRewrapped() because in this case your case you expect that this view controller is already built. But there is a trap. As soon as you go to unsafelyRewrapped zone - it mean you ask configuration not to limit you. Which mean if you will be in a situation when you navigating to the second view controller and the first one doesnt exist - navigation will fail in runtime. Just for the fact that Any? object can not be converted into Int?. And it is not needed because those object probably mean different things for those 2 view controllers. Think about the example with a hotel and a room view controllers I described you above. Also look at them through the prism that both hotel and room view controllers may have the same context by type, lets say String. And even if the route composer wont comlain in that case - it is clear that you cant build a hotel view controller that expects a hotelId as a String with a String that represents roomId.

But describe me what do you actually want there. Do you want SecondViewController to be pushed after the first one?

There are multiple ways of writing the same things as I said.

You can write like this for example

secondStep = StepAssembly(
        finder: ClassFinder(),
        factory: ClassFactory<SecondViewController, Any?>())
        .using(UINavigationController.push())
        .from(GeneralStep.custom(using: ClassFinder<FirstViewController, Any?>()).expectingContainer())
        .assemble()

// or

    static var secondStep = StepAssembly(
        finder: ClassFinder(),
        factory: ClassFactory<SecondViewController, Any?>())
        .using(UINavigationController.push())
        .from(GeneralStep.custom(using: ClassFinder<UINavigationController, Any?>()))
        .assemble()

Can you understand the difference between those 2 examples?

bennnjamin commented 2 years ago

Like lets say you need to build a hotel view controller and a room view controller within the same navigation controller simulateneously. So in this case youll need both hotelId and roomId to be present in the context. It is not a limitation of the root composer. It is something that basic logic dictates.

Do you mean you are using a top-down approach to pass all necessary variables in the Context from a parent view controller to all child view controllers? For example, if this is a deeply nested navigation controller with 4-5 screens that all require contexts, I would need to pass the same context to each view controller, and each view controller will only use the property in the Context that it needs. Like this:

   struct ExampleContext {
     var firstViewControllerContextProperty: Int 
     var secondViewControllerContextProperty: String
     var thirdViewControllerContextProperty: String 
  }

What if the Navigation Controller may only pushes one view controller at a time, but each view controller requires different context? For example, a Settings Navigation Controller that lets a user edit their profile. When tapping on each profile item (name, phone, password, etc.), a view controller is pushed to change that value, and the context is the current value. Perhaps the Settings Navigation Controller asynchronously fetches this profile information based on its own context (userId) and so those values are not even available to pass as part of a Context object at the time the Settings Controller is created.

Can you understand the difference between those 2 examples?

I am honestly having difficulty seeing the difference. First of all I do not understand why the second example requires expectingContainer() because if the Finder finds a UINavigationController then that is already a container VC. The second example is not specific about which UINavigationController we are looking for using the Finder so I imagine that it may not work properly. I always want the router to go from First VC -> Second VC, using the UINavigationController that was created by First VC, regardless of what is present on screen. I think if some modal was present, it should probably be dismissed before pushing Second VC onto the navigation stack.

I also do not understand how .from(GeneralStep.custom(using: ClassFinder<FirstViewController, Any?>()).expectingContainer()) is different from just referencing firstStep

ekazaev commented 2 years ago

@bennnjamin

Lets abstract from view controllers. Just theoretically. If you want to show a hotel and a room simultaneously you need this 2 ids at the very same moment. Nothing to do with view controllers. Just logic. Now lets return to what you potentially want to do. You probably want when user clicks on the hotel in the hotel list - present a hotel view controller. And when user clicks on the room - present room view controllers. Those are not simultaneous actions and you dont need that information simultaneously. So having separate context is correct.

Now lets return to chains. Why do you try to build a chain of hotel view controller + room view controller? Are you sure you need that?

typedef HotelId = UUID
typedef RoomId = String

    static var hotelStep = StepAssembly(
        finder: ClassFinder(),
        factory: ClassFactory<HotelViewController, HotelId>())
        .using(UINavigationController.push())
        .from(NavigationControllerStep<UINavigationController, HotelId>())
        .using(GeneralAction.replaceRoot())
        .from(GeneralStep.root())
        .assemble()

    static var roomStep = StepAssembly(
        finder: ClassFinder(),
        factory: ClassFactory<RoomViewController, RoomId>())
        .using(UINavigationController.push())
        .from(hotelStep.expectingContainer()) // Compiler error that String doesn't match UUID but lets ignore that fact.
        .assemble()

// User clicks on the room with id "1234"

    router.commitNavigation(
            to: Destination(to: roomStep, with: "1234"), // No hotel id to find or build `HotelViewController` is provided.
            animated: true,
            completion: completion
        )

This is exactly why configuration complains that their context do not match. Do you really need that chain? Let me read for you what this configuration means for router: "If room view controller with this room id doesnt exist then find a hotel view controller with this hotel id (this information is actually missing in context) and push into its navigation controller. But if such hotel view controller doesnt exist build it as well with this hotel id (but there is no hotelId in the context so you will fail for sure)". Given that description of your chain is it what you want? No, most likely what you want is so that your RoomViewController to be pushed into any UINavigationController that is currently visible. Because your user is already in some hotel if he is able to click on the button or cell that contains roomId you want to navigate to.

So what you actually want router to do is this:

    static var roomStep = StepAssembly(
        finder: ClassFinder(),
        factory: ClassFactory<RoomViewController, RoomId>())
        .using(UINavigationController.push())
        .from(GeneralStep.current().expectingContainer()) // No dependency on hotel id
        .assemble()

// User clicks on the room with id "1234"

    router.commitNavigation(
            to: Destination(to: roomStep, with: "1234"), // No hotel id is needed because we are happy just to push.
            animated: true,
            completion: completion
        )

Ok. Lets say your app is a tab bar application and fisrt tab is a HomeViewController and second tab bar is a UINavigationController. So if user is in the HomeViewController and he receives a push notification with the roomId and clicks on it - the configuration will not work because HomeViewController is current and it has no UINavigarionController to push. What you do in that case? You can write your configuration like:

    static var roomStep = StepAssembly(
        finder: ClassFinder(),
        factory: ClassFactory<RoomViewController, RoomId>())
        .using(UINavigationController.push())
        .from(GeneralStep.custom(using: ClassFinder<UINavigationController, RoomId>())) 
        .assemble()

// User clicks on the room with id "1234"

    router.commitNavigation(
            to: Destination(to: roomStep, with: "1234"), 
            animated: true,
            completion: completion
        )

Here you specify that router must find a UINavigationController not just expect it. So router will look for a UINavigationController and will find it in the second tab. Then router will make second tab visible for you and then push a RoomViewController into that UINavigationController. As simple as that.

So, In real life applications you usually really rarely need to use chains. But they are available for you if needed. But 95% of time you do not need them as you can see from the configurations above.

========

I am honestly having difficulty seeing the difference. First of all I do not understand why the second example requires expectingContainer() because if the Finder finds a UINavigationController then that is already a container VC

Sorry. my typo expectingContainer() is not needed there, Just a copy paste issue. I dont compile the example I give you. Ignore it.

So the difference is that the first example the router before pushing the SecondViewController will go and find the FirstViewController and if it is not visible (for example because it there are view controllers are pushed from it or it is in a different tab or covered by a modal view controller) then router will make it visible (pop to it in UINavigationController, switch the tab or close the modal view controller etc) and only then will push the SecondViewController into its UINavigationController.

The second example is just searching for any UINavigationController and pushes into it.

Understanding that difference is very important. Think about deeplinking. You can receive a push notification with some room/hotel/whatever id when user is anywhere on the screen (welcome screen, settings screen etc). Fine tuned configurations allow router to cover any such situation.

I also do not understand how .from(GeneralStep.custom(using: ClassFinder<FirstViewController, Any?>()).expectingContainer()) is different from just referencing firstStep

Because when you are just referencing the first step you are referencing its entire configuration and its dependencies. So, you tell the router that this step must be there. And if it must be there - then router must have enough information to be able to build the firstStep if it is absent. Thats why compiler complains that their context must be equal. For the reasons explained in the hotel/room example. As I said - 95% of the times you do not need chaining or you are chaining from the view controller that doesn have a context (Any? - so it can be build no matter that context other screen has).

Dont worry. It is not the first time someone asks such questions at the beginning. The approach above make you thing about your app differently and think about deeplinking and so on. It takes some times. But then I hope you'll be happy see how much job route-composer can do for you.

ekazaev commented 2 years ago

@bennnjamin

Please read this 2 answers as well. Hope they will give some clarification: 1 2

It is also important to remember this piece from documentation:

Context represents a payload that you need to pass to your UIViewController and something that distinguishes it from others. It is not a View Model or some kind of Presenter. It is the missing piece of information. If your view controller requires a productID to display its content, and the productID is a UUID, then the type of Context is the UUID. The internal logic belongs to the view controller. Context answers the questions What to I need to present a ProductViewController and Am I already presenting a ProductViewController for this product.

bennnjamin commented 2 years ago

Ok I am starting to understand a lot more. I thought that chains were the way to link between specific view controllers but it seems correct way to do that is with .from(GeneralStep.custom(...)).

I understand the part about the Context now too. But now I have more questions mostly regarding deep linking and deep navigation:

  1. In the first example, what happens during a deeplink if the Finder fails to find a FirstViewController. There is no Factory in this step to know how to build it if it doesn't exist. Like if the app was terminated and so the deep link tells the router to router.commit(to: secondStep). I am having trouble understanding how every step is connected together if I am not referencing the entire step directly.

  2. Imagine there is some tab bar, with a nav controller, with two view controllers pushed onto it (any of these view controllers may or may not exist) that we need to deep link to from anywhere in the app. There must be a way simple way to do this using a series of re-usable Steps without creating a whole bunch of specific Steps just for deep linking cases. I'm definitely getting lost in the cases of deeply navigating between parts of the app.

  3. You probably want when user clicks on the hotel in the hotel list - present a hotel view controller. And when user clicks on the room - present room view controllers. Those are not simultaneous actions and you dont need that information simultaneously. So having separate context is correct.

What about in the case the user receives a push notification and we need to deep link to the room by first presenting the Hotel (by ID). Then these are simultaneous. I saw in the Issue you linked to use addCase but that would mean every Step in an app would need a lot of cases to present itself from a deep link. I want to create all the Steps based on the Wireframe, and only allow static navigation. My navigation is essentially a tree, I don't need to support circular/cyclical/dynamic navigation cases. Could I reference all previous steps and using Any? for every context to achieve this? I do want a Step to have all of its previous Step's dependencies (Tab Bars, Nav Controllers, other View Controllers) built before it's presented as I believe that is how deep linking should work?

I believe these 3 above cases are all related and if I can figure out how to handle them then I should have a good grasp on how to use this library. Thanks for all of your help

bennnjamin commented 2 years ago

I should also add that the primary reason I was attempting to reference the previous Step is that I am trying to make a TabBarController similar to your Example project, and in order to navigate between tabs you have your Steps reference the Step that builds the TabBarController

ekazaev commented 2 years ago

@bennnjamin

Ill try to answer your questions if I understand them correctly:

1.

    secondStep = StepAssembly(
            finder: ClassFinder(),
            factory: ClassFactory<SecondViewController, Any?>())
            .using(UINavigationController.push())
            .from(GeneralStep.custom(using: ClassFinder<FirstViewController, Any?>()).expectingContainer())
            .assemble()

Of course here we are making an assumption that it exists somewhere in the current view controllers stack. Usually it is so. But if you know that you can be in the situation that it may not exist. Lets say user is on some welcome screen and you dont want to switch the entire app to tab bar until user finishes onboarding. You can use a SwitchStep to define actions ahat to do if the FirstViewController is not found. Or may be it is fine if navigation fails to deep-link while user hasn't finished onboarding. Or may be this step isn't even deep linkable and will be called only within the situations when FirstViewController exists for sure? Then why to care? If you will need to make SecondViewController out of any place you can always change the configuration later. What if FirstViewController view controller in general available only to payed users and instead of pushing SecondViewController you need to present a screen to offer user a subscription? That are all very different scenarios.

Ok, lets say you always want to build tab bar for your navigation. Then it most-likely has no context as it should be possible to build it with a big variety of contexts. Let say you also only want to push into the tab with UINavigationController that contains FirstViewController. So it may look like this:

(Please keep in mind - I dont compile this code, it is here to give you directions)

let tabFactory = CompleteFactoryAssembly(factory: TabBarControllerFactory<UITabBarController, Any?>())
        .with(CompleteFactoryAssembly(factory: NavigationControllerFactory<UINavigationController, Any?>())
            .with(ClassFactory<FirstViewController, Any?>())
            .assemble())
        .with(CompleteFactoryAssembly(factory: NavigationControllerFactory<UINavigationController, Any?>())
            .with(ClassFactory<SettingsViewController, Any?>())
            .assemble())
        )
        .assemble()

let tabBarStep = StepAssembly(
        finder: ClassFinder(),
        factory: tabFactory)
       )
            .using(GeneralAction.replaceRoot())
            .from(GeneralStep.root())
            .assemble()

let secondStep = StepAssembly(
            finder: ClassFinder(),
            factory: ClassFactory<SecondViewController, Any?>())
            .using(UINavigationController.push())
            .from(SingleStep(finder: ClassFinder<FirstViewController, Any?>(), factory: NilFactory).expectingContainer())
            .using(GeneralAction.nilAction())
            .from(tabBarStep)
            .assemble()
  1. I have the feeling that answer above answers this question as well. Feel free to ask again if not.

  2. Here if I understand correctly you want to push 2 view controllers simultaneously. That mean that your deeplink must contain both hotel id and room id. So that mean that both steps should share same context:

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

it is well enough described here

Do not forget, router no only builds such steps but also needs to check if they are already built. Especially cases like: User opened a deeplink in email and you just pushed both HotelViewController + RoomViewController. User tapped back returned to HotelViewController. Then user returned to the mail app and tapped the deeplink again. Should router build HotelViewController again? No if it is the same hotel and router should only build the RoomViewController and push it. As I said this entire thing is covered, but if you don't have the full understanding yet. Don't concentrate on it. It is an extremely rare case when you need such behaviour. Just trust me that its covered.

Overall there is nothing bad to have few configurations in case of deeplinking or normal configuration. All depends on your business tasks. I know apps that if you click on something push them into a navigation controller, but if the request to present such entity comes from deeplinking - they present them modally. So there is nothing wrong to have different configurations to present same view controllers for the different reasons.

4.

I should also add that the primary reason I was attempting to reference the previous Step is that I am trying to make a TabBarController similar to your Example project, and in order to navigate between tabs you have your Steps reference the Step that builds the TabBarController

I think it is more or less described in the first answer. But again, do you really need it? Do you have doubts that tab bar wont exist when deep-linking happens? Don't forget that the example app uses a lot of advanced configurations for demonstration and testing purposes. But as usual I can only guess your business tasks.

But Ill give you an example from one of my projects. Lets say user has access to different medical studies. each medical study is an entire flow with a tab bar. Lets say user is in the study with the id="1". It has no reason to put it into a context as it mean that you have to cary it to every step. It is your internal application state and it is not a context. But lets say user got a deeplink to a study with an id="2" I have a global interceptor there (see Example app about global interceptors). It checks id of the study before every navigation. And if it sees that new id doesnt match internal app state - it presents loading indicator, makes a networking call to check if user has access to the study with id="2" and if it does - it builds new tab bar using its internal router and only then tells that global router can continue navigation. And that process guarantees that tab bar will be always there and I dont need to mention it in the navigation steps. So there are so many ways to do same things, so it is better to be specific. Otherwise we are covering endless amoung of variants but you actually interested in one.

bennnjamin commented 2 years ago

Thanks for you answer, each time I understand a little bit more. You have some example code that I am a little unfamiliar with that I added the comments. Primarily why is it necessary to use SingleStep instead of GeneralStep.custom?

            finder: ClassFinder(),
            factory: ClassFactory<SecondViewController, Any?>())
            .using(UINavigationController.push())
            .from(SingleStep(finder: ClassFinder<FirstViewController, Any?>(), factory: NilFactory).expectingContainer()) //Why SingleStep here when I previously only used GeneralStep.custom(using: finder))?
            .using(GeneralAction.nilAction()) //I am assuming this NilAction is just to tell the router to build tabBarStep before sescondStep?
            .from(tabBarStep) //the actual chain the router must build before proceeding with secondStep
            .assemble()

I have worked on integrating your second example code and it seems to work well to switch between view controllers in a tab bar using the router. It's a good starting point so I will continue to build out more of the controllers and Steps after the tab bar.

As I said this entire thing is covered, but if you don't have the full understanding yet. Don't concentrate on it. It is an extremely rare case when you need such behaviour. Just trust me that its covered.

I think is the issue for me. I am getting stuck on some rare cases and trying to make sure they are covered. As I look at the required navigation of my app, I believe I will need to use some of these rare cases.

but if the request to present such entity comes from deeplinking - they present them modally

I was trying to avoid presenting them modally, and just build the correct view controller graph as if the user navigated to the view controller manually but I can see that maybe the modal approach is simpler.

But Ill give you an example from one of my projects. Lets say user has access to different medical studies. each medical study is an entire flow with a tab bar. Lets say user is in the study with the id="1". It has no reason to put it into a context as it mean that you have to cary it to every step. It is your internal application state and it is not a context. But lets say user got a deeplink to a study with an id="2" I have a global interceptor there (see Example app about global interceptors). It checks id of the study before every navigation. And if it sees that new id doesnt match internal app state - it presents loading indicator, makes a networking call to check if user has access to the study with id="2" and if it does - it builds new tab bar using its internal router and only then tells that global router can continue navigation. And that process guarantees that tab bar will be always there and I dont need to mention it in the navigation steps. So there are so many ways to do same things, so it is better to be specific. Otherwise we are covering endless amoung of variants but you actually interested in one.

This is very similar to my project where the tab bar must be rebuilt if the ID changes so thank you for this example. It's exactly the type of problem I am dealing with. Maybe we can talk directly about some of my specific use cases?

ekazaev commented 2 years ago

Primarily why is it necessary to use SingleStep instead of GeneralStep.custom?

Just because GeneralStep.custom allows you to provide only a Finder but not what to do in case it wasn't found.

.from(SingleStep(finder: ClassFinder<FirstViewController, Any?>(), factory: NilFactory).expectingContainer()) 
            .using(GeneralAction.nilAction()) //I am using NilAction that the view controler built by NilFactory doesnt need to be integrated anywhere.
            .from(tabBarStep) // Router will not go here if it finds `FirstViewController`. Why to build something if there is enough.

Here you basically say build the tab bar if its absent (FirstViewController will be built with the tab bar, right?). Then find FirstViewController and push SecondViewController into its UINavigationController. Don't forget that router reads navigation backward until it find the necessary view controller and then starts to go forward to build missing chain. Like on the gif I attached to the previous answer.

I think is the issue for me. I am getting stuck on some rare cases and trying to make sure they are covered. As I look at the required navigation of my app, I believe I will need to use some of these rare cases.

RouteComposer is fully disassmblable. In case your rare case is not covered you can always write your own absolutely custom Step and Action. But I dont think it will be needed.

I was trying to avoid presenting them modally, and just build the correct view controller graph as if the user navigated to the view controller manually but I can see that maybe the modal approach is simpler.

I am not trying to say that presenting modally is the right approach. But as many deep-links I saw - 99% of them works like "push into currently visible UINavigationController, if there is none - create one, push view controller there and present modally from the current one"

This is very similar to my project where the tab bar must be rebuilt if the ID changes so thank you for this example. It's exactly the type of problem I am dealing with. Maybe we can talk directly about some of my specific use cases?

Sure, but lets leave it for the next week. Write me to my email. Have a nice weekend.

bennnjamin commented 2 years ago

Sounds good, thanks for your help.