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.