< Back to articles

Navigation in Multi-Module Project

Over the last few years Android Developers have established best practises for writing apps: clean architecture, usage of MVVM as primary architectural pattern.

The most discussed topic of 2019 was modularisation. While it brought us a lot of benefits, I’ve discovered some pitfalls, one of them being in-app navigation.

Background

In one of the recent projects I was tasked to integrate Navigation Component (NC) into existing modularised codebase. At Ackee, we have adopted a concept of logical layers for modules, as described in this amazing post.

While NC has solved a lot of issues for us, I was surprised by absence of support for multi-module projects. Shortly speaking, there was no easy and straightforward way how to navigate from one feature module to another, meaning I had to deal with it myself and from here on I saw 2 options:

  1. have a shared :navigation module which would provide contracts for cross-feature navigation. Each feature would be dependant on :navigation, contracts and in-feature navigation would be implemented within feature modules
  2. fully navigation aware :navigation module which would be responsible for all in-app navigation. Each feature would define contracts, these would be implemented in :navigation and provided via DI.

The 1st option just didn’t seem right to me as it makes navigation between features quite hacky. As a result, NC would be used only for in-feature navigation. In order to navigate to screen from another feature module we would have to introduce some abstraction, thus mixing NC and custom components for cross-feature navigation.

The 2nd approach seemed better to me. The great benefit of it is full abstraction of navigation logic. 

First of all, single feature does not have to have any knowledge of how navigation is actually implemented. Imagine having major NC release which would completely change library API or imagine ditching NC and replacing it with something different. ????‍♂ 

Secondly, whole feature or particular screen can be presented from different contexts, as there might be multiple ways how user can end up on particular screen in the app. Same way navigating from same screen might lead to different destinations depending on context/current user position in the app.

To sum up, what I want to achieve at this point:

  • features are completely unaware of navigation implementation details
  • each screen in feature is only aware of its own destinations (no knowledge of context where particular screen is presented)

Solution

After making couple of iOS projects on my own and occasionally helping on smaller iOS projects at company I was aware of Coordinator pattern which is quite popular in iOS development.

The idea behind it is to delegate navigation decision logic to another abstraction, so that next goals are achieved:

  • we avoid God Activity/NavGraph which does all the navigation
  • we are able to reuse existing screens (Fragments) in different navigation contexts 

In iOS development it is also commonly used for variety of other things: sort of service location, initialising scoped components, etc. 

On Android however, the story is not that simple and might be tricky to implement due to nature of how Activities/Fragments are initialised/restored by system. Here, we will be using it solely to abstract navigation. Our abstraction would be calledCoordinator and described in few words: it knows where to go next.

Before moving to an example, let’s make sure we are clear on the naming. Our Coordinator is an abstraction we will delegate navigation to. We will also split navigation into navigation flows, which would contain multiple screens, thus you will seeFlowCoordinator name o lot, rather then Coordinator.

Example

Imagine we are developing a small app for a client who had plans to travel in 2020 and these plans are now ruined by ????. 

Our app would consist of:

  • Map overview screens with events (conference/vacation/concert/etc.) and places (random interesting places to visit near events). (:map feature)
  • Event list screen with events grouped by category (:events feature)
  • Event detail screen with description and list of similar events. (:events feature)

Map and events feature as part of navigation in multi-module project

We will split the app into 2 navigation flows, both of which would be backed by their own Activity:

  • Home flow (navigates to event detail from map and list)
  • Event flow (navigates to similar event detail, opens external app for navigation, opens chrome tab with event website)

As it was mentioned above, our main goal would be to delegate navigation logic. As a first step, we will define our delegates for all our screens:

/**

 * Fragment for screen with [Event] & POI map.

 */

class EventMapFragment : ViewBindingFragment<FragmentMapBinding>() {

    interface NavigationDelegate {

        fun openDetail(eventId: String)

    }

    // ...

}

/**

 * Fragment for screen with list of events.

 */

class EventListFragment : ViewBindingFragment<FragmentEventListBinding>() {

    interface NavigationDelegate {

        fun openDetail(eventId: String)

    }

    // ...

}

/**

 * Fragment for screen with [Event] details.

 */

class EventDetailFragment : ViewBindingFragment<FragmentEventDetailBinding>() {

    interface NavigationDelegate : BaseActionDelegate {

        fun openLink(url: String)

        fun navigate(coordinate: Coordinate)

        fun openSimilar(eventId: String)

    }

    // ...

}

Now, that we have all navigation requirements for our screens, we can proceed with actual implementation of Coordinator.

Navigation Component is cool, but, ideally, we would delegate all of the logic that takes user to some other screen or opens external apps. This would require Coordinator to hold a reference to Activity Context. This means there might be something all coordinators will share, so we define our “base” coordinator like so:

/**

 * Base Coordinator.

 */

interface FlowCoordinator : DefaultLifecycleObserver {

    var activity: FlowActivity?

    var navigationController: NavController?

    override fun onDestroy(owner: LifecycleOwner) {

        activity = null

        navigationController = null

    }

}

