Compose Multiplatform Navigation Solutions - JetpackCompose Navigation

Michał KonkelMichał Konkel
19 min read

In the upcoming posts, I will thoroughly explore several popular navigation libraries that are essential for effective app development. These libraries play a crucial role in managing app navigation, ensuring a smooth and intuitive user experience. Navigation is a key aspect of any app, as it helps users move seamlessly between different screens and features.

In this project, I will showcase JetpackCompose as the primary tool for app navigation. JetpackCompose is a modern toolkit for building native UI, and it offers a powerful and flexible way to handle navigation within your app. By using JetpackCompose, developers can create a more cohesive and responsive user interface.

Since the navigation is being transitioned from Android to a multiplatform project, it is important to explore how JetpackCompose can be utilized in this new context. This transition opens up new possibilities for code sharing and consistency across different platforms. We definitely should give it a try to see how it performs and what benefits it brings to the table. For more information on this topic, you can check out this official guide on Compose Navigation.

Assumptions:

  • Application should allow us to navigate from one screen to another.

  • Application should allow to pass some parameters from first to second screen.

  • Application should handle the screen rotation without loosing data.

  • Application should handle the Tab Navigation.

  • Application should handle the async operations with coroutines.

The project is available in the GitHub repository.


Dependencies

Base project setup, as always, is made with Kotlin Multiplatform Wizard. We also need to add some navigation-compose dependencies, as it is the core thing that we would like to examine. According to the documentation, we should use navigation in version 2.8.0-alpha08 and kotlinx.serialization.

Good news are that the new update of compose multiplatform is available. The version 1.7.0-alpha01that brings the Safe Args!

[versions]
compose-plugin = "1.7.0-alpha01"
navigation-compose = "2.8.0-alpha08"
serialization = "1.6.3"

[libraries]
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }

[plugins]
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Freshly added dependencies needs to be synced with the project and added to the build.gradle.kts

plugins {
    alias(libs.plugins.kotlinSerialization)
}

sourceSets {
    commonMain.dependencies {
        ...
        implementation(libs.navigation.compose)
        implementation(libs.serialization.json)
    }
}

Navigation

Getting started with navigation in Compose Multiplatform can seem daunting at first, but it's quite straightforward once you understand the basics. The key question is: how does the navigation system know where to go? The answer lies in the concept of destinations. Each destination has a unique identifier that specifies the current screen to be displayed.

In most cases, a destination will be a composable function. This function represents the UI that will be shown on the screen. By assigning a unique ID to each composable function, the navigation system can easily determine which screen to display based on user actions or other triggers.

Note:

In previous versions of navigation the route was defined as a string (you can think of it as the URL address). But now we have more robust approach where we can pass an Object/Class/KClass as a destination as log as they are serializable.

Linear Navigation

Let's start with the Screen sealed class that will hold the destinations.

@Serializable
sealed class Screen {
    @Serializable
    data object First : Screen()

    @Serializable
    data object Second : Screen()
}

Now we can create the Navigation composable function, which will hold the NavHost and navigationController. The NavHost acts as the container responsible for displaying the current destination. It ensures that the appropriate composable function is shown based on the navigation state. The navigationController is the object that manages the navigation between destinations, allowing us to move from one screen to another seamlessly.

The NavGraph is another crucial component in this setup. It maps composable destinations to their respective routes, defining the navigation paths within the app. By setting up the NavGraph, we establish the relationships between different screens and specify how users can navigate through them.

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

    NavHost(
        navController = navController,
        startDestination = Screen.First,
    ) {
        composable<Screen.First> {
            FirstScreen(navController)
        }

        composable<Screen.Second> {
            SecondScreen(navController)
        }
    }
}

With the navigation framework built, we should implement some screens. The First screen will be really simple, with a single button that will navigate to the Second screen. The Second screen will also be simple, with a button that will navigate back.

@Composable
fun FirstScreen() {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("First screen")
        Button(onClick = { /*TODO navigate to the second screen*/ }) {
            Text("Second Screen")
        }
    }
}
@Composable
fun SecondScreen() {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("Second screen")
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = { /*TODO navigate back to first screen*/ }) {
            Text("Go Back")
        }
    }
}

Now, let's fill in the gaps in the Navigation() function with the screens we created. We left some TODOs in the screens that need to be addressed. To navigate between screens, we need to pass the navController as an input to our composable functions. For navigation, we will use the navigate() method to move to the next screen and the popBackStack()method to return to the previous screen.

Button(onClick = { navController.navigate(Screen.Second) }) { Text("Second Screen") }
Button(onClick = { navController.navigate(Screen.Second) }) { Text("Second Screen") }

