Traditionally, navigation in Compose relies on defining routes as strings, which opens up a lot of flexibility but also introduces potential risks if not handled carefully. In this guide, we’ll walk through how to safely define string routes, break down the structure of these routes, and address common problems encountered when using this method.
What this article is not — a guide on the new type safe navigation
Click here to view the complete Destination
source code
Compose navigation routes use a string pattern to define the paths between composables. These strings can contain both required and optional arguments, which dictate how data is passed to different screens in the app. Let’s break down the key components that make up a string route:
- Route Name: The base of the string that represents the destination. It could be a simple string like
"home"
,"profile"
, or"details"
. This is the identifier of the composable you want to navigate to.Example:
val route = "profile"
2. Required Arguments: These are values that must be provided when navigating to a specific route. A required argument follows the pattern /{argumentID}
, where /
acts as the delimiter separating multiple required arguments, and {}
enclose the argument ID.
Example:
val route = "profile/{userId}"
Here, userID
is a required argument. When navigating to the profile screen, you need to provide a value for userID
.
3. Optional Arguments: These arguments are not mandatory for the route to function and are defined using query parameter syntax. An optional argument follows the pattern argumentID={argumentID}
. The ?
prefix marks the start of optional arguments, and &
is used as the delimiter to separate multiple optional arguments. Braces {}
enclose the argument ID, which will be replaced with the actual argument when it is passed — the entire {argumentID}
will be substituted by the provided argument (this same rule applies to required arguments as well).
Example:
val route = "search?query={query}"
The query
parameter is optional. If it’s not provided, the screen can still be displayed, but without the query term.
Together, a complete route might look something like this:
val route = "profile/{userId}?showDetails={showDetails}&page={page}"
In this case, the userID
is required, while showDetails
and page
are optional.
Case Study
Let’s take a case study of two screens:
1. A Form screen with three fields: Email, Phone number, social link
2. A profile screen that displays these information.
Let’s make the phone number and social link optional, the route for PROFILE_SCREEN accepts an email as a required argument, a phone number and social link as optional arguments. We follow the convention of using call backs to pass navController
to screens using the signatureonNavigate: (route: String) -> Unit
The responsibility of generating the route is scoped to the screen that calls onNavigate
Since routes are defined as strings, navigating between screens requires that you embed the actual argument values within a route when needed, allowing for potential typos during route construction. This introduces a risk of your app crashing due to:
1. String Typing Errors: If you accidentally type profle/{userId}
instead of profile/{userId}
, it will break the navigation, as the navController
object will have no record of that route. Such errors may not be immediately obvious.
2. Missing Arguments: If a required argument is omitted, the app will crash. For example, when navigating to profile/{userId}
, if you do not provide the userID
, navigation will fail.
3. Complexity with Multiple Arguments: When dealing with routes that require multiple arguments — especially if some are optional — it can become challenging to track what needs to be passed. The more complex the route becomes, the harder it is to ensure that the string is constructed correctly.
Handling routes in this manner within a production app that features many screens, each with its own set of arguments, can lead to a considerable amount of time spent debugging these routes. Clearly, there is a problem that requires an efficient solution.
How can this problem be better managed?
We need to define a route management model to mitigate errors. This model should reliably create routes with the correct patterns.
Aim: The model should guarantee correctness, have a straight forward signature, and offer friendly usage.
The solution Ipresent is a data type that uses Joshua Bloch’s builder pattern, leveraging Kotlin’s DSL to provide a very descriptive usage
The Destination Class API
The data type we will call Destination
holds the following properties and function
Member Function
navRoute(block: Utility.() -> Unit = {})
Return value: A string representing the full route, including the actual arguments if provided (required or optional).
Parameters:block: Utility.() -> Unit
— a lambda block with access to utility functions used to modify the route during construction.
Companion Object Functions
generateRoute(routeID:String, block: Builder.() -> Unit = {}: Destination
Return value: A Destination
instance representing the specified route.
Parameters:routeID:String
— the unique ID of the route.block: Builder.() -> Unit
— an optional lambda block with access to utility functions used to customise the route during construction.
Utility Class API
The utility class is an inner class the holds utitlity function used to append actual arguments to routes
Builder Class API
A nested builder class with utility functions to correctly create the route safely. The builder class is defined with the following functions