Compose Multiplatform Navigation: A Comprehensive Guide

Compose Multiplatform allows you to build UI applications that can run on various platforms, including Android, iOS, desktop, and web, all from a single codebase. Navigation is a critical aspect of any application, and managing navigation across different platforms in a Compose Multiplatform project can be challenging. This comprehensive guide covers how to implement navigation in Compose Multiplatform with best practices, code examples, and step-by-step instructions.

What is Compose Multiplatform?

Compose Multiplatform is a declarative UI framework based on Kotlin that enables you to share UI code across multiple platforms. Built by JetBrains, it leverages the power of Kotlin and Jetpack Compose to streamline the development process, allowing developers to write code once and deploy it to different platforms.

Why is Navigation Important in Multiplatform Apps?

Navigation helps users move between different screens or views within an application. A well-designed navigation system ensures a smooth and intuitive user experience. In a multiplatform context, consistent and platform-appropriate navigation is vital for maintaining a coherent user experience across different devices.

Approaches to Multiplatform Navigation in Compose

There are several ways to implement navigation in Compose Multiplatform. Let’s explore some common approaches:

1. Using Jetpack Compose Navigation Library

While Jetpack Compose’s navigation library (androidx.navigation:navigation-compose) is primarily designed for Android, you can adapt it for multiplatform projects with some modifications. This involves abstracting platform-specific code into interfaces or platform-agnostic classes.

Step 1: Set up a Multiplatform Project

First, ensure you have a multiplatform project set up. You can create one using the Kotlin Multiplatform wizard in IntelliJ IDEA or Android Studio.

Step 2: Add Dependencies

Add the necessary dependencies in your build.gradle.kts or build.gradle files:

In your commonMain source set:


kotlin {
    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation("org.jetbrains.compose.ui:ui-tooling-preview-desktop:1.5.1")
                implementation("androidx.compose.runtime:runtime:1.6.1")
                implementation("androidx.compose.ui:ui:1.6.1")
                implementation("androidx.compose.material:material:1.6.1")
            }
        }
    }
}

In your androidMain source set:


dependencies {
    implementation "androidx.core:core-ktx:1.12.0"
    implementation "androidx.appcompat:appcompat:1.6.1"
    implementation "com.google.android.material:material:1.11.0"

    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.2"
    implementation "androidx.activity:activity-compose:1.9.0"

    implementation "androidx.compose.ui:ui"
    implementation "androidx.compose.ui:ui-tooling-preview"
    debugImplementation "androidx.compose.ui:ui-tooling"
    implementation "androidx.compose.material:material"

    implementation "androidx.navigation:navigation-compose:2.7.7"

}
Step 3: Define Navigation Graph

Create a composable function that defines your navigation graph using the NavHost.


import androidx.compose.runtime.Composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.compose.material.Text
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.NavController
import androidx.compose.material.Button

// Define the screens
sealed class Screen(val route: String) {
    object Home : Screen("home")
    object Details : Screen("details") {
        fun createRoute(itemId: Int): String = "details/$itemId"
    }
}

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = Screen.Home.route) {
        composable(Screen.Home.route) {
            HomeScreen(navController)
        }
        composable("details/{itemId}") { backStackEntry ->
            val itemId = backStackEntry.arguments?.getString("itemId")?.toIntOrNull()
            if (itemId != null) {
                DetailsScreen(itemId = itemId)
            } else {
                Text("Error: Item ID not found")
            }
        }
    }
}

@Composable
fun HomeScreen(navController: NavController) {
    Button(onClick = { navController.navigate(Screen.Details.createRoute(123)) }) {
        Text("Go to Details")
    }
}

@Composable
fun DetailsScreen(itemId: Int) {
    Text("Details for item: $itemId")
}

@Preview
@Composable
fun PreviewAppNavigation() {
    AppNavigation()
}

Step 4: Abstract Platform-Specific Navigation

Since androidx.navigation is Android-specific, abstract navigation logic using Kotlin’s expect/actual mechanism.

Create an expect interface in the commonMain:


// commonMain
expect interface MultiplatformNavController {
    fun navigate(route: String)
    fun popBackStack()
}

Implement the actual class in the androidMain:


import androidx.navigation.NavController

// androidMain
actual class MultiplatformNavController(private val navController: NavController) {
    actual fun navigate(route: String) {
        navController.navigate(route)
    }

    actual fun popBackStack() {
        navController.popBackStack()
    }
}

Usage example:


@Composable
fun AndroidScreen(navController: NavController) {
    val multiplatformNavController = remember { MultiplatformNavController(navController) }

    Button(onClick = { multiplatformNavController.navigate("details") }) {
        Text("Go to Details")
    }
}

@Composable
fun AppNavigation() {
    val navController = rememberNavController()
    val multiplatformNavController = remember { MultiplatformNavController(navController) }

    NavHost(navController = navController, startDestination = "home") {
        composable("home") {
            HomeScreen(navController = multiplatformNavController)
        }
        composable("details") {
            Text("Details Screen")
        }
    }
}

expect interface MultiplatformNavController {
    fun navigate(route: String)
    fun popBackStack()
}

2. Using Third-Party Multiplatform Navigation Libraries