With the initial setup complete, the next step is to integrate the Navigation() function into the main entry point of the application. For Android, this entry point is typically the MainActivity.kt file, while for iOS, it is the MainViewController.kt file. In these files, you will initialize the navigation controller and set up the navigation graph to manage the different screens of your app.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            Navigation()
        }
    }
}
fun MainViewController() = ComposeUIViewController { Navigation() }

After running the application we should see the First scree with a button that navigates to the Second screen and a button that navigates back to the First screen.

Passing parameters

Note:

Now with Safe Args passing values is easy, but with the previous release passing arguments was tricky. Since the route looks like the URL address required arguments should be passed as a path in route and the optional as query

With the Safe Args we can pass parameters as the part of the destination object which is easy and convenient, there are two types of arguments required and optional.

Required Arguments

@Serializable
sealed class Screen {
    ...
    @Serializable
    data class Third(val greeting: String) : Screen()
}

Now we need to create the ThirdScreen composable function that will accept the greetings parameter and provide a way to pass the arguments. Since the composable<T>() is a typed function where T is the route from a KClass for the destination, we can use the .toRoute<T>() function. This extension function returns the route as an object of type T. From now on, we can extract the arguments from the passed class. As we know what type of class it is, we also know what type the arguments are.

Since we know that the passing argument is a String, we can extract it from the route and pass it to the ThirdScreen composable.

@Composable
fun Navigation() {
    NavHost(...) {
        ...
        composable<Screen.Third> {
            val args = it.toRoute<Screen.Third>()

            ThirdScreen(
                navController = navController,
                greetings = args.greeting
            )
        }
    }
}

Let's modify the First screen to navigate to the Third screen with the greeting parameter as an argument of the data class.

@Composable
fun FirstScreen(navController: NavHostController) {
    ...
    Button(
        onClick = {
            val greetings = "Hello from First Screen"
            navController.navigate(Screen.Third(greetings))
        }
    ) {
        Text("Third Screen")
    }
}

The Third screen should be built in the same way as the Second screen, but with the appropriate parameters passed.

@Composable
fun ThirdScreen(navController: NavHostController, greetings: String) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("Third screen")
        Spacer(modifier = Modifier.height(16.dp))
        Text("Greetings: $greetings")
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = { navController.popBackStack() }) {
            Text("Go Back")
        }
    }
}

Optional Arguments

For optional arguments we will follow same idea as with required arguments. Let's create the Fourth screen that will have two optional arguments name and surname.

Without the safe args
With the Safe Args, it's easy to pass optional arguments. In previous versions, we needed to use query parameters where arguments should be passed in the route and preceded by a ? character following the pattern ?key=value, and if you want to pass multiple optional parameters, they have to be separated with the & character ?key1=value1&key2=value2. Also, the optional parameters have to be provided with a default value.

With the Safe Args, it's easy to pass optional arguments. In previous versions, we needed to use query parameters where arguments should be passed in the route and preceded by a ? character following the pattern ?key=value, and if you want to pass multiple optional parameters, they have to be separated with the & character ?key1=value1&key2=value2. Also, the optional parameters have to be provided with a default value.

@Serializable
data class Fourth(val name: String, val surname: String? = null) : Screen()

Now we need to create the Fourth screen composable function that will accept the name and surname parameters.

composable<Screen.Fourth> {
    val args = it.toRoute<Screen.Fourth>()

    FourthScreen(
        navController = navController,
        name = args.name,
        surname = args.surname
    )
}
@Composable
fun FourthScreen(navController: NavHostController, name: String, surname: String?) {
    ...
}

Navigation is as simple as it can possibly be:

fun FirstScreen(navController: NavHostController) {
    Button(onClick = { navController.navigate(Screen.Fourth(name = "John", surname = "Doe")) }) {
        Text("John Doe Screen")
    }

    Button(onClick = { navController.navigate(Screen.Fourth(name = "Michael")) }) {
        Text("Michael Screen")
    }
}

Nested Navigation

In the case of complex applications, splitting navigation into smaller parts is a good idea. Currently, we have one NavHost with all screens originating from the same place. We can divide the navigation into smaller parts that will be encapsulated according to their purpose. Let's create a Fifth and Sixth screen that will be separate from the main navigation and will be accessible only from the Third screen. The graph for such screens will look like this:

With such structured navigation, we can easily manage the navigation and the screens. We can create a NestedNavigation composable function that will hold the NavHost and navigationController for the nested navigation. When we close the Third screen, navigation will remove all its children from the backstack, and they won't be accessible anymore. It's a great tool for structuring processes in the application—when a process is finished (for example, a signup, a payment, or a tutorial).

