ComPilot: Type-Safe Navigation for Jetpack Compose

Mahmoud Afarideh
6 min readNov 11, 2024

--

Introduction

ComPilot Logo

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.

Github: https://github.com/mahmoudafarideh/ComPilot

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:

  1. 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.
  2. Lack of Type Safety: Argument types are manually defined, creating potential for passing incorrect types, such as a String instead of an Int. The need for repetitive type declarations adds verbosity and increases the chance of error.
  3. 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.
  4. 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
  2. Defining Routes
  3. Types of Routes
  4. Using ComPilotNavController
  5. Setting Up NavController in the Composition
  6. Additional Notes

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 generated navigator() function from your route to navigate:
comPilotNavController.navigate(SomeScreenRoute(title = "Welcome").navigator())
  • Pop the Backstack Safely
    Control backstack behavior using safePopBackStack():
comPilotNavController.safePopBackStack()
  • Conditional Navigation
    Prevent duplicate navigations with checkShouldNavigate() or skip specific routes using checkNotInRoutes():
comPilotNavController.checkShouldNavigate()
comPilotNavController.checkNotInRoutes("SomeRoute", "AnotherRoute")
  • Pass Results Back
    Use setResult(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 a companion 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 with ComPilotNavController 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:

--

--