Problems With Navigation in Jetpack Compose

Douglas Montes
ProFUSION Engineering
7 min readMay 2, 2023

--

Since its launch in 2021, Jetpack Compose has come a long way in simplifying UI development by adopting a reactive paradigm akin to that started by React. Among the many wins brought by the framework we can count the ease with which we can now deal with recyclable views, the introduction of powerful animation libraries, the drastic reduction of XML code, and much more.

However, with all its benefits, there are still some areas in which the framework begs for improvements, one of which is most definitely navigation. In this article you’ll find a recap of how navigation works in the XML world with Navigation Component, then we’ll explore the framework’s standard library for navigation while pinpointing some of the current problems with it. Finally, we wrap things up by discussing a third-party library which offer solutions to some of the posed problems.

Navigation Component (Pre-Jetpack)

In the XML world, the standard for navigation consists of three parts:

  1. A navigation graph;
  2. A navigation host;
  3. A NavController.

The navigation graph is an XML resource, centralizing all destinations, as well as the actions taking the user from one destination to the next. That means the navigation graph contains all possible navigation paths a user can take within your app. Meanwhile, the navigation host is an empty container that can hold views and it has a controller (NavController) that is responsible for switching the current view displayed inside the navigation host.

This model describes well an app with one Activity, in the case where your app has multiple ones, each Activity would have its own graph.

As an example, here’s what a navigation graph looks like:

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
app:startDestination="@id/mainFragment">
<fragment
android:id="@+id/mainFragment"
android:name="com.example.cashdog.cashdog.MainFragment"
android:label="fragment_main"
tools:layout="@layout/fragment_main" >
<action
android:id="@+id/action_mainFragment_to_sendMoneyGraph"
app:destination="@id/sendMoneyGraph" />
<action
android:id="@+id/action_mainFragment_to_viewBalanceFragment"
app:destination="@id/viewBalanceFragment" />
</fragment>
<fragment
android:id="@+id/viewBalanceFragment"
android:name="com.example.cashdog.cashdog.ViewBalanceFragment"
android:label="fragment_view_balance"
tools:layout="@layout/fragment_view_balance" />
<navigation android:id="@+id/sendMoneyGraph" app:startDestination="@id/chooseRecipient">
<fragment
android:id="@+id/chooseRecipient"
android:name="com.example.cashdog.cashdog.ChooseRecipient"
android:label="fragment_choose_recipient"
tools:layout="@layout/fragment_choose_recipient">
<action
android:id="@+id/action_chooseRecipient_to_chooseAmountFragment"
app:destination="@id/chooseAmountFragment" />
</fragment>
<fragment
android:id="@+id/chooseAmountFragment"
android:name="com.example.cashdog.cashdog.ChooseAmountFragment"
android:label="fragment_choose_amount"
tools:layout="@layout/fragment_choose_amount" />
</navigation>
</navigation>

Notice how each fragment can contain multiple actions connecting them to the next fragment, thus creating a graph structure. From the example, you can also see the Navigation Component supports nested graphs.

A navigation host that’s capable of holding fragments (NavHostFragment) should be added to your Activity as follows

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
    <androidx.appcompat.widget.Toolbar
.../>
<!-- NavHostFragment Goes Here -->
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph" />
<com.google.android.material.bottomnavigation.BottomNavigationView
.../>
</androidx.constraintlayout.widget.ConstraintLayout>

The container-nature of the navigation host is clear from the snippet, where we can find settings for its size and position. Finally, to perform navigation from one fragment to another, you can use the navController associated with the fragment:

view.findNavController().navigate(R.id.action_mainFragment_to_sendMoneyGraph)

Notice how the navigate method takes in the action's id as an argument.

If you need to pass arguments to your destination, it can be done by first adding an argument tag to your fragment as follows:

<fragment android:id="@+id/myFragment" >
<argument
android:name="myArg"
app:argType="integer"
android:defaultValue="0" />
</fragment>

Navigation Component supports all primitive types, as well as Parcelables/ Serializables, and, with the use of the Safe Args Gradle plugin, navigation arguments become type-safe.

You can also override any default parameters associated with a destination by providing action-level arguments, though I’ll not get into details, as it’s outside the scope of this article.

This level of maturity in the APIs really makes navigation feel like a first-class citizen in Navigation Component, contrasting with the younger, less mature navigation APIs in Compose.

Jetpack standard navigation library

Jetpack’s navigation model is laid out by its navigation-compose library. Let's understand how it works!

Just as before, we still have a navigation host and a navigation controller. The main difference now is that we no longer have a navigation graph laid out in an XML file, instead, our navigation graph is declared inside the navigation host itself. Also, since our UI is now made up of Composable functions, our destinations are composables, which means our navigation host itself must be a composable, as it is the container for composable content.

An app can have as many navigation hosts as needed. However, each must have its own separate NavController. Thus, in order to start setting up navigation in Jetpack, we must start by creating a NavController. This controller must be accessible down the hierarchy tree by composables that need to trigger navigation, so we create it in a place high enough to make it accessible. The actual code for creating the controller is

