Revolutionize Your Jetpack Compose Navigation with Compose-Destinations

Written by welltech | Published 2023/04/14
Tech Story Tags: jetpack-compose | android-app-development | app-development | mobile-app-development | android | jetpack | mobile-apps | good-company

TLDRVadim is an Android Developer at Welltech. W Vadim explains the advantages and drawbacks of the Navigation component library for Compose.via the TL;DR App

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:
  1. Describe the arguments that the screen can accept (key, type);
  2. Change the screen path with the specification of argument keys;
  3. Create a path with the necessary parameters when navigating, and;
  4. Retrieve the passed arguments from the Bundle by key.
  5. 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:
    • Boilerplate code (when there are many screens in the project, it starts to get tedious);
    • It is possible to make a simple typo (in the path or argument name);
    • Since the path is parsed as a Uri, you need to make sure that the String parameters being passed don’t include any special characters - or encode them in Url format (for example, using URLEncoder.encode);
    • Sooner or later, you will need to pass Serializable or Parcelable. Of course, this can be done by serializing the object to String beforehand, however, writing serialization every single time is boring.
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:
  • It’s not a separate library for navigation but a superstructure over Navigation-Compose, whose main task is code generation.
  • Since it’s only a superstructure, it can easily be used in an existing project that uses NC. For new projects, the entry threshold is low in case there is experience with standard NC.
  • There is no need to manually describe the navigation graph (simply add the @Destination annotation to the composable function of the screen and the library will generate it).
  • There is no need to think about how to pass Serializable/Parcelable.
  • Arguments that are passed in String format are encoded in URL format.
  • Arguments required on a specific screen are simply enumerated as parameters of the composable function. Additionally, if you specify the NavController, the necessary controller for navigation management will be passed.
  • There is no need to change anything in the graph description (our file was quite large - about 1000 lines - and looking for something in it was a pleasure).
There are also a few downsides:
  • It uses code generation, which increases the project build time (for our project, this is not a critical disadvantage, and we did not notice a significant impact).
  • It is developed and maintained by one person, so what will happen to the project in the future is unknown.
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.


Written by welltech | Welltech is a global IT company that produces Health & Fitness mobile applications.
Published by HackerNoon on 2023/04/14