Skip to content

Instantly share code, notes, and snippets.

@levinzonr
Created June 7, 2021 14:38
Show Gist options
  • Save levinzonr/1d34e1986d62c15e8ce2f475b3360e26 to your computer and use it in GitHub Desktop.
Save levinzonr/1d34e1986d62c15e8ce2f475b3360e26 to your computer and use it in GitHub Desktop.

Safe & Easy Navigation with Jetpack Compose

Navigation is the key part in application development and Android is no exception. From Activities and Fragments transitions to Navigation Component and now, Navigation Component is available for Jetpack Compose! In this article I would like to give a brief overview of how Jetpack Compose Navigation works, the problems I've faced and my solution for it. Lets get started πŸ™‚

Your First Route

When using original Navigation Component we used XML to describe our navigation. There we could declare the arguments for our destination and transitions between them. In Compose world its, of course, we use functions :)

We start by declaring a NavHost and then we can build our navigatoin graph by adding destinations using composable . This function takes a path URI and a @Composable as the destination content

val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
    composable("home") { HomeScreen(/*...*/) }
    composable("details") { DetailsScreen(/*...*/) }
}

We've just set up our first destinations, lets add some action into our code by addding a transition from"home" to "details". Compose Navigation Component makes it pretty easy as well, all we need to do is to tell our navController the path we want to navigate to

/**/
composable("home") { 
  HomeScreen(onClick: { navController.navigate("details") }) 
}

Arguments Support

Lets Imagine our details route needs two arguments: an optional Int called number and a mandatory String called id. To support an ability to pass the arguments you need to do two things: adjust the path so that Navigation Component knows that this route can accept arguments and describe the type of the arguments. This is how it looks like in our case

NavHost(navController = navController, startDestination = "home") {
   composable("home") { HomeScreen(/*...*/) }
 	composable("details/{id}?number={number}",
       arguments = listOf(
         navArgument("id") { type = NavType.StringType }
         navArgument("number") { type = NavType.IntType; defaultValue = 1 }
       )
   ) {...}}

Now for our detail transtion, it won't be enough to specify just the destination name like we used to - we need to provide arguments too, at least id anyway, as the optional param will be set to our default value.

navController.navigate("details/myFancyId?number=21")

Next step is to obtain the arguments. This can be done in multiple ways, either from the NavBackStackEntry or from the SavedStateHandle (this can injected in our ViewModel)

Not that in both cases, the value you receive can be null, so that must be addresed in some way.

/**
* Get args from NavBackStackEntry
**/
composable(/** our details route **/ ) { backStackEntry ->
    val id: String? = backStackEntry.arguments?.getString("id")
    val number: Int? = backStackEntry.arguments?.getInt("number")                                    
    DetailsScreen(id, number)
}

/**
* Get args from SavedStateHandle
**/
class DetailsScreenViewModel @Inject constructor(
  savedStateHandle: SavedStateHandle
) {
  init {
    val id: String? = savedStateHandle.get<String>("number")
    val number: Int? = savedStateHandle.get<Int>("name")
  }
}

The elephant in the room 🐘

By now you have probably noticed how much boilerplate code we have to just support these two routes and more importantly, how many hard-coded strings we have. Each navigation stage: declarartion, arguments handling and navigation actions, they all use the path and parameters. Moreover, the arguments handling is very poor since always need to be aware of the argument name and its type.

To reduce this overhead you can wrap your destinations in a sealed class for example, that will clean up the code for sure, but it won't solve all the problems.

Remember original Navigation Component? Well it has safeArgs, a very useful plugin that generates code that allows to handle arguments in very clean and safe way. When I started to work with Jetpack Compose and its Navigation Component I immediately noticed that I miss this functionality, so I've decided to write on myself πŸ‘¨β€πŸ’»

Introducing Safe Routing Library

At it's core, safe-routing is an annotation processing library that generates a code that will handle most of the boilerplate code and hardcoded strings an will provide a safe way to navigate between you composables and ease up arguments handling

Installation

in your project level build.gradle

allprojects {
	repositories {
		...
		maven { url 'https://jitpack.io' }
	}	
}

And then in you app level build.gradle

dependencies { 
    kapt("com.github.levinzonr.compose-safe-routing:compiler:1.0.0")
    implementation("com.github.levinzonr.compose-safe-routing:core:1.0.0")
}

Lets sync our project and use apply some annotations to clean up our code. safe-routing provides two annotations for you describe the routes. Meet @Route and @RouteArg

  • @Route is used to describe your Route, its constructor accepts a route name and the arguments for it
  • @RouteArg is used to describe the arguments for your Route, it accepts several parameters: the name, type, a flag to tell whether that argument is optional or not and a String representation of the default value. Note that the default value will only be used in case the argument is optional.

Okay, now its time to revisit our navigation code πŸ™‚

Basic Setup

To start, all we have to do is to apply the annotations four out two routes

@Composable
@Route("details", args = [
    RouteArg("id", RouteArgType.StringType, false),
    RouteArg("number", RouteArgType.IntType, true, defaultValue = 1.toString()),
]) 
fun DetailsScreen() {
  /** your sweet composable code **/
}

// aand 
@Composable
@Route("profile") 
fun HomeScreen() {
  /** your sweet composable code **/
}

Thats it! After rebuilding the project, several new kotlin files will be generated. Here is a quick description, but if you want to see how it actually looks like, in this particular example, check this gist

  • Routes.kt - This file contains all your routes descriptions that can be used to declare the destinations in the NavGraphBuilder
  • RoutesActions.kt - This file contains the Nav Actions for your routes. Simillar to NavDirections from safeArg plugin it will build a proper path only accpeting the arguments you provided in the annotations.
  • *RouteArgs.kt -> for every route that contains arguments an Argument Wrapper will be generated - DetailsRouteArgs.kt in our case. This wrapper will also contain several helper functions that will help with arguments handling.

Usage

Now lets start using all this code thats been generated, starting with our NavGraphBuilder. Routes.kt now contains two objects called home and path . They all contain two properties

  • path - full path of the route including the arguments we specifed
  • navArgs - list of the NamedNavArgument to fully describe our arguments

Lets put them to good use πŸ™‚

NavHost(navController = navController, startDestination = Routes.home.path) {    composable(Routes.home.path) { HomeScreen(/*...*/) }  	composable(Routes.details.path, Routes.details.navArgs) {...}}

Our only transition also needs some love, lets use our RoutesActions.kt

composable(Routes.home.path) {   HomeScreen(    onClick = {      	navController.navigate(RoutesActions.toDetails(id = "MyFancyId", number = 0))      // navController.navigate(RoutesActions.toDetails("myId")) in case we don't want to pass the number    }	) }

Final touch - the arguments.

/*** Get args from NavBackStackEntry**/composable(/** our details route **/ ) { backStackEntry ->   	val args = DetailsRouteArgs.fromBackStackEntry(backStackEntry)                 DetailsScreen(args)}/***  OR get args from SavedStateHandle**/class DetailsScreenViewModel @Inject constructor(  savedStateHandle: SavedStateHandle) {  init {   	val args = DetailsRouteArgs.fromSavedStateHandle(savedStateHandle)  }}

And thats it!

Foreword

Thanks for your time! I really enjoyed making this tiny little library and I hope you can find it useful, if so please leave a πŸ‘! I will really appreciate any feedback and suggestions so feel free to reach out to me πŸ™‚ Here is the GitHub page for those of you who are interested.

@Tgo1014
Copy link

Tgo1014 commented Jun 7, 2021

In Compose world its, of course, we use functions :)

This phrase looks a bit weird, maybe "In Compose world, of course, we use functions"

"/**/"

This doesn't add much to the code

and a mandatory String called id.

String code has a extra space in the end

transtion

spelling

Not that in both cases

Spelling, should be "Note"

declarartion

Spelling

RouteArg

Maybe add the param names, in Android Studio it shows so it's fine, but for who is reading they have no idea what the param is supposed to be

// aand

spelling

NavHost(navController = navController, startDestination = Routes.home.path)

Would be nice if it was broken into lines so it's easier to read than scrolling

composable(Routes.home.path) { HomeScreen(

Same as above

/*** Get args from NavBackStackEntry**/composable(

Same

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment