Blog Infos
Author
Published
Topics
, ,
Published

This is an opinionated article on how I think navigation should be handled in multi-module single-activity projects. Also, this is not “yet another tutorial on Navigation Component”. In fact, I’m not going to use Navigation Component at all but will demonstrate the approach by using our good old FragmentManager .

The main problem with the Navigation Component IMO is that its logic cannot be abstracted away easily, there are graphs, ids for the destination, NavActions with some vile way on how to pass some arguments. Your navigation is represented in XML rather than in code you right (Java/Kotlin). There are DSLs that allow you to write NavGraph in code but who uses that :p. Luckily by using the “safe-args” gradle plugin we can solve the long-awaited problem of not being able to pass parcelables as arguments and talking about “safe args”, there is a lot of magic involved when using the “safe-args” gradle plugin & the code it generates for deeplinks (mainly by manifest merging) & <argument/> which sometimes becomes hard to reason about.

Despite being these issues (& a lot more), I think for any beginner in Android development he/she should go with the Navigation Component as their first choice for navigation library since it offers a lot more simplicity & hides most of the implementation details of FragmentManager methods (with a simple navigateTo call) visually (using Navigation Graph Editor in Studio) & programmatically. Later on, they can then decide if they should migrate to another navigation library to solve some problems that can’t be solved through Navigation Component or maybe want to reduce coupling between the navigation logic. Likewise, the problem of multi-module navigation is already solved by Navigation Component whether by using deeplinks (I wouldn’t recommend it since it’s not typesafe & could cause issues). There is a great video on the “Android Developers” YouTube channel which covers this topic. You can see how simple the approach is when using the Navigation Component to solve multi-module navigation but then why shouldn’t we use it?

In Android, FragmentManager is the first-class citizens API to handle navigation with fragments & is much flexible to use. You have more control over your navigation, backstack, animation, etc.

There are a lot of third-party navigation libraries for Android (simple-stackFragNavnavigatorAlligator, name a few) which offers similar or maybe more functionality but the one thing they have in common is “go-to screen”, “go back” & such functionalities similar to FragmentManager due to which their logic can be abstracted away so in the future if you decide to change your navigation library it shouldn’t be that burdensome.

Also, when migrating your app to pure Jetpack Compose (no fragments at all) such library or pure FragmentManager approach is far better than the “Navigation Component” one to ensure a somewhat smooth migration.

So how are we going to solve the problem of multi-module navigation if the idea is to have an abstracted navigation logic & to also have slower build-times (that’s why we do modularization right :p)? There is an article by

 “Structural and navigation anti-patterns in multi-module and modularized applications: The case against “Android Clean Architecture” and the “domain” wherein one of the sections (anti-pattern #3) he outlined the theoretical approach on how to do it. Fortunately a sample by

 in the article “Multi-module navigation with Dagger2” shows how to do it. I tried to implement this solution by having one nested navigation in one of the child modules & had pretty good success with it. If you want to see a similar approach check the dagger branch on my sample repository which does the same thing including a solution for carrying out nested navigation in one of the child modules. For any future references, I’m going to talk or take some excerpts from this repository itself.

As you noticed we are going to use a DI framework for solving multi-module navigation. In the sample, I tried two approaches one with Dagger 2 & the other with Hilt (goes by many names like “The opinionated Dagger framework”). DI frameworks help us to provide an implementation for an interface when requested which is crucial for navigating between modules.

As mentioned in

’s article the approach with DI is similar to what we would do if we were to use FQNs instead where there is no guarantee whether classes exist at runtime (similar to how multi-module navigation is handled using deeplinks using Navigation Component). With DI especially the ones which provide compile-time safety, we can ensure if our code breaks our build will also break (plus refactoring becomes simple) which is why I think Dagger or Hilt is the best-suited tool for our use case (sorry Koin). But this does not mean you cannot use other DI frameworks, you definitely can but you lose type-safety & some build overhead (through kapt if using Kotlin, although I heard Dagger is getting a KSP version, no ETA yet).

Anyway, I like a system that will nag me if I do something wrong during compile time rather than crashing on random devices during production just because one binding in module {}doesn’t resolve at runtime. Having said this,

As I said we are going to use Dagger! But Dagger is complex not because of the difficulty but its verbosity. Since setting it up requires a lot of boilerplate code it may not be ideal especially when injecting in Android specific components like ViewModel, WorkManager where you requires solutions like @AssistedInject . Luckily injecting with Hilt into these components is fairly simple, so what we want is the power of Hilt with a little bit of Dagger magic (in terms of Hilt).

Let’s look at the app’s module architecture,

The architecture follows a single-activity pattern where each child module contains one or more Fragments & we are navigating to them using Dagger/Hilt. For eg: Navigation from “WelcomeFragment” to “HomeFragment” i.e from module :welcome to :home is not happening directly since :welcome module doesn’t know anything about the :home module.

As :welcome & :home are the child modules of :app , it knows every public class & method exposed by them. So what we do here is,

  • Expose an interface in:welcome module which contains a method called goToNext().
// declared in :welcome module
interface WelcomeButtonClick {
fun goToNext()
}
  • Implement this interface in :app module where we can then provide an actual logic on how to navigate to the “HomeFragment”.
// implement the interface in the :app module
class WelcomeButtonClickImpl @Inject constructor(
private val activity: FragmentActivity /* Provides the nearest fragment activity */
) : WelcomeButtonClick {
override fun goToNext() {
activity.supportFragmentManager.beginTransaction()
.addToBackStack("home")
.replace(R.id.frag_container, HomeFragment::class.java, null)
.commit()
}
}
  • Bind the interface with our implementation in the :app module with the help of a DI framework. Note, we need to scope the module to the ActivityComponent . In hilt, it is straightforward by using @InstalIn annotation but in Dagger, we need to create our custom Activity subcomponent (all shown in the “dagger” branch of the sample).
// in the :app module
@Module
@InstallIn(ActivityComponent::class)
abstract class ActivityModule {
@Binds
abstract fun provideWelcomeButtonClick(welcomeButtonClick: WelcomeButtonClickImpl) : WelcomeButtonClick
}
  • Now for the navigation part, you need to @Inject “WelcomeButtonClick” at the site (in :welcome module) where you want to navigate to the “HomeFragment”.
// in :welcome module
@AndroidEntryPoint
class WelcomeFragment : Fragment(R.layout.fragment_welcome) {
@Inject lateinit var welcomeButtonClick: WelcomeButtonClick // <-- Injected (provides WelcomeButtonClickImpl)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
val button = view.findViewById<Button>(R.id.btn)
button.setOnClickListener {
welcomeButtonClick.goToNext() // <-- Navigating to "HomeFragment"
}
}
}

