Exploring Safe Navigation in Jetpack Compose Multiplatform

Kerry Bisset
13 min readAug 5, 2024

Have you ever struggled with navigating complex UI flows in your application? It’s a common challenge, especially when dealing with dynamic and evolving user interfaces. In my experience, the traditional nav graph has often felt like a gamble — sometimes, you never quite know how complicated things will get until you’re deep into development. That’s why I’ve typically opted to implement my own navigation solutions, preferring the control and flexibility they provide.

However, the emergence of Jetbrain’s adoption of Navigation has piqued my interest, particularly its official support for safe navigation. The promise of type safety and the backing of an official library made it worth a closer look. This article explores the concept of safe navigation within this framework, aiming to provide a clear and practical guide to implementing scalable navigation solutions. Along the way, it will highlight the benefits of using Koin for dependency injection, ensuring that your app remains modular and maintainable.

Project Setup

Of course, setting up the project with the dependencies and configurations is necessary before diving into the implementation. This section outlines the versions and libraries used in this Jetpack Compose Multiplatform project, focusing on the critical components for safe navigation and serialization.

Version Numbers

This section lists the version numbers for various dependencies and SDKs used in the project.

[versions]
# Android SDK versions
android-compileSdk = "34"
android-minSdk = "29"
android-targetSdk = "34"

# JVM target version
commonsLogging = "1.3.3"
jvmTarget = "17"

# Gradle Plugins
agp = "8.2.0" # Android Gradle Plugin
compose-plugin = "1.7.0-dev1750"

# AndroidX Libraries
androidx-activityCompose = "1.9.0"

# Jetbrains and Kotlin Libraries
kotlin = "2.0.0"
coroutines = "1.8.1"
kotlinx-serialization = "1.6.3"

# Testing Libraries
lifecycleViewmodelCompose = "2.8.0"

# Other Dependencies
koin = "4.0.0-RC1"
navigationCompose = "2.8.0-alpha08"

Libraries

Defines the dependencies for the project, referencing the versions defined above.

