Blog Infos
Author
Published
Topics
, ,
Published

Jetpack Compose is here to stay. There are tons of incentives and benefits in choosing Compose as your UI Framework when starting new projects. Even looking at other platforms, we can easily see the movement for declarative UIs becoming more and more prevalent.

When I started looking into Jetpack Compose, I followed the official documentation regarding navigation. I was excited that I could get away with a single Activity and no Fragments. That being said, I was a little disappointed that, after using the navigation component and the safe-args plugin, I was again relying on things like Bundles to pass arguments. Besides, I felt there was a bit of redundancy and boilerplate for each screen destination in the app.

This article is my attempt at trying to explain why I felt this. Then I would like to introduce you to Compose Destinations — a code generating library that tries to improve navigation in this new Jetpack Compose world.

Let’s start by looking at some code of how we would define a navigation graph in Compose:

  • You define your Composables:
@Composable
fun ProfileScreen(
profileId: String,
isEditable: Boolean
) {/*...*/}
@Composable
fun LoginScreen() {/*...*/}
@Composable
fun MainFeedScreen(
navigateToProfile: (String, Boolean) -> Unit
) {/*...*/}
@Composable
fun SearchScreen() {/*...*/}
view raw Composables.kt hosted with ❤ by GitHub
  • You add some kind of sealed class or enum that contains all your routes:
sealed class Screens(val route: String) {
object Login : Screens("login")
object MainFeed : Screens("main_feed")
object Profile : Screens("profile/{id}?isEditable={isEditable}")
object Search : Screens("search")
}
view raw Screens.kt hosted with ❤ by GitHub
  • And then you add these screens to the NavHost call:
NavHost(
navController = navController,
startDestination = Screens.Login.route
) {
composable(Screens.Login.route) { LoginScreen(/*...*/) }
composable(Screens.MainFeed.route) {
MainFeedScreen(
navigateToProfile = { id, isEditable ->
navController.navigate("profile/$id?isEditable=$isEditable")
}
)
}
composable(
route = Screens.Profile.route,
arguments = listOf(
navArgument("id") {
type = NavType.StringType
},
navArgument("isEditable") {
type = NavType.BoolType
defaultValue = false
}
)
) {
ProfileScreen(
profileId = it.arguments?.getString("id")!!,
isEditable = it.arguments?.getBoolean("isEditable")!!
)
}
composable(Screens.Search.route) { SearchScreen(/*...*/) }
}
view raw NavHost.kt hosted with ❤ by GitHub

For the most part, this is what I’ve seen people do. It is where I started as well. After some time working with this, I realized there were some things I wished could be improved:

1. The redundancy surrounding the arguments. Each argument is mentioned in multiple places.

  • The full route (ex: profile/{id}?isEditable={isEditable})
  • The arguments parameter of the composable call
  • The preparation of the arguments from the NavBackStackEntry
  • And in the actual @Composable function

Ideally, I would like to not have to repeat the arguments as much. Each repetition is a bit of boilerplate and a place we can make a mistake in.

2. The way arguments are passed to a Destination.

Every time we want to navigate to a screen with arguments, we have to be careful with the route format and arguments order.
Also, the way we get arguments from the NavBackStackEntry doesn’t make use of the type system.
Imagine the time all of us will spend building the app just to find we forgot to include a mandatory argument in the route or mistyped an argument key.

3. Whenever we want to add a new Screen Composable or remove an old one we have to change multiple files.

  • The sealed class to add or remove it. When adding, we also need to carefully consider the full route with all the arguments.
  • The NavHost call to add or remove it. When adding, we need to check how to define the arguments and they need to match the full route we defined in the sealed class. Finally, we need to prepare the arguments to call the Composable.
  • The actual Composable. The arguments also make an appearance 😰

When adding or removing arguments, multiple files would also have to be changed.

4. The “ever-growing” and “multiple responsibilities holder” NavHost call.

