bluelinelabs / Conductor

A small, yet full-featured framework that allows building View-based Android applications
Apache License 2.0
3.9k stars 343 forks source link

[Question] Replacing one controller with another upon configuration change #636

Open K1rakishou opened 3 years ago

K1rakishou commented 3 years ago

I have a controller in which I want to insert another controller, conditionally. If the phone is in portrait layout I want to add Portrait controller, if in landscape or if an additional setting is turned on then Landscape controller. I'm doing it the following way:

    // onControllerCreated is basically onCreateView()
    override fun onControllerCreated(savedViewState: Bundle?) {
    super.onControllerCreated(savedViewState)

    // AndroidUtils.isSplitMode() is basically "orientation==LANDSCAPE" but it is also manually configurable
    val controller = if (AndroidUtils.isSplitMode(currentContext())) {
      SplitHomeController()
    } else {
      HomeController()
    }

    val transaction = RouterTransaction.with(controller)
      .tag(controller.getControllerTag().tag)

    getChildRouter(contentContainer).setRoot(transaction)
  }

I'm using the same view (contentContainer) for both controllers and also using RetainViewMode.RETAIN_DETACH for all controllers.

The issue I'm having with this approach is that upon config change the old HomeController is still in the stack, it's getting rebound and then is destroyed (after getChildRouter(contentContainer).setRoot(transaction)) which is expected. I'm getting the following log:

// Normal app start (the phone is in landscape orientation)
2020-12-04 14:46:19.995 D/BaseController: MainController onCreateView()
2020-12-04 14:46:20.005 D/BaseController: SplitHomeController onCreateView()
2020-12-04 14:46:20.216 D/BaseController: HomeController onCreateView()
2020-12-04 14:46:20.436 D/BaseController: SplitBrowseController onCreateView()
2020-12-04 14:46:20.556 D/BaseController: CatalogController onCreateView()
2020-12-04 14:46:20.624 D/BaseController: SplitThreadController onCreateView()
2020-12-04 14:46:20.679 D/BaseController: MainController onAttach()
2020-12-04 14:46:20.680 D/BaseController: SplitHomeController onAttach()
2020-12-04 14:46:20.680 D/BaseController: HomeController onAttach()
2020-12-04 14:46:20.680 D/BaseController: SplitBrowseController onAttach()
2020-12-04 14:46:20.680 D/BaseController: CatalogController onAttach()
2020-12-04 14:46:20.681 D/BaseController: SplitThreadController onAttach()

// The app start with the phone in portrait orientation and then is rotated (logs are taken when rotation occurs)
2020-12-04 14:46:35.528 D/BaseController: MainController onDetach()
2020-12-04 14:46:35.528 D/BaseController: HomeController onDetach()
2020-12-04 14:46:35.528 D/BaseController: SlideBrowseController onDetach()
2020-12-04 14:46:35.528 D/BaseController: CatalogController onDetach()
2020-12-04 14:46:35.528 D/BaseController: ThreadController onDetach()
2020-12-04 14:46:35.531 D/BaseController: MainController onDestroyView()
2020-12-04 14:46:35.531 D/BaseController: HomeController onDestroyView()
2020-12-04 14:46:35.532 D/BaseController: SlideBrowseController onDestroyView()
2020-12-04 14:46:35.532 D/BaseController: CatalogController onDestroyView()
2020-12-04 14:46:35.532 D/BaseController: ThreadController onDestroyView()
2020-12-04 14:46:35.574 D/BaseController: MainController onCreateView()
2020-12-04 14:46:35.614 D/BaseController: HomeController onCreateView()
2020-12-04 14:46:35.620 D/BaseController: SlideBrowseController onCreateView()
2020-12-04 14:46:35.626 D/BaseController: CatalogController onCreateView()
2020-12-04 14:46:35.632 D/BaseController: ThreadController onCreateView()
2020-12-04 14:46:35.670 D/BaseController: MainController onAttach()
2020-12-04 14:46:36.029 D/BaseController: SplitHomeController onCreateView()
2020-12-04 14:46:36.051 D/BaseController: HomeController onCreateView()
2020-12-04 14:46:36.055 D/BaseController: SplitBrowseController onCreateView()
2020-12-04 14:46:36.057 D/BaseController: CatalogController onCreateView()
2020-12-04 14:46:36.065 D/BaseController: SplitThreadController onCreateView()
2020-12-04 14:46:36.068 D/BaseController: HomeController onDestroyView()
2020-12-04 14:46:36.069 D/BaseController: SlideBrowseController onDestroyView()
2020-12-04 14:46:36.069 D/BaseController: CatalogController onDestroyView()
2020-12-04 14:46:36.069 D/BaseController: ThreadController onDestroyView()
2020-12-04 14:46:36.072 D/BaseController: SplitHomeController onAttach()
2020-12-04 14:46:36.072 D/BaseController: HomeController onAttach()
2020-12-04 14:46:36.072 D/BaseController: SplitBrowseController onAttach()
2020-12-04 14:46:36.072 D/BaseController: CatalogController onAttach()
2020-12-04 14:46:36.073 D/BaseController: SplitThreadController onAttach()

While everything seems to be working I wonder if there is an official way to handle this situation (without old controller getting recteated)? Maybe I can somehow remove this controller during the call Conductor.attachRouter(this, rootContainer, savedInstanceState) before setting the root controller onto the main router?

I saw the MasterDetailController demo and that it uses two containers when the phone is in landscape orientation and two child routers but I wonder whether it's possible to only have one container.

K1rakishou commented 3 years ago

A little update. I figured out that maybe I should use Router.setBackstack() method to replace previous backstack with a new one without the controller that I want to remove but now I'm getting NPEs because the "container" view is null since I'm trying to do this before calling rebindIfNeeded().

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    rootContainer = findViewById(R.id.root_container)
    router = attachRouterHacky(this, rootContainer, savedInstanceState)

    if (!router.hasRootController()) {
      val controller = MainController()
      controller.setControllerPresenterDelegate(this)

      router.setRoot(RouterTransaction.with(controller))
    }
  }

  private fun attachRouterHacky(
    activity: Activity,
    container: ViewGroup,
    savedInstanceState: Bundle?
  ): Router {
    BackgroundUtils.ensureMainThread()
    val isSplitMode = isSplitMode(activity)
    val router = LifecycleHandler.install(activity)
      .getRouter(container, savedInstanceState)

    if (savedInstanceState != null) {
      val controllerTag = if (isSplitMode) {
        SlideUiElementsController.CONTROLLER_TAG
      } else {
        SplitNavController.CONTROLLER_TAG
      }

      val result = router.findRouterWithControllerByTag(controllerTag)
      if (result != null) {
        val (foundRouter, foundController) = result
        val backstackCopy = foundRouter.backstack

        val index = backstackCopy.indexOfFirst { routerTransaction ->
          (routerTransaction.controller as? BaseController)?.getControllerTag() == controllerTag
        }

        if (index >= 0) {
          backstackCopy.removeAt(index)
          foundRouter.setBackstack(backstackCopy, null)
        }
      }
    }

    router.rebindIfNeeded()
    return router
  }
     Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'int android.view.ViewGroup.getChildCount()' on a null object reference
        at com.bluelinelabs.conductor.Router.removeAllExceptVisibleAndUnowned(Router.java:901)
        at com.bluelinelabs.conductor.Router.setBackstack(Router.java:409)
2020-12-10 16:44:24.715 E/KurobaEx:     at com.bluelinelabs.conductor.ControllerHostedRouter.setBackstack(ControllerHostedRouter.java:113)
        at com.github.k1rakishou.kurobanewnavstacktest.activity.MainActivity.attachRouterHacky(MainActivity.kt:119)
        at com.github.k1rakishou.kurobanewnavstacktest.activity.MainActivity.onCreate(MainActivity.kt:41)
        at android.app.Activity.performCreate(Activity.java:8000)
        at android.app.Activity.performCreate(Activity.java:7984)
        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1309)
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3422)
            ... 13 more

I wonder whether there is a way to make this work.

EricKuck commented 3 years ago

I suppose there is no ideal way to do what you're looking for. Your original attempt is the best bet given the currently available APIs. If you have a suggestion for an API improvement I would be open to considering it, but I'm not sure of a good way to facilitate something like this.

K1rakishou commented 3 years ago

I actually managed to find a hacky solution: originally I was trying to replace a child controller in the backstack (SlideUiElementsController and SplitNavController are children of MainController) and it didn't work because at that stage those child controllers do not have the container view (and it crashes), however this works when replacing MainController which is the root in my case. The state of all controllers is lost but that is expected and not a problems in my case since I store everything in ViewModels.

If you have a suggestion for an API improvement I would be open to considering it

I guess the current API is fine, but having an ability to remove/replace child controller somewhere deep in the backstack before router.rebindIfNeeded() is called would be nice, right now it simply crashes.