[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-coroutines = { module = "io.insert-koin:koin-core-coroutines", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }

Plugins

Configuration for Gradle plugins used in the project.

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Key Dependencies

  • navigation-compose: This library facilitates navigation within Jetpack Compose, providing a type-safe API for defining and handling navigation routes.
  • kotlinx-serialization-json: Essential for serializing and deserializing data objects, especially when passing data between screens or saving state.

App Setup

Setting up the application involves registering key components with Koin and setting up the navigation architecture. This section details the steps taken to integrate navigation with Koin, the rationale behind these choices, and a brief description of the code.

Koin Module Definition

val module = module {
viewModel { p -> MainViewModel(p[0], getAll<INavigationItem<Any>>().sortedBy { it.order }, getAll()) }
single { HomeNavigationItem() } bind INavigationItem::class
single { TestNavigationItem() } bind INavigationItem::class
single { HomeNavigationArea() } bind INavigationArea::class
single { TestMainNavigationArea() } bind INavigationArea::class
factory { TestViewModel(get()) }
single { TestSecondArea() } bind INavigationArea::class
factory { p -> TestSecondScreenViewModel(get(), p[0]) }
}
  • ViewModel Registration: The MainViewModel is registered with Koin, taking the NavHostController as a parameter. This view model manages navigation and keeps track of navigation areas and items. It is linked to the ViewModelStore for Jetbrain’s Multiplatform ViewModel.
  • Singletons for Navigation Items and Areas: Various navigation items and areas are registered as singletons, ensuring consistent behavior and state throughout the app.
  • Factory for ViewModels: Factories create the ComposableViewModel (a creation of mine to be similar to Voyagers ScreenViewModel) as needed, ensuring that each ViewModel instance can be recreated with the necessary dependencies.

Composable Function for App

@Composable
@Preview
fun App() {
MaterialTheme {
val navController = rememberNavController()
val mainViewModel = koinViewModel<MainViewModel> { parametersOf(navController) }

Surface(Modifier.fillMaxWidth()) {
Row {
NavigationRail {
mainViewModel.navigationAreas.forEach { item ->
NavigationRailItem(
selected = mainViewModel.selectedItem.collectAsState().value == item,
onClick = { mainViewModel.onInteraction(MainInteractions.NavigateTo(item)) },
icon = item.icon,
)
}
}
Box(Modifier.weight(1f).fillMaxHeight()) {
NavHost(
mainViewModel.navController,
startDestination = mainViewModel.selectedItem.value.route,
) {
mainViewModel.navigatables.forEach { it.display(this) }
}
}
}
}
}
}
  • NavController Initialization: A NavController is initialized using rememberNavController(). This controller manages app navigation and can be accessed throughout the app.
  • MainViewModel Integration: The MainViewModel is retrieved using koinViewModel, with the NavHostController passed as a parameter. This setup links the navigation controller with the ViewModel, enabling the ViewModel to control navigation.
  • UI Setup: The App function sets up the basic UI structure, including a NavigationRail for side navigation and a NavHost for displaying screens. The NavigationRail dynamically displays items from mainViewModel.navigationAreas, allowing users to switch between different sections of the app.

MainViewModel Class

class MainViewModel(
internal val navController: NavHostController,
internal val navigationAreas: List<INavigationItem<Any>>,
internal val navigatables: List<INavigationArea>,
) : ViewModel(), KoinComponent {
private val module = module {
single { navController }
}

private val _selectedItem = MutableStateFlow(navigationAreas.first())
val selectedItem = _selectedItem.asStateFlow()

init {
getKoin().loadModules(listOf(module))
}

internal fun onInteraction(interactions: MainInteractions) {
when (interactions) {
is MainInteractions.NavigateTo -> {
_selectedItem.value = interactions.route
navController.navigate(interactions.route.route)
}
}
}

override fun onCleared() {
super.onCleared()
getKoin().unloadModules(listOf(module))
}
}
  • NavController Dependency: The NavHostController is registered within Koin, making it available for injection across the app.
  • Selected Item State Management: The _selectedItem state flow tracks the currently selected navigation item, allowing the UI to respond to changes.
  • Interaction Handling: The onInteraction function handles various interactions, such as navigation events, updating the selected item, and triggering navigation actions.
  • Lifecycle Management: The MainViewModel registers the NavHostController with Koin during initialization and unloads it when the ViewModel is cleared, ensuring proper lifecycle management.

Interfacing Components for Scalability

Abstracting and decoupling various components is essential to maintaining a scalable and maintainable architecture in Jetpack Compose Multiplatform. This section introduces the interfaces used in the project, detailing their purpose and how they facilitate a modular design. By defining clear contracts for navigation items and areas, we ensure consistent behavior across the application and simplify the integration of new features.

INavigationItem Interface

The INavigationItem interface defines a contract for app navigation items. It includes properties for an icon, label, route, and order, allowing the app to render navigation elements dynamically.

interface INavigationItem<T : Any> {
val icon: @Composable () -> Unit
val label: String
val route: T
val order: Int
}
  • icon: A composable function that returns the icon for the navigation item.
  • label: A string representing the label of the navigation item.
  • route: A type-safe route associated with the navigation item.
  • order: An integer defining the order in which the item appears in the navigation list.

Example Implementation: HomeNavigationArea

class HomeNavigationItem : INavigationItem<Home> {
override val icon: @Composable () -> Unit = {
Icon(Icons.Default.Home, contentDescription = "Home")
}
override val label: String = "Home"
override val route: Home = Home
override val order: Int = 0
}

In this implementation, the HomeNavigationItem class provides a specific icon, label, and route for the "Home" screen. The order property is set to 0, indicating its position in the navigation list.

INavigationArea Interface

The INavigationArea interface defines a contract for navigation areas responsible for displaying specific screens or sections within the app. It includes a single function, display, which takes a NavGraphBuilder as a parameter.

interface INavigationArea {
fun display(navGraphBuilder: NavGraphBuilder)
}
  • display: A function that defines how the navigation area is displayed, utilizing the NavGraphBuilder to register composables and routes.

Example Implementation: HomeNavigationArea

class HomeNavigationArea : INavigationArea {
override fun display(navGraphBuilder: NavGraphBuilder) {
navGraphBuilder.composable<Home> {
Home()
}
}

@Composable
private fun Home() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Home, contentDescription = "Home", modifier = Modifier.size(96.dp))
Text(text = "Home", style = MaterialTheme.typography.headlineLarge)
}
}
}
}

The HomeNavigationArea class implements the INavigationArea interface and defines the display function. It uses the NavGraphBuilder to register a composable function, Home, responsible for rendering the "Home" screen.

Original App state

Stretching Navigation Capabilities with Sealed Classes and Serialization

In this section, we will examine whether the library can handle and how to extend the navigation capabilities in Jetpack Compose Multiplatform using sealed classes combined with serialization. This approach allows us to encapsulate complex navigation scenarios and pass data safely between screens. Sealed classes define a closed set of navigation destinations, ensuring type safety and clarity in navigation logic.

Sealed Classes for Navigation

Sealed classes provide a powerful way to represent different states or types in a type-safe manner. In our navigation setup, we use a sealed class to define the different screens available in the “Test” section of the app.

@Serializable
sealed class TestScreens {
@Serializable
data object TestMainScreen : TestScreens()

@Serializable
internal data class TestSecondScreen(val text: String) : TestScreens()
}
  • TestMainScreen: Represents the main screen of the “Test” section.
  • TestSecondScreen: This class represents the second screen, capable of carrying a string parameter (text), demonstrating the ability to pass data between screens. This class is internal because maybe we only want this package routed to this screen rather than others using it.

Navigation Item: TestNavigationItem

To integrate the “Test” section into the app’s navigation, we define a TestNavigationItem implementing the INavigationItem interface.

class TestNavigationItem : INavigationItem<TestScreens.TestMainScreen> {
override val icon: @Composable () -> Unit = {
Icon(Icons.Default.Science, contentDescription = "Test")
}
override val route = TestScreens.TestMainScreen
override val label: String = "Test"
override val order: Int = 1
}

Navigation Area: TestMainNavigationArea

The TestMainNavigationArea class defines how the "Test" section screens are displayed and navigated.

class TestMainNavigationArea : INavigationArea {
override fun display(navGraphBuilder: NavGraphBuilder) {
navGraphBuilder.composable<TestScreens.TestMainScreen> {
TestMain()
}
}

@Composable
private fun TestMain() {
val koin = getKoin()
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
ComposableViewModel({ koin.get<TestViewModel>() }) { viewModel ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Science, contentDescription = "Test", modifier = Modifier.size(96.dp))
Text(text = viewModel.titleText, style = MaterialTheme.typography.headlineLarge)
Button(onClick = { viewModel.onInteraction(TestMainInteractions.GoToSecondScreen) }) {
Text("Go to second screen")
}
}
}
}
}
}

ViewModel: TestViewModel

The TestViewModel handles the logic for the "Test" section, including navigation actions.

class TestViewModel(private val navController: NavHostController) : ComposeViewModel() {
internal val titleText = "Test Screen"

internal fun onInteraction(interactions: TestMainInteractions) {
when (interactions) {
TestMainInteractions.GoToSecondScreen -> {
navController.navigate(TestScreens.TestSecondScreen("Send this to the second screen"))
}
}
}

override suspend fun onClear() {
println("TestViewModel cleared")
}
}
  • onInteraction: Handles interactions, such as navigating to the second screen. It uses the NavHostController to navigate to TestSecondScreen and passes data along with the route.
  • onClear: A cleanup method called when the ViewModel is cleared, ensuring proper resource management.

Handling Navigation Arguments in TestSecondArea

Implementation of TestSecondArea

The TestSecondArea class implements the INavigationArea interface and defines the behavior for the "TestSecondScreen." This screen can receive arguments from the previous screen, demonstrating advanced navigation capabilities.

class TestSecondArea : INavigationArea {
override fun display(navGraphBuilder: NavGraphBuilder) {
navGraphBuilder.composable<TestScreens.TestSecondScreen> {
TestSecond(it.toRoute())
}
}

@Composable
private fun TestSecond(args: TestScreens.TestSecondScreen) {
val koin = getKoin()
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
ComposableViewModel({ koin.get<TestSecondScreenViewModel> { parametersOf(args.text) } }) { viewModel ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.BackHand, contentDescription = "Test", modifier = Modifier.size(96.dp))
Text(text = viewModel.title, style = MaterialTheme.typography.headlineLarge)
Button(
onClick = { viewModel.onInteraction(TestSecondScreenInteractions.GoToBack) },
enabled = viewModel.goBackAvailable,
) {
Text("Go back")
}
}
}
}
}
}

Extracting Navigation Arguments with toRoute()

The toRoute() extension function facilitates this by converting the NavBackStackEntry into the appropriate type, the generic you assign the route.

How toRoute() Works

The toRoute() function is an extension function on NavBackStackEntry that retrieves the destination's arguments and maps them to the corresponding data class defined in the sealed class hierarchy. This process ensures the data passed between screens is correctly typed and available in the target composable.

Benefits of an Encapsulated Navigation Setup

The navigation setup outlined in this article leverages a well-encapsulated design, offering several significant benefits. This approach not only simplifies the navigation logic but also enhances the scalability and maintainability of the application. Let’s explore some key advantages:

1. Separation of Concerns

By encapsulating navigation logic within the INavigationArea and INavigationItem interfaces, the navigation setup maintains a clear separation of concerns. The MainViewModel does not contain hard dependencies on specific navigation destinations, making it more focused on managing state and interactions rather than handling navigation logic. This separation allows for:

  • Simplified ViewModel Logic: The MainViewModel focuses solely on managing navigation items and areas without being burdened by detailed navigation routes or screens.
  • Modular Navigation Areas: Each navigation area, defined by INavigationArea, is responsible for its own routes and screen setup. This modular approach allows for easy addition or modification of screens without impacting the rest of the application.

2. Scalability