If you read my post about navigation Decompose you can, you will see similarities in the approach. In Decompose, every component can have its own stack and manage it.

Adding the nested navigation graph is accomplished by using the navigation() function within the NavHost composable. The navigation() function requires two key parameters: startDestination and route. The route serves as a unique identifier for the nested navigation graph, ensuring it is distinct from other navigation graphs within the application. This identifier can be a unique name or an object.

The startDestination parameter specifies the initial screen that will be displayed when the nested navigation graph is activated. This is the entry point for the nested navigation, guiding users to the first screen they will interact with in this part of the app.

@Serializable
sealed class Route {
    @Serializable
    data object Root : Route()

    @Serializable
    data object Main : Route()

    @Serializable
    data object Nested : Route()
}

@Composable
fun Navigation() {

    NavHost(
        navController = navController,
        startDestination = Screen.First,
        route = Route.Root::class
    ) {
        ...
        navigation(
            startDestination = Screen.Fifth,
            route = Route.Nested::class
        ) {
            composable<Screen.Fifth> {
                FifthScreen(navController)
            }

            composable<Screen.Sixth> {
                SixthScreen(navController)
            }

            composable<Screen.Seventh> {
                SeventhScreen(navController)
            }
        }
    }
}

To clarify the navigation, we can split the Navigation() function into separate components. The first will handle the main graph, and the second will handle the nested graph. To do this, we need to create extension functions for NavGraphBuilder that will hold specific screens, resulting in the following graph changes:

fun NavGraphBuilder.nested(navController: NavHostController) {
    navigation(
        startDestination = Screen.Fifth,
        route = Route.Nested::class
    ) {
        // code for nested navigation
    }
}
fun NavGraphBuilder.main(navController: NavHostController) {
    navigation(
        startDestination = Screen.First,
        route = Route.Main::class
    ) {
        // code for main navigation
    }
}
@Composable
fun Navigation() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Route.Main,
        route = Route.Root::class
    ) {
        main(navController)
        nested(navController)
    }
}
fun FirstScreen(navController: NavHostController) {
    ...
    Button(onClick = { navController.navigate(Route.Nested) }) {
        Text("Nested")
    }
}

With such changes, we can still navigate between graphs. There is no problem calling the Fourth screen from the nested graph. Let's try to achieve that by adding a way for the Sixth screen to open a Fourth screen.

