Hello! My name is Vadim, and I am an Android Developer at Welltech. We specialize in developing mobile applications in the Health & Fitness category. I would like to tell you about our experience with navigation in Jetpack Compose.
This article will be useful for those who are just starting to learn Compose or for those who are not satisfied with Google’s standard solution - the Navigation component for Jetpack Compose.
History
While View was being used, the Navigation component library (NC) became the de facto standard. The majority of developers, including us, used it and "were perfectly happy." With the advent of Compose, Google also adapted NC and recommended its use.
When we started writing a new project on Compose, we naturally chose NC to build our navigation as we already had experience working with the library. Everything looked familiar: the same NavController and the same NavHost. The difference was that NavHost's description was in the code instead of an XML file.
This new approach to navigation became a real novelty: now, instead of generating screen identifiers in the R class, we needed to specify a path with arguments (if any) in the form of a String:
NavHost(navController = navController, startDestination = "home") {
composable("home") { HomeScreen(/*...*/) }
/*...*/
}
//navigate
navController.navigate("home")
We immediately created a sealed class for convenience to list the screens and the logic of creating a path with arguments, as well as retrieving the passed arguments from the Bundle:
sealed class Destination(
val path: String,
/*...*/
) {
object Home : Destination("home", /*...*/)
/*...*/
}
NavHost(
navController = navController,
startDestination = Destination.Home.path
) {
composable(Destination.Home.path) { HomeScreen(/*...*/) }
/*...*/
}
//navigate
navController.navigate(Destination.Home.path)
The first inconvenience arose when we needed to pass arguments. In NC for the View framework, arguments were described in XML (primitive types, Serializable, Parcelable). During compilation, a class with these arguments was generated, and it was only necessary to create an instance with the necessary data and pass it to NavController. But in the Compose world, we were in for a disappointment...
Passing arguments in NC for Compose and the drawbacks of this implementation
To pass arguments, we need to:
composable(
"other_screen/{param1}", //path with args
arguments = listOf(
navArgument("param1") { type = NavType.StringType }
) //args descriptions
) { backStackEntry ->
//obtain our args
val param1 = backStackEntry.arguments?.getString("param1")
OtherScreen(param1)
}
//navigate (with param1="hello_world")
navController.navigate("other_screen/hello_world")
We moved the logic of creating a screen URL and enumerating arguments, as well as retrieving argument values from the Bundle, into a separate class. We got this form:
sealed class Destination(
val path: String,
val args: List<NamedNavArgument>
) {
object OtherScreen : Destination(
path = "other_screen/{param1}",
args = listOf(
navArgument("param1") {
type = NavType.StringType
defaultValue = null
nullable = true
}
)
) {
class Arguments(val param1: String?)
fun getRoute(arguments: Arguments): String {
return "other_screen/${arguments.param1}"
}
fun getArguments(bundle: Bundle): Arguments {
return Arguments(
param1 = bundle.getString("param1")
)
}
}
}
It was convenient to use this for a while. Our graph description and navigation looked like this:
But as time has passed, this approach has shown significant drawbacks:
Compose-destinations
Over time, we became accustomed to the inconveniences described above, until one day we came across an interesting library - "Compose-destinations". After reading the documentation, it became clear - this was it!
We identified the following advantages:
There are also a few downsides:
Migration to Compose-destinations
After weighing all the pros and cons, it was clear - we needed to integrate it into our project. The migration process was not difficult, but neither was it fast as we had a lot of screens. An example of migrating our navigation:
@RootNavGraph
@Destination
@Composable
fun OtherScreen(
param1: String?,
param2: Boolean,
navigator: DestinationsNavigator
) { /*...*/ }
It should be noted that @RootNavGraph is used to indicate that our screen belongs to the default graph. If you have more than one graph, you should create your own annotation by inheriting it from @NavGraph and then use it.
DestinationsNavigator is a library abstraction over NavController, and you can also use the regular NavController.
After adding a new screen, you need to rebuild the project for code generation to work. Then you can use the generated graph:
DestinationsNavHost(navGraph = NavGraphs.root)
Concise, isn't it? Next, we will look at how to navigate to a specific screen and pass arguments:
val navigateToOtherScreen = { param1: String?, param2: Boolean ->
navigator.navigate(
OtherScreenDestination(param1, param2)
)
}
OtherScreenDestination is also a generated class, an instance of which we create with arguments and pass to NavController/DestinationsNavigator.
Conclusion
In this article, we have looked at the use of a library that significantly improves (in our subjective opinion) the use of Navigation-Compose. This solution may not be suitable for everyone - I have only shown a simple use case - but the library is at least worthy of attention. Our team would have been extremely happy if someone had shown it to us before we started our development, so maybe it will be a revelation for someone here.