As the application grows, the encapsulated navigation setup makes it straightforward to expand the navigation structure. Developers can easily add new screens and navigation items without altering existing code:

  • Adding New Navigation Areas: To introduce new functionality, developers only need to create a new implementation of INavigationArea and register it with Koin. This new area can define its own routes and screens, seamlessly integrating with the existing navigation framework.
  • Adding New Navigation Items: If a new navigation icon is required, it can be registered by implementing the INavigationItem interface. This setup ensures that the navigation UI dynamically adapts to include the new item, maintaining a consistent and cohesive experience.

3. Reduced Code Complexity

The codebase remains clean and manageable by avoiding a large, monolithic navigation graph setup within the NavHost. The separation into distinct navigation areas means that the NavGraphBuilder is not cluttered with extensive route definitions:

  • Organized Navigation Logic: Each navigation area manages its own routes, reducing the need for a central location that handles all navigation logic. This organization makes it easier to find and modify specific routes as needed.
  • Improved Readability: The encapsulated design leads to more readable code, as developers can quickly understand the structure and flow of the application. Navigation logic is localized to the relevant areas, minimizing the cognitive load when making changes.

4. Flexibility and Reusability

The encapsulated design promotes flexibility and reusability across different parts of the application:

  • Reusable Components: By defining reusable interfaces and abstracting navigation logic, components can be reused in different contexts or even across different projects.
  • Flexible Navigation Changes: Navigation logic can be adjusted or extended without affecting other parts of the application. For example, changing the order of navigation items or modifying the behavior of a specific area can be done independently.

5. Enhanced Testability

With clear boundaries between navigation logic and UI, the setup becomes more testable:

  • Isolated Testing: Individual navigation areas and items can be tested in isolation, ensuring that each component functions correctly. This isolation also allows for more targeted unit tests, improving test coverage and reliability.
  • Mocking and Dependency Injection: The use of Koin for dependency injection allows for easy mocking of dependencies, facilitating robust testing scenarios. Developers can simulate various states and interactions without relying on actual navigation components.

Bugs and Observations

While the encapsulated navigation setup offers numerous benefits, there are still some quirks and potential issues, especially given the alpha state of some libraries. Below are a few bugs and observations that emerged during implementation:

1. Transition Timing Issues

One of the primary issues encountered is navigating to another area before the transition to the current area is complete. This can result in unexpected behavior, such as:

  • Incomplete Transitions: The app may not properly complete the transition to the target area, causing visual glitches or partially rendered screens.
  • Navigation Conflicts: Rapidly initiating multiple navigations can lead to conflicts, where the app tries to process multiple navigation commands simultaneously, potentially causing crashes or undefined states.

These issues are expected given the navigation library's alpha state, as it may not yet fully handle concurrent or rapid navigation commands.

2. Non-Suspendable Navigation Calls

Another surprising discovery is that some navigation commands, such as popBackStack, are not suspend functions. This means that these operations are expected to execute immediately and complete without the need for suspension, which can lead to issues when dealing with asynchronous tasks or animations:

  • Synchronous Execution: The immediate execution of navigation calls can lead to problems if other asynchronous operations are incomplete, potentially resulting in inconsistent states or race conditions.
  • Lack of Coroutine Support: Integrating navigation logic with coroutines and asynchronous workflows without suspending functions becomes more challenging. Developers must manually ensure that navigation commands are appropriately synchronized with other tasks.

3. Main Thread Dependency

The NavHostController relies heavily on being used on the main thread. This dependency can lead to issues if navigation commands are triggered from background threads:

  • UI Thread Enforcement: Navigation must always occur on the UI thread. A navigation command accidentally called from a background thread can cause crashes or undefined behavior.
  • Thread Management: Developers must ensure that all navigation-related logic is dispatched on the main thread. This can sometimes complicate the code, especially in complex applications with multiple background tasks.

Other Findings

  • Do not use the same “SerialName” because this will cause the same screen not to determine which one it should be based on Kotlin serialization.

Wrap Up

Despite the bugs and challenges encountered, I’m still committed to using this navigation library in my projects. The encapsulated design, type safety, and modularity it offers are invaluable, and I’m confident that by the time I’m ready to release something, the library will have gained significant stability. The rapid evolution of Jetpack Compose Multiplatform and the navigation components promises a bright future, and I’m eager to see how it will continue to improve.

I’m particularly looking forward to introducing NavGraph ViewModels, which will enhance state retention across navigation graphs. This feature could streamline state management and provide a better user experience. Additionally, I’m excited to explore the potential of implementing multiple backstacks in the future, offering more sophisticated navigation patterns and improving user interaction flows.

In the end, while there are still a few rough edges, the benefits of using this library far outweigh the drawbacks. Its flexibility makes it a powerful tool for developing modern, multi-platform applications. With ongoing development and community feedback, I’m optimistic that the library will continue to mature, making it an even better choice for developers.

--

--