@Composable
fun SixthScreen(navController: NavHostController) {
    ...
    Button(onClick = {
        navController.navigate(Screen.Fourth("John", "Doe")) {
            Text("John Doe Screen")
        }
    }

We can now modify the Fourth screen and add a button that will navigate back to the main graph instead of popping back the stack. This way, we can close the nested graph immediately and remove all its child screens from the backstack. The navigate() builder has a popUpTo() method that allows us to remove destinations from the backstack. We can pass the destination to which we want to pop back. There is also the inclusive parameter to remove the passed destination from the backstack as well.

@Composable
fun FourthScreen(...) {
    ...
    Button(
        onClick = {
            navController.navigate(Route.Main) {
                popUpTo(Route.Main)
            }
        }
    ) {
        Text("MAIN")
    }
}

We can close the nested graph even quicker, while opening the Fourth screen from the Sixth all we need to do is use popUpTo() method with the Route.Nested parameter.

@Composable
fun SixthScreen(...) {
    ...
    Button(onClick = {
        navController.navigate(Screen.Fourth("John", "Doe")) {
            popUpTo(Route.Nested)
        }
    }) {
        Text("John Doe Screen")
    }
}

You can combine these functions in various ways to achieve the desired behavior. For instance, you can pop a screen before navigating to a new one, or you can remove entire navigation graphs from the backstack. This flexibility allows you to manage your app's navigation flow precisely.

Bottom Navigation

Yet another thing that is widely common in mobile apps nowadays is the bottom navigation. Let's extend the project with one more feature! We need to add three new screens: the Eighth screen, which will be the main screen that holds the bottom menu and is a container for the tabs, and the Ninth screen and Tenth screen. Inside the Eighth screen, we will add a new NavHost that will build its own graph and handle switching tabs. We will also use the BottomNavigation Jetpack Compose control to create the bottom bar view and its items.

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

    Scaffold(
        modifier = Modifier.fillMaxSize(),
        bottomBar = {
            // TODO: add bottom navigation
        },
    ) { innerPadding ->
        NavHost(
            modifier = Modifier.padding(innerPadding),
            navController = navController,
            startDestination = Screen.Eighth.Tab.Home,
        ) {
            composable<Screen.Eighth.Tab.Home> {
                NinthScreen()
            }

            composable<Screen.Eighth.Tab.Edit> {
                TenthScreen()
            }
        }
    }
}

The new NavHost has its own navController and startDestination. When we enter the screen, the item displayed as the first tab will always be the Ninth screen. The local navController is used to navigate between tabs. The BottomNavigation control is quite helpful. It will render the bottom bar with necessary elements such as icon, label, and selected state, and even adds a slight dim to the selected item. But to do so, we need to provide information about the tabs. Like in every other type of navigation, the displayed screens need their own route/destination, so we need to create a new sealed class for the tabs inside the current Screen.kt file.

@Serializable
sealed class Screen {
    ...
    @Serializable
    data object Eighth : Screen() {
        @Serializable
        sealed class Tab(val icon: ICON, val label: String) : Screen() {
            @Serializable
            data object Home : Tab(icon = ICON.HOME, label = "Home")

            @Serializable
            data object Edit : Tab(icon = ICON.EDIT, label = "Edit")

            @Serializable
            enum class ICON {
                HOME, EDIT
            }
        }
    }
}

Now we can create the bottom navigation tabs.

@Composable
private fun BottomBar(navController: NavHostController) {
    val tabs = listOf(
        Screen.Eighth.Tab.Home,
        Screen.Eighth.Tab.Edit,
    )

    val backstackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = backstackEntry?.destination

    BottomNavigation {
        tabs.forEach { tab ->
            TabItem(tab, currentDestination, navController)
        }
    }
}

The BottomBar is a composable function that is responsible for managing the elements within the tabs container. Inside this function, we need to define the elements, referred to as tabs, that will be displayed in the bottom bar. These tabs are specified as a list, which includes Screen.Eighth.Tab.Home and Screen.Eighth.Tab.Edit.

To keep track of the current screen, we utilize the currentBackStackEntryAsState() function. This function helps us obtain the current destination by monitoring changes in the navController. Whenever the navController undergoes changes due to navigation actions like navigate() or pop(), the currentBackStackEntryAsState() function updates the value, triggering a recomposition of the UI. This means that the top entry on the backstack is returned, allowing us to determine what is currently displayed on the screen.

By retrieving the destination from the backstack entry, we can access detailed information about the current screen. This setup ensures that the bottom navigation bar is dynamic and responsive to changes in the navigation state.

The BottomNavigation control takes a few parameters, and the last one is the content: @Composable RowScope.() -> Unit, which will be responsible for creating the bottom navigation view. For each tab that we want to display, we should create a proper UI element. We can create an extension function for RowScope that will be responsible for providing the BottomNavigationItem for each tab.

@Composable
private fun RowScope.TabItem(
    tab: Screen.Eighth.Tab,
    currentDestination: NavDestination?,
    navController: NavHostController,
) {
    BottomNavigationItem(
        icon = { Icon(imageVector = tab.icon.toVector(), contentDescription = "navigation_icon_${tab.label}") },
        label = { Text(tab.label) },
        selected = currentDestination?.hierarchy?.any { it == tab } == true,
        onClick = {
            navController.navigate(tab) {
                navController.graph.startDestinationRoute?.let { popUpTo(it) }
                launchSingleTop = true
            }
        },
    )
}

// helper function for transforming enum to vectorIcon
private fun Screen.Eighth.Tab.ICON.toVector() = when (this) {
    Screen.Eighth.Tab.ICON.HOME -> Icons.Default.Home
    Screen.Eighth.Tab.ICON.EDIT -> Icons.Default.Edit
}

The selected state is calculated by checking if the current destination is the same as the tab of the current item. The onClick action is responsible for navigating to the clicked tab.

Since we want only one active screen inside the tabs container we need to pop it. This will cause dropping other element from the back stack. We can also add the launchSingleTop which will ensure that the tab is not preserved, and will be recreated with every click.

Last thing to do is to add an entry point in the main graph.

fun NavGraphBuilder.main(navController: NavHostController) {
    ...
    composable<Screen.Eighth> {
        EighthScreen()
    }
}
@Composable
fun FirstScreen(navController: NavHostController) {
    ...
    Button(onClick = { navController.navigate(Screen.Eighth) }) {
        Text("Bottom")
    }
}

Coroutines

The last topic we will cover in this post is asynchronous operations. As mentioned earlier, various navigation libraries like Decompose, Appyx, and Voyager offer their own business logic container objects where asynchronous operations can be managed effectively.

In the context of Jetpack Compose, we will be utilizing ViewModels to manage these asynchronous tasks. ViewModels have recently been integrated into the Compose Multiplatform library, making them a versatile choice. This integration allows us to leverage the full power of ViewModels while building cross-platform applications with Compose.

It's important to note that even though Appyx and Voyager come with their own mechanisms for handling business logic and async operations, they still provide the flexibility to use ViewModels if you prefer. This means you can choose the approach that best fits your project's architecture and requirements.

The first thing to add is the proper dependency according to the documentation.

[versions]
common-viewmodels = "2.8.0"

[libraries]
viewmodels-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "common-viewmodels" }
commonMain.dependencies {
    ...
    implementation(libs.viewmodels.compose)
}

