ComPilot: Type-Safe Navigation for Jetpack Compose
Introduction
Introduction
Handling navigation in Jetpack Compose can be complex and error-prone due to fragile route definitions, lack of type safety, and unreliable backstack management. ComPilot is designed to address these challenges by enforcing type-safe routes and offering advanced navigation control through the ComPilotNavController
. I will walk through the specific navigation challenges that led to ComPilot’s development and explain how ComPilot makes navigation safer, simpler, and more reliable.
Navigation Route Challenges
Defining navigation routes in Compose typically involves string-based routes and manually handled arguments, as seen in this example:
enum class SomeRoutes(val routeName: String) {
SomeScreen("some/screen/{some-parameter}")
}
composable(
"${SomeRoutes.SomeScreen.routeName}",
arguments = listOf(
navArgument("some-parameter") { type = NavType.IntType }
)
) {
val messageId = it.arguments?.getInt("some-parameter") ?: ""
MessageDetailsScreen(messageId)
}
This approach introduces several challenges:
- Fragile Route Definitions: Routes are defined with plain strings, making them vulnerable to typos and mismatches. For instance, an error in
{id}
would only be caught at runtime, resulting in crashes that could have been prevented with compile-time checks. - Lack of Type Safety: Argument types are manually defined, creating potential for passing incorrect types, such as a
String
instead of anInt
. The need for repetitive type declarations adds verbosity and increases the chance of error. - Error-Prone Argument Parsing: Each screen is responsible for extracting and parsing its own arguments, which can lead to inconsistent handling, especially if argument parsing code is reused across different destinations.
- Redundant and Verbose Code: The manual handling of routes and arguments leads to redundant code that becomes difficult to maintain as the app scales.
ComPilot Solution: Type-Safe Routes with KSP
ComPilot leverages Kotlin Symbol Processing (KSP) to generate type-safe navigation routes, eliminating the challenges of manual route definition. With ComPilot, each route is defined as a data class with strongly-typed parameters or object or enum class, simplifying both navigation setup and argument management.
1. Defining Routes with Data Classes, Object or Enum
Instead of manually constructing route strings, you define each route as a data class that explicitly specifies each parameter and its type:
@RouteNavigation
data class MessageDetailRoute(val id: Int) {
companion object
}
This type-safe structure ensures that each route is correctly defined and that parameters are always type-checked, reducing the risk of passing incorrect values.
2. Automatically Generated Navigation Functions
ComPilot’s KSP-powered code generation creates navigation functions for each route, ensuring compile-time safety. Some of the generated functions include:
navigationRoute()
: Constructs a safe route string.arguments()
: Defines the argument list with correct types.navigator()
: Creates a navigator instance for type-safe routing.screen(...), dialog(...) or bottomsheet(..)
: Creates a function based on route type to add the composable to the NavGraphBuilder.
Example of generated functions for SomeScreen
:
fun SomeScreen.Companion.navigationRoute(): String {
return "m/a/compilot/SomeScreen/{id}/"
}
fun SomeScreen.Companion.arguments(): List<NamedNavArgument> {
return listOf(
navArgument("id") {
type = NavType.IntType
nullable = false
},
)
}
fun SomeScreen.navigator(): String {
return "m/a/compilot/SomeScreen/${this.id}/"
}
private fun Bundle.toSomeScreen(): SomeScreen {
return SomeScreen(id = getInt("id"),)
}
@Composable
@NonRestartableComposable
fun SomeScreen.Companion.rememberArguments(bundle: Bundle): SomeScreen {
return remember { bundle.toSomeScreen() }
}
fun SomeScreen.Companion.screen(
navGraphBuilder: NavGraphBuilder,
...
content: @Composable AnimatedContentScope.(RouteStack<SomeScreen>) -> Unit
) {
navGraphBuilder.composable(
route = this.navigationRoute(),
arguments = this.arguments(),
...
content = {
val argument = rememberArguments(it.arguments ?: bundleOf())
this.content(RouteStack(it, argument))
}
)
}
Navigation Challenges and ComPilotNavController
While type-safe route definitions simplify route management, Compose navigation also faces challenges around backstack control, conditional navigation, and safe navigation flows. ComPilot’s ComPilotNavController
interface was designed to address these specific issues.
Key Abilities of ComPilotNavController
val comPilotNavController = LocalNavController.comPilotNavController
1. Safe Navigation
The ComPilotNavController
interface provides methods to navigate safely without risking redundant navigations:
safeNavigate()
: Checks if the target is already the current destination, preventing duplicate navigations.
comPilotNavController
.safeNavigate()
.navigate(SomeScreen.navigator)
checkShouldNavigate()
: Ensures that navigation only occurs when it’s necessary, helping avoid redundant entries in the backstack.
comPilotNavController
.checkShouldNavigate()
.navigate(SomeScreen.navigator)
2. Backstack Management
Managing the backstack is simplified with ComPilotNavController
, which offers clear, structured methods:
safePopBackStack()
: Pops the backstack only when the current destination is valid, helping to avoid unexpected screen removals.
comPilotNavController.safePopBackStack()
popToDestination(route)
: Jumps to a specific destination in the stack, preserving state without disrupting the flow.
comPilotNavController.popToDestination(SomeScreenRoute.navigationRoute())
3. Conditional Route Control
ComPilotNavController also provides conditional navigation options:
checkNotInRoutes(vararg route: String)
: Skips navigation to specified routes, allowing fine-grained control over allowed navigation paths based on current context.
comPilotNavController.checkNotInRoutes(SomeScreenRoute.navigationRoute())
Data Passing Between Screens
To facilitate passing data between screens without error-prone argument handling, ComPilotNavController
includes:
setResult(key, result)
: Passes data to the previous screen in the stack, enabling seamless result handling:
comPilotNavController
.setResult("SomeKey") {
this.setInt("SomeResultKey", someValue)
}
.safePopBackStack()
NavigationResultHandler
it.navBackStackEntry.NavigationResultHandler {
this.handleNavigationResult("SomeKey") {
this.getInt("SomeResultKey")
}
}
4. Type-Safe Navigation via Route Objects
With ComPilotNavController, navigation is type-safe by design, as developers can navigate using generated route objects:
comPilotNavController.navigate(SomeScreenRoute.navigator)
ComPilot User Manual
Version: 1.0.0-beta03
Compatibility: Currently compatible with Compose 1.7.5
Table of Contents
1. Installation
To integrate ComPilot into your project, add the following dependencies to your Gradle files:
Add ComPilot dependencies
implementation 'io.github.mahmoudafarideh.compilot:runtime:1.0.0-beta03'
implementation 'io.github.mahmoudafarideh.compilot:navigation:1.0.0-beta03'
ksp 'io.github.mahmoudafarideh.compilot:compiler:1.0.0-beta03'
2. Defining Routes
To create a navigation route with ComPilot, define a data class and annotate it with @RouteNavigation
. ComPilot will generate the necessary navigation code automatically using KSP.
Note: Remember to include a
companion object
in each route data class, as this is required for the compiler to generate code for the route.
Example Route Definition
@RouteNavigation
data class SomeScreenRoute(
val someArgument: String
) {
companion object
}
In this example, SomeScreenRoute
is a simple route that takes a String
parameter someArgument
. ComPilot will generate type-safe navigation functions for this route.
Example with Nested Data
ComPilot supports nested data classes and enums within routes, allowing complex data structures to be passed as arguments.
@RouteNavigation
data class SomeScreenWithNestedArgRoute(
val nested: NestedData,
val child: Child?
) {
data class NestedData(
val id: Int,
val name: String,
)
enum class Child {
Child1,
Child2
}
companion object
}
In this example, SomeScreenWithNestedArgRoute
includes NestedData
and Child
as parameters, enabling structured and flexible route definitions.
3. Types of Routes
ComPilot allows for different types of routes, such as screens, dialogs, and bottom sheets. Use the type
parameter in the @RouteNavigation
annotation to specify the route type.
Standard Screen
By default, a route is created as a standard screen:
@RouteNavigation
data class SomeScreenRoute(
val title: String
) {
companion object
}
Dialog Route
To define a dialog route, set type = RouteType.Dialog
:
@RouteNavigation(type = RouteType.Dialog)
data class SomeDialogRoute(
val id: Int
) {
companion object
}
This configuration will generate code for displaying SomeDialogRoute
as a dialog.
Bottom Sheet Route
To define a bottom sheet route, set type = RouteType.BottomSheet
:
@RouteNavigation(type = RouteType.BottomSheet)
data class SomeBottomSheetRoute(
val label: String
) {
companion object
}
This setup will generate code for displaying SomeBottomSheetRoute
as a bottom sheet.
4. Using ComPilotNavController
ComPilot provides ComPilotNavController
, a specialized navigation controller that enhances control over Compose navigation with features like safe navigation and structured backstack management.
To use ComPilotNavController
, retrieve it from the LocalNavController
as shown below:
val comPilotNavController = LocalNavController.comPilotNavController
With comPilotNavController
, you can safely navigate using generated route objects, ensuring type safety and preventing redundant navigation. Here are some key functions:
- Navigate to a Route
Use the generatednavigator()
function from your route to navigate:
comPilotNavController.navigate(SomeScreenRoute(title = "Welcome").navigator())
- Pop the Backstack Safely
Control backstack behavior usingsafePopBackStack()
:
comPilotNavController.safePopBackStack()
- Conditional Navigation
Prevent duplicate navigations withcheckShouldNavigate()
or skip specific routes usingcheckNotInRoutes()
:
comPilotNavController.checkShouldNavigate()
comPilotNavController.checkNotInRoutes("SomeRoute", "AnotherRoute")
- Pass Results Back
UsesetResult(key, result)
to pass data back to previous screens:
comPilotNavController.setResult("someKey") {
// Set result data
}
5. Setting Up NavController in the Composition
To make ComPilotNavController
accessible throughout your screens, provide the NavController
at the root of your composable hierarchy. This enables screens to retrieve the navigation controller as needed.
Add the following setup to your root composable:
CompositionLocalProvider(LocalNavController provides navigation) {
// Your screen content
}
This CompositionLocalProvider
allows all child composables to access LocalNavController
and use comPilotNavController
for navigating between screens.
6. Additional Notes
- Companion Object Requirement
Each@RouteNavigation
annotated data class must include acompanion object
to allow the compiler to extend it with generated functions. - Testing Your Routes
ComPilot’s KSP-generated code ensures type-safe, structured navigation routes. For complex navigation flows, testing ComPilot routes withComPilotNavController
can help confirm their behavior, especially with nested arguments and different route types. - Updating Compose Version
Ensure that your Compose version is compatible with ComPilot (currently tested with Compose 1.7.5). Compatibility with newer versions may require library updates.
Stackademic 🎓
Thank you for reading until the end. Before you go:
- Please consider clapping and following the writer! 👏
- Follow us X | LinkedIn | YouTube | Discord | Newsletter | Podcast
- Create a free AI-powered blog on Differ.
- More content at Stackademic.com