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.
The current way
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() {/*...*/} |
- 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") | |
} |
- And then you add these screens to the
NavHost
call:
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 thecomposable
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 | |
) | |
} |
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:
Job Offers
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 allScreens
list (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 ofNavHost
:
- And you can enable type-safe navigation with some small changes:
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 correspondingDestination
. 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 thenavigate
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 aroundNavController
. Applying the dependency inversion principle eliminates most of the drawbacks of passingNavController
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:
- “The redundancy surrounding the arguments. Each argument is mentioned in multiple places.” — Arguments definition is now only done in the composable function itself!
- “The way arguments are passed to a Destination.” — When navigating, we can use
WhateverDestination.withArgs(arg1 = ..., arg2 = ...)
. No need to get the arguments fromNavBackStackEntry
either. - “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.
- “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 😊