Here, Dagger (Hilt) provides an instance of “WelcomeButtonClickImpl” whenever we request “WelcomeButtonClick”. We have then used it in the button’s OnClick listener. This will properly navigate the current destination to “HomeFragment” whenever that button is clicked. “HomeFragment” here is nothing but aFragmentContainerViewwhose default destination is “HomeStartFragment”.

Some of you might ask me, how did we get an instance of Activity in “WelcomeButtonClickedImpl”? Well this is Dagger helping us, all ActivityComponent have a reference to its Activity (@BindInstance) and is available through the component/subcomponent hierarchy so we can reference it anytime we want which is what we did in the “WelcomeButtonClickedImpl”.

This solution works for top-level navigation, the real problem arises when you have nested navigation in one of the child modules.

 

 

If you look at the diagram again, we have a nested-navigation in :home module which depends on two other modules :home-internal & :home-internal2 containing Fragments to navigate. What happens here, :home module now acts as:app module managing navigation for its child modules.

In :home module “HomeStartFragment” can directly navigate to “HomeInternalFragment” which is defined in :home-internal as it is one of the child modules so it definitely knows about it.

The problem is navigating from :home-internal to :home-internal2! Surely we can reuse the same solution mentioned above which we have implemented for navigating between:welcome &:home right? Let’s try & see what happens,

// Navigate from :home-internal module's HomeInternalFragment to :home-internal2 module's HomeInternal2Fragment
// in :home-internal module
interface HomeInternalButtonClicked {
fun goToNext()
}
// implement the interface in :home module
// this implementation is incorrect & will break, read further to know more.
class HomeInternalButtonClickedImpl @Inject constructor(
private val fragment: HomeFragment, // <-- We need HomeFragment since we need access to the childFragmentManager.
) : HomeInternalButtonClicked {
override fun goToNext() {
fragment.childFragmentManager.beginTransaction()
.addToBackStack("home-internal2")
.replace(R.id.frag_container, HomeInternal2Fragment::class.java, null)
.commit()
}
}
// in :home module
@Module
@InstallIn(FragmentComponent::class) // <-- Scope to fragments
abstract class HomeModule {
@Binds
abstract fun provideHomeInternalButtonClick(homeButtonClicked: HomeInternalButtonClickedImpl) : HomeInternalButtonClicked
}
view raw HomeModule.kt hosted with ❤ by GitHub
// in :home-internal module,
// use the interface on the site where required
@AndroidEntryPoint
class HomeInternalFragment : Fragment(R.layout.fragment_home_internal) {
@Inject lateinit var homeInternalButtonClicked: HomeInternalButtonClicked // <-- Provide our `HomeInternalButtonClickedImpl`
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
val btnGoto = view.findViewById<Button>(R.id.btn_goto)
btnGoto.setOnClickListener {
homeInternalButtonClicked.goToNext() // <-- Navigate to `HomeInternal2Fragment` of :home-internal2 module
}
...
}
}

We’ve done exactly like previous, exposing interface from child module, implementing it in the parent module & using it in the child module where navigation is required. This should work, right?

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Navigation superpowers at your fingertips

This talk will begin by the demonstration of a beautiful sample app built with Compose Mulitplatform and Appyx, complete with:
Watch Video