Several third-party libraries are designed to provide navigation solutions for Compose Multiplatform. One notable library is Decompose.

Decompose

Decompose is a Kotlin Multiplatform library for managing hierarchical navigation state in a lifecycle-aware manner.

Step 1: Add Dependency

Include Decompose in your build.gradle.kts:


dependencies {
    implementation("com.arkivanov.decompose:decompose:2.3.0")
    implementation("com.arkivanov.decompose:compose:2.3.0") // For Compose integration
}
Step 2: Define a Component

A component in Decompose represents a screen or a logical unit with its lifecycle and navigation state.


// commonMain
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.value.Value
import kotlinx.serialization.Serializable

class RootComponent(
    componentContext: ComponentContext
) : ComponentContext by componentContext {

    private val navigation = StackNavigation()

    private val stack =
        childStack(
            source = navigation,
            initialConfiguration = Config.Home,
            childFactory = ::createChild,
        )

    val state: Value> = stack

    private fun createChild(config: Config, componentContext: ComponentContext): ComponentContext =
        when (config) {
            is Config.Home -> HomeComponent(componentContext)
            is Config.Details -> DetailsComponent(componentContext)
        }

    fun navigateToDetails() {
        navigation.push(Config.Details)
    }

    @Serializable
    sealed class Config {
        @Serializable
        object Home : Config()

        @Serializable
        object Details : Config()
    }
}

class HomeComponent(
    componentContext: ComponentContext
) : ComponentContext by componentContext {
    fun onClick() {
        // navigate
    }
}

class DetailsComponent(
    componentContext: ComponentContext
) : ComponentContext by componentContext
Step 3: Implement Navigation

Use the StackNavigation to manage the navigation stack.


import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun RootContent(root: RootComponent) {
    // val childStack: ChildStack<*, RootComponent.Config> = root.stack.subscribeAsState()
    // val activeComponent = childStack.active.instance
}

@Preview
@Composable
fun PreviewRootContent() {
    // RootContent()
}
Step 4: Render UI

Render the appropriate UI based on the current navigation state.


@Composable
fun App() {
    val rootComponent = rememberRootComponent()
    RootContent(root = rootComponent)
}

@Composable
fun rememberRootComponent(): RootComponent {
    //val lifecycle = LocalLifecycleOwner.current.lifecycle
    return remember {
        // val componentContext = DefaultComponentContext(lifecycle = lifecycle)
        // RootComponent(componentContext = componentContext)
        TODO("Provide the implementation of component context")
    }
}

3. Custom Navigation Solution

You can also build a custom navigation solution tailored to your application’s specific needs. This approach provides the most flexibility but requires more effort.

Step 1: Define Navigation State

Create a data class or sealed class to represent the different screens or states in your application.


sealed class ScreenState {
    object Home : ScreenState()
    data class Details(val itemId: Int) : ScreenState()
}
Step 2: Create a Navigation Controller

Implement a navigation controller to manage the current screen state and handle transitions between screens.


import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class NavigationController {
    private val _currentScreen = MutableStateFlow(ScreenState.Home)
    val currentScreen: StateFlow = _currentScreen

    fun navigateTo(screen: ScreenState) {
        _currentScreen.value = screen
    }

    fun goBack() {
        _currentScreen.value = ScreenState.Home // Implement proper back stack if needed
    }
}
Step 3: Implement UI Rendering

Render the appropriate UI based on the current screen state in your composable functions.


import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun AppContent(navigationController: NavigationController) {
    val currentScreenState = navigationController.currentScreen.collectAsState()

    when (val screen = currentScreenState.value) {
        is ScreenState.Home -> HomeScreen(navigationController)
        is ScreenState.Details -> DetailsScreen(itemId = screen.itemId)
    }
}

@Composable
fun HomeScreen(navigationController: NavigationController) {
    Button(onClick = { navigationController.navigateTo(ScreenState.Details(123)) }) {
        Text("Go to Details")
    }
}

@Composable
fun DetailsScreen(itemId: Int) {
    Text("Details for item: $itemId")
}

@Composable
fun App() {
    val navigationController = remember { NavigationController() }
    AppContent(navigationController)
}

@Preview
@Composable
fun PreviewAppContent() {
    val navigationController = remember { NavigationController() }
    AppContent(navigationController)
}

Best Practices for Compose Multiplatform Navigation

  • Abstraction: Abstract platform-specific code using expect/actual to keep the core navigation logic platform-agnostic.
  • State Management: Use a state management solution like MutableStateFlow to manage the current navigation state.
  • Third-Party Libraries: Consider using third-party libraries like Decompose for advanced navigation features.
  • Consistency: Ensure a consistent navigation experience across different platforms by adhering to platform-specific UI/UX guidelines.
  • Testing: Implement thorough testing strategies to ensure navigation works correctly on all supported platforms.

Conclusion

Implementing navigation in Compose Multiplatform requires careful planning and consideration of the target platforms. By using a combination of Jetpack Compose navigation components, third-party libraries, and custom solutions, you can create a robust and consistent navigation experience across Android, iOS, desktop, and web. Leveraging best practices such as abstraction, state management, and platform-specific UI/UX guidelines will help you build a successful Compose Multiplatform application.