In a small project, this might not be a huge deal, but for large projects, I can definitely see this being a bit of an issue. This NavHost call:

  • Defines each Composable that is a destination of the nav graph and its route
  • Defines the arguments for each destination
  • Prepares the arguments to call the actual Composable

Most of these may not seem like a big deal, but I knew we could do better. So I started looking for ways to improve it.

After some time, here is what I ended up with:

  • I defined a ScreenSpec sealed interface:
sealed interface ScreenSpec {
companion object {
val allScreens = listOf<ScreenSpec>(
LoginScreenSpec,
SearchScreenSpec,
MainFeedScreenSpec,
ProfileScreenSpec
)
}
val route: String
val arguments: List<NamedNavArgument> get() = emptyList()
val deepLinks: List<NavDeepLink> get() = emptyList()
@Composable
fun Content(
navController: NavController,
navBackStackEntry: NavBackStackEntry
)
}
view raw ScreenSpec.kt hosted with ❤ by GitHub

This interface contains all needed definitions to add a Composable to the navigation graph, including the @Composable function which will prepare the arguments and call the actual screen Composable (as we can see in the next example). Since Kotlin now allows sealed class inheritance in the same package, I defined one implementation of this interface for each Screen in its own file.

  • The ProfileScreenSpec.kt example:
object ProfileScreenSpec : ScreenSpec {
override val route = "profile/{id}?isEditable={isEditable}"
override val arguments = listOf(
navArgument("id") {
type = NavType.StringType
},
navArgument("isEditable") {
type = NavType.BoolType
defaultValue = false
}
)
@Composable
override fun Content(
navController: NavController,
navBackStackEntry: NavBackStackEntry,
) {
ProfileScreen(
profileId = navBackStackEntry.arguments?.getString("id")!!,
isEditable = navBackStackEntry.arguments?.getBoolean("isEditable")!!
)
}
}

And after I defined a ScreenSpec for each of my screens, here is what my NavHost was like:

NavHost(
navController = navController,
startDestination = LoginScreenSpec.route
) {
ScreenSpec.allScreens.forEach { screen ->
composable(
screen.route,
screen.arguments,
screen.deepLinks,
) {
screen.Content(navController, it)
}
}
}

Job Offers

Job Offers


    (Senior) Android Software Developer (w/m/d)

    Paradox Cat GmbH
    Munich
    • Full Time
    apply now

    Android Developer

    ClearScore
    London (Hybrid role)
    • Full Time
    apply now

    Kotlin Backend Developer and Mobile Enthusiast (m/f/d)

    Axel Springer National Media & Tech
    Berlin
    • Full Time
    apply now
Load more listings

OUR VIDEO RECOMMENDATION

Jobs

In my opinion, this is already looking way better!
Each screen now has a Spec which defines its route, arguments and prepares everything the actual Composable function needs. The NavHost doesn’t need to change anymore and its responsibilities are divided between each ScreenSpec.
Each Composable can focus exclusively on showing the state it’s given and all platform stuff is placed in its corresponding Spec.

But I wasn’t done yet. I noticed how most of this code was still a lot of boilerplate and redundancy and I disliked how I had to add/remove ScrenSpecs from the allScreenslist (I searched around but seems like there isn’t a way to get all children of a sealed parent except by using reflection).

If only there was a way to have a tool infer most of this information for us and generate the needed code… 🤔

Compose Destinations – the better way 🙌

At its core, Compose Destinations is an annotation processor library that generates a lot of the boilerplate and the “not so safe” code we’ve seen in the last chapter.
The main way you interact with it is by using @Destination annotation which signals the code generating task to consider annotated Composable functions as destinations of the navigation graph.

Let’s look at the same example we saw before but this time using Compose Destinations:

  • First, you define the Composables and annotate them:
@Destination(route = "profile")
@Composable
fun ProfileScreen(
id: String,
isEditable: Boolean = false
) {/*...*/}
@Destination(route = "login", start = true)
@Composable
fun LoginScreen() {/*...*/}
@Destination(route = "main_feed")
@Composable
fun MainFeedScreen() {/*...*/}
@Destination("search")
@Composable
fun SearchScreen() {/*...*/}
  • Then you call Destinations.NavHost instead of NavHost:
