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

How to change root view controller using LoginInterceptor and then continue navigation #82

Closed tadelv closed 2 years ago

tadelv commented 2 years ago

Hi!

I am trying to understand how to compose the navigation for the following case:

I've tried to navigate to home in the interceptor, but the subsequent navigation to destination fails, because landing was used for origin view controller.

Any ideas?

P.S.: I'm trying to do this in the example app with the ColorViewController. Here is the diff:

index 12d2bee8..c42e0b3f 100644
--- a/Example/RouteComposer/Configuration/ExampleConfiguration.swift
+++ b/Example/RouteComposer/Configuration/ExampleConfiguration.swift
@@ -78,6 +78,7 @@ extension ExampleScreenConfiguration {
         StepAssembly(
             finder: ColorViewControllerFinder(),
             factory: ColorViewControllerFactory())
+            .adding(LoginInterceptor<String>())
             .adding(DismissalMethodProvidingContextTask(dismissalBlock: { context, animated, completion in
                 // Demonstrates ability to provide a dismissal method in the configuration using `DismissalMethodProvidingContextTask`
                 UIViewController.router.commitNavigation(to: GeneralStep.custom(using: PresentingFinder()), with: context, animated: animated, completion: completion)
index 97e20638..0a3dff78 100644
--- a/Example/RouteComposer/SceneDelegate.swift
+++ b/Example/RouteComposer/SceneDelegate.swift
@@ -18,6 +18,17 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
     func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
         ConfigurationHolder.configuration = ExampleConfiguration()

+      guard let windowScene = (scene as? UIWindowScene) else { return }
+
+      /// 2. Create a new UIWindow using the windowScene constructor which takes in a window scene.
+      let window = UIWindow(windowScene: windowScene)
+
+      let storyboard = UIStoryboard(name: "PromptScreen", bundle: nil)
+      let controller = storyboard.instantiateInitialViewController()
+
+      window.rootViewController = controller
+      self.window = window
+      
         // Try in mobile Safari to test the deep linking to the app:
         // Try it when you are on any screen in the app to check that you will always land where you have to be
         // depending on the configuration provided.
@@ -26,8 +37,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
         // dll://products?product=01
         // dll://cities?city=01
         ExampleUniversalLinksManager.configure()
+
+      window.makeKeyAndVisible()
tadelv commented 2 years ago

I forgot to add that i would like the destination to open from either the circle view controller or from anywhere the user is currently on (and logged in)

ekazaev commented 2 years ago

@tadelv

Thank you for the question. I am not able to reply right now as I am not by the computer. Ill try to answer your question asap.

tadelv commented 2 years ago

Thank you, There is no rush. I am just getting to know this framework, so it might be I am trying to tackle this problem from the wrong angle. Let me know if you need any additional clarification.

On Thu, 2 Jun 2022 at 17:16, Eugene Kazaev @.***> wrote:

@tadelv https://github.com/tadelv

Thank you for the question. I am not able to reply right now as I am not by the computer. Ill try to answer your question asap.

— Reply to this email directly, view it on GitHub https://github.com/ekazaev/route-composer/issues/82#issuecomment-1144985742, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAAK35N7ECWIBQLZYPPOBPLVNDF4XANCNFSM5XVDYT5A . You are receiving this because you were mentioned.Message ID: @.***>

tadelv commented 2 years ago

@ekazaev I think I have found a way to do it. Find the diff below. Is this the right way to go about it? I am using a SwitchAssembly to determine the source controller based on the login status of the user.

index 12d2bee8..51e6887a 100644
--- a/Example/RouteComposer/Configuration/ExampleConfiguration.swift
+++ b/Example/RouteComposer/Configuration/ExampleConfiguration.swift
@@ -78,6 +78,7 @@ extension ExampleScreenConfiguration {
         StepAssembly(
             finder: ColorViewControllerFinder(),
             factory: ColorViewControllerFactory())
+            .adding(LoginInterceptor<String>())
             .adding(DismissalMethodProvidingContextTask(dismissalBlock: { context, animated, completion in
                 // Demonstrates ability to provide a dismissal method in the configuration using `DismissalMethodProvidingContextTask`
                 UIViewController.router.commitNavigation(to: GeneralStep.custom(using: PresentingFinder()), with: context, animated: animated, completion: completion)
@@ -86,7 +87,16 @@ extension ExampleScreenConfiguration {
             .using(ExampleNavigationController.push())
             .from(SingleContainerStep(finder: NilFinder(), factory: NavigationControllerFactory<ExampleNavigationController, String>()))
             .using(GeneralAction.presentModally())
-            .from(GeneralStep.current())
+            .from(SwitchAssembly<UIViewController, String>()
+              .addCase({ _ in
+                guard isLoggedIn == false else {
+                  return nil
+                }
+                return circleScreen.unsafelyRewrapped()
+              })
+                .assemble(default: {
+                  GeneralStep.current()
+                }))
             .assemble()
     }
tadelv commented 2 years ago

Hey @ekazaev, just pinging, are you still away from the computer? :)

ekazaev commented 2 years ago

Hey. Sorry. I am on vacation. Will be back tomorrow 🥹

tadelv commented 2 years ago

Ooops! Sorry for bothering you then 🙈

On Jun 13, 2022, at 6:04 PM, Eugene Kazaev @.***> wrote:

Hey. Sorry. I am on vacation. Will be back tomorrow 🥹

— Reply to this email directly, view it on GitHub https://github.com/ekazaev/route-composer/issues/82#issuecomment-1154105324, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAAK35PHM47ZFDZPMPHDAPTVO5LXZANCNFSM5XVDYT5A. You are receiving this because you were mentioned.

ekazaev commented 2 years ago

@tadelv Sorry for the delay. If I understood your question correctly here you hit a limitation of the logic. It first searches for the view controller to start as configuration may be incorrect or you can be already on the screen that requires login (and if you are already there it is pointless to show login right?), then if it found the appropriate view controller to start it runs all the gloably assigned interceptors and then individual interceptors. And continues to build the leftover route after all the Interceptors succeded.

The problem here is that you can't change what router considers as a partialy build stack as correct from the interceptor. For example, if you are on HomePage as origin, you want to navigate somewhere but in the Interceptor you want to change the origin as well. I did not implement it as it will make the router logic to complex, hard to explain and hard to debug. But it is also a rare case as for the intermediate login full screen modal is usually used and then navigation continues.

But if it is necessary, the GlobalInterceptorRouter wrapper can help you. You can add your interceptor to it and it will run an interceptror before the main router starts to work. Then only thing is you can not parse the configuration from that wrapper so youll have to mark all the contexts that require to have a login somehow. For example with an empty protocol LoginRequiring.

Then you can write your global interceptor like

    func perform(with context: Any?, completion: @escaping (_: RoutingResult) -> Void) {
        guard let loginRequiringContext = context as? LoginRequiring else {
            completion(.success)
            return
        }
        // Otherwise check if user is logged in and if not - do whatever you want with the stack as the MainRouter hasnt started to work yet

Or viceversa if most of the screens require login and only a few dont, mark them with a protocol NotRequiringLogin and update your GlobalLoginInterceptor accordingly.


Another way (complex as it is not provided by the library and requires coding) is to make your own wrapper to the Router as it has only one method.

func navigate<ViewController: UIViewController, Context>(to step: DestinationStep<ViewController, Context>,
                                                             with context: Context,
                                                             animated: Bool,
                                                             completion: ((_: RoutingResult) -> Void)?) throws

In this method you can save the configuration that was passed there. Run the configuration through the DefaultRouter, throw the your own Error from the LoginInterceptor in case user is not logged in. That Error you can recognise in your wrapper, present user login screen from the wrapper, and if user successfully logins there within the wrapper change the view controllers stack and then run the saved configuration through the DefaultRouter again.


Another way is to make your configuration like you wrote it, but i think it may limit or make complex your configurations in the future. I would go with one of the solutions above.

Hope I understood your question correctly. Please let me know.

tadelv commented 2 years ago

Hey @ekazaev thanks for taking the time to write this exhaustive answer.

Let me see if I got it right: The global interceptors run before router decides which will be the originating (root) view controller? So this means, they can change the view controller hierarchy before origin VC is selected? I assume this will definitely be cleaner than the current solution. But on the other hand, I could package the SwitchAssembly into a property of the configuration and just use it where i need it - though it will still introduce complexity which would be abstracted away by the global interceptor. The app I'm planning to build using route-composer has most of the screens behind login, but some of them require a specific navigation stack to be built and some can be presented on any current view controller visible (when authenticated), i.e. a certain detail view will require to have a list view in the stack, but a certain other detail view will be presented modally anywhere, requiring only that the user is logged in (and the correct rootVC is there). The app is divided into two states (which have their own rootVCs), logged in and not logged in. In normal operation, the user starts with the not-logged-in state and after authenticating, the rootVC is changed (let's say to a tab bar view controller).

Hope it makes sense. Let me know please if my assumptions regarding global interceptor are correct

tadelv commented 2 years ago

Just a quick update - I was able to get it to work the way you proposed, using a GlobalInterceptor and replacing the root view controller in there.


index 31ddbf30..dda355c3 100644
--- a/Example/RouteComposer/Extensions/ViewController.swift
+++ b/Example/RouteComposer/Extensions/ViewController.swift
@@ -11,6 +11,8 @@ import os.log
 import RouteComposer
 import UIKit

+protocol RequiresLogin {}
+
 extension UIViewController {

     // This class is needed just for the test purposes
@@ -29,8 +31,53 @@ extension UIViewController {
         }
     }

+
+
+  private final class GlobalLoginInterceptor<C>: RoutingInterceptor {
+    typealias Context = C
+
+    func perform(with context: Context, completion: @escaping (RoutingResult) -> Void) {
+      guard context is RequiresLogin else {
+        completion(.success)
+        return
+      }
+      guard isLoggedIn == false else {
+        completion(.success)
+        return
+      }
+      let destination = LoginConfiguration.login()
+      do {
+        try UIViewController.router.navigate(to: destination) { routingResult in
+          guard routingResult.isSuccessful,
+                let viewController = ClassFinder<LoginViewController, Any?>().getViewController() else {
+            completion(.failure(RoutingError.compositionFailed(.init("LoginViewController was not found."))))
+            return
+          }
+
+
+          viewController.interceptorCompletionBlock = { result in
+            guard case .success = result else {
+              completion(result)
+              return
+            }
+            do {
+              try viewController.router.navigate(to: ExampleConfiguration().homeScreen, animated: false) { result in
+                completion(result)
+              }
+            } catch {
+              completion(.failure(error))
+            }
+          }
+        }
+      } catch {
+        completion(.failure(RoutingError.compositionFailed(.init("Could not present login view controller", underlyingError: error))))
+      }
+    }
+  }
+
     static let router: Router = {
         var defaultRouter = GlobalInterceptorRouter(router: FailingRouter(router: DefaultRouter()))
+      defaultRouter.addGlobal(GlobalLoginInterceptor<Any?>())
         defaultRouter.addGlobal(TestInterceptor("Global interceptors start"))
         defaultRouter.addGlobal(NavigationDelayingInterceptor(strategy: .wait))
         defaultRouter.add(TestInterceptor("Router interceptors start"))```
ekazaev commented 2 years ago

@tadelv are you happy with the result? I am really sorry for the late replies. I have nine weddings to attend this year so my availability is terrible 🥹

tadelv commented 2 years ago

Hey @ekazaev, I think I have what I need. I was only researching the approaches in the example app in this repo. When I start using route-composer in the production app, I will decide whether to use the configuration approach or the GlobalInterceptor. I think it will depend whether I will have many configurations and whether I can provide different contexts for the interceptor. But for now, my questions have been answered, thanks a lot!

Good luck with all the weddings 😆

ekazaev commented 2 years ago

@tadelv Thank you. Ill close this issue then. Dont hesitate either to reopen it or to create a new one