Our first Coordinator (component we are delegating navigation to) will then look like this:

interface HomeFlowCoordinator : FlowCoordinator, EventMapFragment.NavigationDelegate, EventListFragment.NavigationDelegate

internal class HomeFlowCoordinatorImpl : HomeFlowCoordinator {

    override var activity: FlowActivity? = null

    override var navigationController: NavController? = null

    // single navigation action from home flow, opens event detail

    override fun openDetail(eventId: String) {

        startFlow<EventFlowActivity>(bundle = EventFlowActivity.arguments(eventId))

    }

}

// each flow is considered to be backed by Activity, we use this extension to start new flows.

inline fun <reified F : FlowActivity> FlowCoordinator.startFlow(bundle: Bundle = bundleOf()) {

    activity?.let { it.startActivity(Intent(it, F::class.java).putExtras(bundle)) }

}

Here comes the trickiest part. When should be HomeFlowCoordinator set up and how to provide delegates to fragments?

Due to potential process death issues we can’t assign delegates directly. In addition to that, imagine a complicated navigation graph with a navigation loop (product detail -> products in same category -> product detail -> …) in that case we would like to have FlowCoordinator to be bound to its own context holder (Activity). This means that single FlowCoordinator lives within scope of a dedicated activity.

To provide delegates to fragments we will make use of Dependency Injection and scoped components. For this example, I have chosen Koin, since scope usage is much more straightforward.

We begin with defining a base Activity, which requires subclasses to provide a scope:

/**

 * Base Activity for each app flow.

 */

abstract class FlowActivity : AppCompatActivity() {

    abstract val flowScope: Scope

    override fun onDestroy() {

        super.onDestroy()

        if (!isChangingConfigurations && !flowScope.closed) {

            flowScope.close()

        }

    }

}

In HomeFlowActivity we than inject concrete implementation of coordinator and set up it’s properties, as well as logic for changing current NavigationController from Jetpack Navigation Component:

/**

 * Activity for home flow with bottom navigation.

 */

class HomeFlowActivity : FlowActivity() {

    private lateinit var coordinator: HomeFlowCoordinatorImpl

    override val flowScope: Scope

        get() = getKoin().getOrCreateScope(SCOPE_NAME, named(SCOPE_NAME))

    override fun onCreate(savedInstanceState: Bundle?) {

        super.onCreate(savedInstanceState)

        coordinator = flowScope.get()

        coordinator.activity = this // Assign this activity to flow coordinator

        lifecycle.addObserver(navigator) // setup observer for activity and navController to be disposed during onDestroy

        if (savedInstanceState == null) {

            setupBottomNavigation()

        }

    }

    override fun onRestoreInstanceState(savedInstanceState: Bundle) {

        super.onRestoreInstanceState(savedInstanceState)

        setupBottomNavigation()

    }

    private fun setupBottomNavigation() {

        val navGraphs = listOf(R.navigation.map_navigation, R.navigation.list_navigation)

        val controllerChanges = binding.bottomNavigation.setupWithNavController(navGraphs, supportFragmentManager, R.id.fragment_container, intent)

        controllerChanges.observe(this, Observer {

            coordinator.navigationController = it // change navigation controller when bottom navigation tab is changed

        })

    }

}

Koin module for navigation is defined like so:

val navigationModule = module {

    scope(named(HomeFlowActivity.SCOPE_NAME)) {

        scoped {

            HomeFlowCoordinatorImpl()

        } binds arrayOf(

            EventMapFragment.NavigationDelegate::class,

            EventListFragment.NavigationDelegate::class

        )

    }

}

Now, we have to provide navigation delegates to fragments. For convenience, we define an extension on BaseFragment to locate flow scoped components:

inline fun <reified T> BaseFragment.flow(): Lazy<T> = lazy { 
    (requireActivity() as FlowActivity).flowScope.get() 
}

And finally, we provide navigation delegates to fragments in similar fashion to viewModels:

/**

 * Fragment for screen with list of events.

 */

class EventListFragment : ViewBindingFragment<FragmentEventListBinding>() {

    interface NavigationDelegate {

        fun open(eventId: String)

    }

    private val navigationDelegate: NavigationDelegate by flow()

    private val viewModel: EventListViewModel by viewModel()

    // ...

Conclusion

In this article I wanted to showcase the adoption of Flow Coordinator pattern on Android with a minimalistic project example. If you are interested in detail flow implementation details, make sure to have a look at the GitHub repo.

Any problems with this approach?

There are certainly ?caveats? with this approach which are not solved in this example. Ideally, there would be a 1:1 mapping between flow and navigationGraph from Navigation Component, this however is a bit tricky to achieve, especially if we want to achieve BottomNavigation behaviour as stated in Material Design docs. If you have taken a look into the code, you may have noticed that I have applied a hack to preserve backstacks when changing tabs. It will be exciting to see a multiple backstack support for FragmentManager.

Vlad Gorbunov
Vlad Gorbunov

Are you interested in working together? Let’s discuss it in person!