Navigation superpowers at your fingertips

Zsolt Kocsi
Principal Android engineer
Bumble

Navigation superpowers at your fingertips

Zsolt Kocsi
Principal Android en ...
Bumble

Navigation superpowers at your fingertips

Zsolt Kocsi
Principal Android enginee ...
Bumble

Jobs

Well it does not, in fact, your app will crash especially in “HomeButtonClickedImpl” at line 7 (above) saying that this fragment is not attached to any container. What does this mean? Well what happened in “HomeButtonClickedImpl” is we have injected “HomeFragment” thinking that it will provide us the same instance of “HomeFragment” which @AndroidEntryPoint i.e hilt has created for us but rather it is creating & providing a new instance of the “HomeFragment” which is not present in our navigation backstack hence the crash. BTW you can test this crash from the sample in the hilt-crash branch.

This crash did not happen when Dagger was used because there we’ve created a multi-map binding to create and instantiate (while constructor inject) Fragments using our own custom FragmentFactory called DaggerFragmentFactory . The problem when using hilt (along with its gradle plugin) is injection is completely handled by itself, we’ve no control over the individual component or subcomponent in sense. Somehow we’ve to provide the same instance of “HomeFragment” which is currently on backstack. But can we do that?

If you want to check the solution that fixed this issue make sure to check out the hilt branch from the sample or diff the branches.

git difftool hilt-crash hilt

Edit: The logic is updated to fix crashes due to configuration change & process death as pointed by Gabor Varadi here.

What we want to do is to provide the same instance of “HomeFragment” every time any navigation logic requests i.e in one of the child modules of :home. For that, we define a qualifier to restrict the injection of HomeFragment to navigation purposes only.

// in :home module
@Qualifier
annotation class HomeQualifier
// in :home module
@Module
@InstallIn(FragmentComponent::class)
class HomeDependencyModule {
...
@Provides @HomeQualifier
fun homeFragment(fragment: Fragment) : HomeFragment {
return fragment.requireActivity().supportFragmentManager.fragments.find { it is HomeFragment } as HomeFragment
}
}

And by doing that all we’ve to change in “HomeInternalButtonClickedImpl” is to annotate homeFragment parameter with @HomeQualiferwhich will provide us the required instance of “HomeFragment” from the backstack instead of creating a new one.

// modify the existing one from the :home module
class HomeInternalButtonClickedImpl @Inject constructor(
// @HomeQualifier will correctly route this to our provider defined in `HomeDependencyModule` to provide
// the required instance of HomeFragment from Activity's backstack.
@HomeQualifier private val fragment: HomeFragment,
) : HomeInternalButtonClicked {
override fun goToNext() {
fragment.childFragmentManager.beginTransaction()
.addToBackStack("home-internal2")
.replace(R.id.frag_container, HomeInternal2Fragment::class.java, null)
.commit()
}
}

This way we’ve fixed our crash (you can check it out in the hilt branch of the sample) & everything will work perfectly.

So the real logic is defined in HomeDependencyModule where we’ve installed a provider scoped to FragmentComponent that looks up the HomeFragment in the Activity’s backstack & returns it. It will work across configuration change & process death but will break if Activity has multiple instances of HomeFragment . To solve this problem we can use tags to identify the required fragment from the backstack (findFragmentByTag). Also, we’ve restricted the injection of HomeFragment by adding @HomeQualifier , this will ensure that we cannot just inject HomeFragment anywhere we want & should only be used for navigation purposes only.

The solution may not be correct in fact many of you might consider it as a hack so if anyone knows a better solution other than this please do share with us.

One other solution that comes to my mind is in “HomeInternalButtonClickedImpl” instead of requesting “HomeFragment” we should request just Fragment which if I’m not wrong should provide the current instance of the fragment (since the injection is scoped to FragmentComponent) & then using parentFragmentManager property we can commit a new fragment transaction. But this solution assumes that there must & should be a parent fragment which I think will always be true. I don’t know if this approach is correct or even better than what I suggested above. If you have any suggestions let me know!

Also, the sample could be improved a lot more especially in structuring the code & logic but my main idea was to demonstrate this use case with DI.

I’ve not talked anything about Jetpack Compose yet but I know for sure that this problem can be easily solved. In fact, if you had looked into the tivi sample, each screen is modularized into a new gradle module & is wired up in the app module where each destination is added to the navigation graph (using “Navigation Component for Compose”). So if any screen wants to navigate to another screen (which it does not know about) instead of injecting an interface we just have to pass a lambda to do so, since destinations in Compose are nothing but a function we can always add an argument to the function & invoke it when required. So far this seems a good way to approach modularization & handle navigation in a pure Jetpack Compose app without using any DI. But who knows? It’s still too early to make any assumptions. For now, stick to Fragment + Compose approach which is the safest bet I can see.

Finally, I’m closing by linking the sample that was used to demonstrate everything here. Also, thanks to Gabor Varadi for answering my questions over Twitter DM.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu