Original address: medium.com/google-deve…

Hitherejoe.medium.com/

Published: April 20, 2021-15 minutes to read

A large number of mobile applications require some form of navigation that allows users to move between different parts of the application. When implementing these requirements in Android applications, applications have either rolled out their own solutions, relied on traditional intent or fragment managers, or explored options for navigation components in recent years. Throughout Jetpack Compose’s alpha and developer preview, I’ve often been asked, “What about navigation?” , “Is it possible to navigate between components?” . With Jetpack Compose, the idea of free-use combos introduces the idea of fragment-free and (mostly) inactive applications, allowing us to rely solely on combos when displaying our user interfaces.

This is now entirely possible thanks to the navigation composition support in the navigation component. Applications can start a single activity in their application, relying on Composables to represent the UI components that make up the application. I’ve used this component on several projects and really like how it feels like a new way to build Android applications. However, after initially using this thing in the project from existing guides and blog posts, I started to see things that felt like they would add some friction and pain points to the project.

  • A reference to NavHostController is required in combinatorial functions – in many cases, we need to have one combinatorial function perform navigation to another. I have found that in many cases the default method is to pass the NavHostController reference through the composite function. This means that in order to perform navigation, we must always have a reference to the current NavHostController. This is not very scalable and forces us to rely on this reference to perform navigation.
  • Difficult to test navigation logic – it is difficult to test our navigation logic when our composable functions directly use the NavHostController to trigger navigation. Currently, composable functions are tested using instrumental tests. Having another class handle our navigation logic, such as the viewmodel, allows us to test navigation events in the project’s unit tests.
  • Add friction to Compose migration – Many of the projects using Compose are existing projects of varying sizes. In this case, the project will most likely not be completely rewritten, but will migrate into Compose in various ways — new functionality may be written in Compose, while existing components will be slowly rewritten. In these cases, it may prove difficult to provide the NavHostController for these combinations. For example, the isolation of existing components that might be rewritten as Composables makes it difficult for the NavHostController to be provided with these functions.
  • Coupling to navigation dependencies – Requiring a NavHostController reference to perform navigation means that every module that uses this requires a reference to the synthetic navigation dependency. Similarly, if you intend to use the Hilt Navigation Compose dependency to provide a view-model for different modules, this dependency will be the same. While this may feel like an expected requirement, the centralized reliance on these things is a nice side effect when it comes to solving the problems described above.

While these are just some of the things I’ve been thinking about, it could be Compose Navigation that makes you think about how to incorporate it into your existing applications, or structure it in your new projects.

Inspired by an article I read a few years ago about modular Navigation, I’d like to share some of the explorations I made while adding Compose Navigation to a modular Android app. We will learn.

  • How to navigate to the assembly from different functional modules without relying on NavHostController references
  • How do you decouple these modules from composite navigation and handle navigation in a centralized location through the viewmodel
  • How can I use Hilt Navigation Compose to provide a viewmodel for these assemblies without having to rely on this dependency for each functional module
  • How can we simplify our testing methods by optimizing our navigation logic

In this article, we will not cover the basics of composite navigation components, so if you want an introduction to composite navigation, please use the following guide.


Modular applications

To explore the above points, we will use my Minimise project as a reference, and the application structure at work will look like this.

Here, we have several modules that make up our application.

  • Application module – This is the base module of our application. It contains our composite navigation diagram, providing navigation for the different compositions contained in the functional modules of our application.
  • Navigation module – This module will coordinate navigation throughout the project. It provides a way for dependent modules to trigger navigation and observe these navigation events.
  • Functional module – a composable module containing a specified function. Note: In the sample code, we will use more than one function module, including only one here to keep the diagram simple.

From this figure, we can begin to see the relationships between these different modules and how they work together to achieve our desired navigation goals.

  • The application module uses NavHostController to build our navigation diagram, providing a way to combine and navigate to composability in our function modules.
  • The navigation module defines the possible destinations that can be navigated to in our diagram. These are structured as commands that are triggered from our functional modules as needed.
  • The application module uses the navigation module to observe these navigation commands when triggered by the navigation manager. When any event occurs, the NavHostController will be used to navigate through our composition.
  • The feature module uses the navigation module to trigger these navigation events from its viewmodel, relying on whatever is observing these events to handle the actual navigation.
  • The viewmodel is provided for composability from within the application module using Hilt Navigation Compose, extending the scope of the viewmodel to the current Backstack entry in the corresponding NavHostController.

So that we can see how this is done, we’ll start building some code to represent the above requirements.


Combinable destinations

Before we can start thinking about navigating to composable destinations, we need to set up some composable destinations. In my Minimise project, I have two functions; An authentication screen and a dashboard screen. Users will start with the authentication screen, and when they are successfully authenticated in the app, they will be taken to the dashboard.

We’ll start here by defining a new composable function called Authentication that will get a reference to the AuthenticationState Kotlin class that holds the state of this screen. We won’t delve into the insides of these composable functions, because the code isn’t important for this article, just look at what they are made of.

@Composable
private fun Authentication(
    viewState: AuthenticationState
)
Copy the code

This state will come from a ViewModel annotated with @hiltViewModel, which is integrated with Jetpack Hilt.

@HiltViewModel
class AuthenticationViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    ...
) : ViewModel() {
    val state: LiveData<AuthenticationState> ...
}
Copy the code

You may have noticed that our previous composable functions were marked private. To enable anyone who accesses our functional modules to form an authenticated user interface, we will add a publicly accessible composable function. In this function, we will add the ViewModel as a parameter, allowing us to decouple the way the ViewModel is provided, while also adding room to improve our composable test approach.

@Composable
fun Authentication(
    viewModel: AuthenticationViewModel
) {
    val state by viewModel.uiState.observeAsState()
    Authentication(state)
}
Copy the code

With this, we now have a composable function to implement the authentication functionality of our application. To enable us to navigate between the two parts of the application, we will continue to create another composable function for the second function, the dashboard for our application. We’ll do the same thing here, we’ll create a composable function to make up our Dashboard UI and a ViewModel to coordinate its state.

@Composable
fun Dashboard(
    viewModel: DashboardViewModel
) {
    val state by viewModel.uiState.observeAsState()
    DashboardContent(state)
}

@HiltViewModel
class DashboardViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    ...
) : ViewModel()
Copy the code

With this in mind, we now have two functions made up of composable functions, each with its own ViewModel. However, these features don’t do anything in our application right now — so let’s take a closer look at how to configure to move between these two parts of our application.


Set navigation route

Because we will have a centralized navigation module to control the navigation in our application, we need to create some form of contract for the supported navigation in our application. We’ll create it as NavigationCommand, which allows us to define different navigation events that can be triggered and observed by the class that holds the navigation controller.

If you don’t already have Compose Navigation in your project, you need to add the following dependencies.

androidx.navigation:navigation-compose:1.0.0-alpha07
Copy the code

From here we will define an interface, NavigationCommand. This will define the requirements for a navigational event — for now I just need it to support a destination and any parameters to provide. There is room for this class to grow if other requirements need to be met.

interface NavigationCommand {

    val arguments: List<NamedNavArgument>

    val destination: String
}
Copy the code

With this contract, we can now define navigation commands that can be used to navigate between specific functions of our application — we’ll navigate for our authentication and dashboard functions. Here, we’ll define a new function for each function that implements the NavigationCommand interface above. Now, we will simply hardcode the navigation destination to satisfy the destination attributes of our command. This destination will then be used by our navigation controller when calculating what combination to navigate to.

object NavigationDirections {

    val authentication  = object : NavigationCommand {

        override val arguments = emptyList<NamedNavArgument>()

        override val destination = "authentication"

    }

    val dashboard = object : NavigationCommand {

        override val arguments = emptyList<NamedNavArgument>()

        override val destination = "dashboard"}}Copy the code

These destinations do not currently use navigation parameters when navigating, but I want to provide this flexibility because other parts of my application will need it. We can still use the parameters for Compos navigation centrally in our navigation module when needed. Use the function of the dashboard destination to provide the required parameters, which can then be used to build a parameter list. This preserves our approach to navigation contracts while still giving us the flexibility of modular navigation.

object DashboardNavigation {

  private val KEY_USER_ID = "userId"
  val route = "dashboard/{$KEY_USER_ID}"
  val arguments = listOf(
    navArgument(KEY_USER_ID) { type = NavType.StringType }
  )

  fun dashboard(
    userId: String? = null
  ) = object : NavigationCommand {
    
    override val arguments = arguments

    override val destination = "dashboard/$userId"}}Copy the code

With that in mind, you can configure the navigation using the route and parameters of the object, and then perform the actual navigation by firing events using the Dashboard () function. Using navigation parameters is not within the scope of my current requirements, but hopefully this will give us a rough example of what can be done here


Setting the navigation chart

Now that we have defined our navigation commands, we can proceed to configure the navigation diagram for our application — it is used to define the destinations and the composables they point to. We will start here by defining a new NavHostController reference using the rememberNavController composition function — it will be used to handle the navigation of our diagram.

val navController = rememberNavController()
Copy the code

With this in mind, we can proceed to define our NavHost — it will be used to contain the composibles that make up our navigation diagram, which will be provided through its builder parameters. We will now provide the NavHostController reference we defined earlier, and the destination where our graph should start — for this we will use the authentication screen to get its destination string from the NavigationDirections reference we defined earlier.

NavHost(
    navController,
    startDestination = NavigationDirections.Authentication.destination
) {

}
Copy the code

With this startDestination definition, this means that our NavHost will use it to configure the initial state of our navigation graph — starting our user at a combinable point that matches the authentication destination string.


Setting navigation Destinations

While we’ve defined this initial destination for our navigation diagram, we haven’t defined any of the composable destinations that make up our diagram — so things won’t work out like this! So here we continue to define the initial destination for our navigation diagram. So here we will continue to use NavGraphBuilder.com posable function to add a new destination. We first provide the composable route with a string from our NavigationDirections definition, which means that the body of the composable destination will be composed in our user interface whenever the route is navigated. Here, we provide the composable authentication that we defined earlier for the subject.

composable(NavigationDirections.Authentication.destination) {
    Authentication()
}
Copy the code

Then we’ll do the same thing again for our dashboard destination — using the dashboard composable we defined earlier for the subject, defining the route that triggers this composable to be navigated to.

composable(NavigationDirections.Dashboard.destination) {
    Dashboard()
}
Copy the code

With that in mind, we now define two composable destinations in the navigation diagram, with the authentication composable used as the starting destination in our diagram.


Provides a view model for composables

If we go back to the composable functions we defined earlier for authentication and dashboards, we see that the above declaration does not compile — this is because we lack the viewmodel parameters needed for these composable functions. To provide these parameters, we will use our NavController reference and the hiltNavGraphViewModel extension function. To get this, you need to add the following dependencies to your application.

androidx.hilt:hilt-navigation-compose:1.0.0-alpha01
Copy the code

Using this extension function will provide us with a viewModel reference whose scope is the route provided by our NavController.

Authentication(
    navController.hiltNavGraphViewModel(route = NavigationDirections.Authentication.destination)
)
Copy the code

While this is possible in the composable itself, passing it in as a parameter allows us to leave the dependency of the combined navigation + tilt navigation combination outside of our functional modules. When it comes to composable tests and providing mock references, it helps to be able to provide viewModels themselves through composable functions.

If we can guarantee that the navigation graph will always exist (and that we don’t need to provide our own route when providing the ViewModel), then we can use the hiltNavGraphViewModel() function, which is not an extension function on the NavController. This function does not need to provide a route, as this will be inferred from the current Backstack entry.

Authentication(
    hiltNavGraphViewModel()
)
Copy the code

After preparing our Authentication Composable, we will continue to do the same for our Dashboard Composable to ensure that it has a ViewModel available for it to use.

composable(NavigationDirections.Dashboard.destination) {
    Dashboard(
        hiltNavGraphViewModel()
    )
}
Copy the code

Handling navigation Events

With our view model, we can trigger navigation events for our navigation diagram to handle. The key to this section is a centralized location to trigger and observe events, which in this case will be a monad class called NavigationManager. This class needs to define two things.

  • A component that outputs previously defined NavigationCommand events, allowing external classes to observe them
  • A function that can be used to trigger these NavigationCommand events, enabling observers of the above component to handle them.

With that in mind, we have a NavigationManager class that looks something like this.

class NavigationManager {

    var commands = MutableStateFlow(Default)

    fun navigate(
        directions: NavigationCommand
    ) {
        commands.value = directions
    }

}
Copy the code

Here, the command reference can be used by the external class to observe the NavigationCommands being triggered, and the navigate function can be used to trigger navigation based on the provided NavigationCommand. It is important to note that this class must be a monad instance. This ensures that every class that communicates with NavigationManager references the same instance. In my application, I define this class in the Hilt SingletonComponent.

@Module
@InstallIn(SingletonComponent::class)
class AppModule {

    @Singleton
    @Provides
    fun providesNavigationManager(a) = NavigationManager()
}
Copy the code

With this, we can observe these navigation events being processed by our navigation diagram. We previously defined our NavHost reference, which is used to define our navigation diagram, and for that we also provided a NavHostController reference. This NavHostController can also be used to trigger navigation between different destinations — we can do this when the NavigationCommand event we observed occurs. What we do here is inject a reference to the NavigationManager, and then use the included commands to observe the navigation events. Because these commands use StateFlow, we can use the Compose Runtime collectAsState() extension function to collect the StateFlow events that occur in the form of the Compose state. We can then use the value of this state to determine the direction to navigate, using our NavHostController to trigger this.

@Inject
lateinit var navigationManager: NavigationManager

navigationManager.commands.collectAsState().value.also { command ->
    if (command.destination.isNotEmpty()) navController.navigate(command.destination)
}
Copy the code

Note: StateFlow currently requires (as far as I know) a value for initialization. That’s why we have this empty check here – hopefully this can be sorted out in the future!


Trigger navigation event

Now that we have an observation of the navigation commands, we’re going to trigger them. This will be done from our viewmodel, removing the responsibility for navigation from our Composable.

All we need to do here is add our NavigationManager to our viewmodel, providing it through the constructor. Remember, this reference is a monad, so this will be the same instance we use where our navigation chart is.

@HiltViewModel
class AuthenticationViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val authenticate: Authenticate,
    private val sharedPrefs: Preferences,
    private val navigationManager: NavigationManager
)
Copy the code

With this, we can now trigger navigation events directly from our ViewModel. Maybe our Composable wants to manually invoke our ViewModel to trigger some navigation, or maybe we want to trigger navigation based on the result of an operation. However, we can do this by triggering the navigation function and passing in the NavigationDirections we want to use for the navigation command.

navigationManager.navigate(NavigationDirections.Dashboard)
Copy the code

With this navigation logic now in our view-model, we can easily test any simulated instance of our navigation manager by verifying that the required functions are called.

verify(mockNavigationManager).navigate(NavigationDirections.Dashboard)
Copy the code

Finishing touches

With this in mind, we’ve been able to navigate our modular application for Jetpack Compose. These changes allow us to focus our navigation logic, and in doing so, we can see a range of advantages that are now in place.

  • We no longer need to pass NavHostController to our component, the reference is used to perform navigation. Keeping it out of our component eliminates the need for synthetic navigation dependencies for our functional modules, while also simplifying the constructor at test time.

  • We have added ViewModel support for our composition, provided through our Navigation controller, and again do not need to add Hilt Compose Navigation related dependencies for each of our function modules — instead, viewModels are provided through the Composable function. This not only gives us the advantages mentioned here, but also simplifies our composable testing again — allowing us to easily provide mock instances of the ViewModel and its nested classes at test time.

  • We have centralized our navigation logic and created a contract for things that can be triggered. In addition to the benefits mentioned above, this helps keep our application’s navigation easier to understand and debug. Anyone who jumps into our application can experience reduced friction when it comes to understanding what navigation or the application supports and where those things are triggered.

  • In addition to the points above, we’ve been able to handle navigation in a way that helps reduce friction while using Compose. When incorporating Compose into an existing application, the developer will most likely add composable parts to the application — perhaps a composable part instead of a view, or an entire screen to represent a composable user interface. Either way, it helps to keep things simple and responsibilities minimal — and this modular approach to navigation helps.

These are just some of the advantages that come to mind when thinking about Jetpack Compose’s approach to navigation. Can you think of anything else? Or even so, are there any downsides? Let us know!

Navigation in Compose is still in its early stages, so things could still change. I currently use this approach in my Minimise project, but that will definitely change as I continue to learn more about Compose and the best way to construct things for my project. Depending on the needs of your project, this may also work for you. At the same time, I’m happy with this approach and it definitely has room to grow if more navigation is needed.


www.deepl.com translation