val navController = rememberNavController()

You can then use the CompositionLocal API in order to avoid passing the navController reference as an argument to all your composables that need it. If you have multiple navControllers, though, be careful when choosing the name of each provider, as it should be clear to each consumer which reference they should use.

Next, we create the NavHost, which is now a composable function and follows the Navigation Kotlin DSL, for example:

NavHost(navController = navController, startDestination = "profile") {
composable("profile") { Profile(/*...*/) }
composable("friendslist") { FriendsList(/*...*/) }
/*...*/
}

Notice it needs a NavController reference, also, the startDestination parameter reveals one of the most important differences between navigation-compose and Navigation Component: navigation in compose is done via a route string, much like how a web URL works. Each node in the navigation graph is declared via the composable() method, wherein we pass the route associated with it, as well as the content to be displayed by it.

Navigation between composables is, then, achieved by the navigate() method provided by the NavController:

navController.navigate("friendslist")

For more details on this method, e.g., manipulating the behavior of the backstack, please refer to the documentation).

All in all, an implementation might look like this:

@Composable
fun MyAppNavHost(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController(),
startDestination: String = "profile"
) {
NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination
) {
composable("profile") {
ProfileScreen(
onNavigateToFriends = {
navController.navigate("friendsList")
},
/*...*/
)
}
composable("friendslist") { FriendsListScreen(/*...*/) }
}
}
@Composable
fun ProfileScreen(
onNavigateToFriends: () -> Unit,
/*...*/
) {
/*...*/
Button(onClick = onNavigateToFriends) {
Text(text = "See friends list")
}
}

As we can see, inside the navigate method we pass the route associated with the composable we want to navigate to.

What if we need navigation arguments? It is here that the string route approach of Jetpack Compose feels lacking in comparison to Navigation Component.

Firstly, we modify our routes to accept the parameters they need, for example, a profile route might need a user id to display the correct profile, so instead of "profile" we declare "profile/{userId}", where userId is an arbitrary name of our choosing. Optional arguments are declared with query parameter syntax, for example, if the userId were to be optional we'd write "profile?userId={userId}". The first problem here is that any data we want to pass must be serialized and must escape special characters like /, space, @, etc that are used to build the URL, which means type-safety is not a thing. Also, as the number of screens grows, so does the number of routes, therefore we need to store them efficiently, like in an enum or sealed class to get IDE completion as well as a centralized place for modification.

Secondly, we modify our navigation graph by specifying inside each node the arguments it can accept:

NavHost(startDestination = "profile/{userId}") {
...
composable(
"profile/{userId}",
arguments = listOf(navArgument("userId") { type = NavType.StringType })
) {...}
}

We pass the arguments as a list of NamedNavArguments, created through the navArgument() method. We can see this method accepts a type argument, but it can only handle primitives, meaning no custom Parcelables or Serializables. The reasoning for such an approach is that access to complex objects and data types should be centralized in a single source of truth, a state holder, like a view model or a database. This is so that we avoid data inconsistencies, for example, when mutating the data passed to a composable. Though completely sound, this approach takes away the flexibility we previously had, as there are cases in which we might not want to store the data in a database or don't care about persistence at all.

Finally, there’s the issue of animations. The Jetpack framework comes equipped with powerful animation APIs that can be applied to navigation via the accompanist-navigation-animation library, which provides an AnimatedNavHost, working in much the same way as its counterpart, while providing customizable animations for transitioning between screens. The only issue is that now we lack the ability to set the transition animations based on specific conditions, which was possible before with fragment transactions.

The lack of out-of-the-box type-safety when calling the navigate() method with arguments, the inability to pass anything other than primitive types, the additional overhead of storing the routes efficiently as well as the inability to define condition-based animations are some of the pain points with navigation-compose today.

A Third-Party Solution

Though there are many libraries that offer solutions to the problems raised in this article, one of them stands out to me:

  • Compose Destinations: this library uses annotations processed with Kotlin Symbol Processing (KSP) to generate all the code necessary for navigation-compose, which it uses under the hood, thus, hiding the complexity we’d have to deal with. The library also handles Parcelables and Serializables out of the box. It is also possible to specify a different animation for each destination, though this requires an additional dependency.

Conclusions

Jetpack Compose has undoubtedly brought a productivity boost to UI development. It comes as part of the reactive paradigm shift seen firstly on the Web and is fairly successful in bringing this paradigm to the mobile world. Here at ProFUSION we’ve been using it to build Android projects of various sizes, including large commercial projects written entirely with Compose. While we can say there’s a long way ahead until the framework becomes as mature as the XML / View counterparts, it’s fair to say one can build very complex modern-looking apps with it, though it is advisable to take some time to develop your custom solutions to problems which the framework doesn’t currently address, or just look for a third-party lib. In the worst case scenario, you might even take advantage of the interoperability between Views and Compose to use an older API in conjunction with it until Compose develops further.

--

--