Compose Multiplatform Navigation Solutions - JetpackCompose Navigation
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-alpha01
that brings theSafe 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 anObject/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 asquery
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
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.
Subscribe to my newsletter
Read articles from Michał Konkel directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by