Destinations.NavHost(navController = navController)
// ~~~~~~ feel the emptyness 😌 ~~~~~~
  • And you can enable type-safe navigation with some small changes:
@Destination(route = "main_feed")
@Composable
fun MainFeedScreen(
//1. add an argument of type `DestinationsNavigator` (or `NavController`)
navigator: DestinationsNavigator
) {
//2. use the "withArgs" function on the generated Destination to safely create a route to navigate to
navigator.navigate(ProfileScreenDestination.withArgs(id = $id, isEditable = $isOwnUser))
}

That’s it! This is equivalent to the first examples we discussed!

Key differences:

  • Sealed class equivalent to the one in the first chapter gets generated for you. Each annotated Composable function generates an object with the same name suffixed with “Destination”. These Destinations implement a sealed interface similar to the ScreenSpec one shown in the previous chapter.
  • In order to specify the arguments of your destination, just add them to the Composable function! Have an argument that is not mandatory? Just use Kotlin’s default parameter feature or make it nullable! 🤯
  • To navigate to a screen with arguments, you can use the withArgs generated function of the corresponding Destination. This function contains the same arguments of the Composable you want to navigate to including the default values. It will return a correct String route to use in the navigate method.
  • You don’t need to worry about arguments in routes. You just need to specify the route’s “name” and the rest gets generated automatically.
  • The navigation graph will be inferred and added through the Destinations.NavHost call.
  • If your Composable needs to be able to navigate, you can declare an argument of type DestinationsNavigator instead of a lambda for each specific navigation. DestinationsNavigator is an interface that wraps around NavController. Applying the dependency inversion principle eliminates most of the drawbacks of passing NavController directly while actually being more convenient than passing one lambda argument for each navigation action.

Let’s also review the four points we set out to improve:

  1. “The redundancy surrounding the arguments. Each argument is mentioned in multiple places.” — Arguments definition is now only done in the composable function itself!
  2. “The way arguments are passed to a Destination.” — When navigating, we can use WhateverDestination.withArgs(arg1 = ..., arg2 = ...). No need to get the arguments from NavBackStackEntry either.
  3. “Whenever we want to add a new Screen Composable or remove an old one we have to change multiple files.” — All we do now is add the Composable and annotate it, or just remove it.
  4. “The “ever-growing” and “multiple responsibilities holder” NavHost call.” — Not an issue anymore either!

Yay! Seems like we did manage to solve the pain points of the bare compose navigation solution 👏

As a bonus, let’s say we want each destination to have a title to show in the top bar. Here is what we could do:

@get:StringRes
val Destination.title get(): Int {
return when (this) {
ProfileScreenDestination -> R.string.profile_screen
LoginScreenDestination -> R.string.pogin_screen
MainFeedScreenDestination -> R.string.main_feed_screen
SearchScreenDestination -> R.string.search_screen
}
}

Guess what happens if you add a new screen and build? A new Destination will be generated and this will be a compile error because this when expression must be exhaustive. A perfect way to make sure you have a title for each of your Destinations!

But this is not all Compose Destinations can do for you!
If you’re using Scaffold, nested navigation graphs or deep links, Compose Destinations has you covered 🙂

If you want to know more, including how to set up Gradle, check it here:
https://github.com/raamcosta/compose-destinations

That’s it, folks!
The library is still in its early stages, there is still plenty to do but I feel it is already quite helpful (in my very impartial opinion of course 😁). I really want to keep improving it so I’m looking for all kinds of feedback and contributions from our dear community 🙏

By the way, please do let me know in the comments how did I do in my first ever article! I usually have this thing where my explanations tend to get a little convoluted 😊

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
blog
I recently found a bug that would cause a crash in all the apps…
READ MORE

Leave a Reply

Your email address will not be published.

Fill out this field
Fill out this field
Please enter a valid email address.

Menu