The general usage of the ViewModel in Jetpack Compose is straightforward and bears a strong resemblance to the traditional Android approach. To begin with, we need to create a class that extends the ViewModel. This class will serve as the container for our business logic and asynchronous operations.

Within this ViewModel class, we have access to the viewModelScope, which is a specialized CoroutineScope tied to the lifecycle of the ViewModel. The significance of the viewModelScope is that it ensures any coroutines launched within it are automatically canceled when the ViewModel is destroyed. This lifecycle-aware behavior is crucial for managing resources efficiently and avoiding memory leaks.

For instance, if we need to perform a network request or a database operation, we can launch these tasks within the viewModelScope. This makes it easy to handle asynchronous operations without worrying about manually canceling coroutines when the ViewModel is no longer needed. Additionally, this setup allows us to write cleaner and more maintainable code, as the ViewModel encapsulates the logic and handles the lifecycle management for us.

Let's create a simple ViewModel that will handle the countdown timer and the corresponding Eleventh screen for displaying the values.

class EleventhViewModel : ViewModel() {
    private val _countDownText = MutableStateFlow("")
    val countDownText: StateFlow<String> = _countDownText.asStateFlow()

    init {
        viewModelScope.launch {
            for (i in 10 downTo 0) {
                _countDownText.value = i.toString()
                delay(1000)
            }
        }
    }
}

To use the ViewModel, we can utilize the viewModel function provided by the lifecycle-viewmodel-compose library. The function returns an existing view model or creates a new one in the scope. The created ViewModel is bound to the viewModelStoreOwner and will be retained as long as the scope is alive.

The source code of the Eleventh screen is quite simple and looks like all previously created screens.

@Composable
fun EleventhScreen(
    navController: NavHostController,
    viewModel: EleventhViewModel = viewModel { EleventhViewModel() },
) {
    Column(...) {
        ..
        Countdown(viewModel)
    }
}

@Composable
private fun Countdown(viewModel: EleventhViewModel) {
    val countdownText = viewModel.countDownText.collectAsState().value
    Text("COUNTDOWN: $countdownText")
}

After adding the created screen to the navigation, we can launch it from the First screen end examine the countdown functionality.

Summary

In this post, we covered the basics of Jetpack Compose navigation. The library is well-documented, and you will surely find many posts, videos, and other resources to help you understand the navigation better. Jetpack Compose navigation is a powerful tool that allows you to create complex navigation structures with ease. The library has been available for Android for a long time, and now it's available for multiplatform projects. The multiplatform version of the library is still in alpha version, but in my opinion, it's ready to use in production. The library is a great choice for developers who want to create modern and complex navigation structures in their applications.

In my previous posts, I covered the Decompose, Appyx, and Voyager navigation libraries. Now I can say that my personal favorite is the JetpackCompose navigation, as it will be the most popular and widely used in the future, with great support from the community and the Jetbrains team. The Compose Multiplatform is growing rapidly, and I'm sure that in the near future, we will see a lot of great libraries and tools that will help us create modern and complex applications. But all of them are great, and you should choose what fits your needs best.

When I began writing a post about Fullstack Kotlin Developer, I was thinking about the navigation library that I would use in the project. As there was no official or suggested way of handling the navigation in the multiplatform project, I chose the Decompose library. However, after adopting the JetpackCompose navigation and the Viewmodels to Compose Multiplatform, I would definitely recommend it as the best choice for your project, and I'm sure that I will use it in all of my future projects as well.

In this project, I will explore several popular navigation libraries for app development, emphasizing the use of JetpackCompose for managing navigation in a multiplatform context. I will cover linear navigation, passing parameters safely, nested navigation, bottom navigation, and handling asynchronous operations with coroutines using ViewModels. The content highlights how to set up navigation components, manage navigation state, and integrate bottom navigation into your app, along with a practical guide on using ViewModels for managing asynchronous tasks. This article aims to demonstrate how JetpackCompose is a versatile and modern solution for implementing complex navigation structures in your applications.

0
Subscribe to my newsletter

Read articles from Michał Konkel directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Michał Konkel
Michał Konkel