Blog Infos
Author
Published
Topics
, , , ,
Published

The recent alpha version of Navigation Compose 2.8.0-alpha08 released the ability to pass types into the navigation.

You do not need to pass strings around as in the stable version, but create your typing and take advantage of linter in programming.

If you are not familiar with compose navigation, I recommend reading my other article about it here:

https://tomas-repcik.medium.com/android-jetpack-compose-and-navigation-59f5ffa1219e?source=post_page—–337ec177026e——————————–

Dependencies

If you already use the navigation compose, it is enough to bump up the version to 2.8.0-alpha08 or higher. Moreover, we will need a kotlin serialization plugin to make our classes serializable and usable by the navigation framework.

[versions]
...
kotlinxSerializationJson = "1.6.3"
kotlinxSerialization = "1.9.0"
navigationCompose = "2.8.0-alpha08"
  
[libraries]
...
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
  
  
[plugins]
...
jetbrains-kotlin-serialization = { id ="org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinxSerialization"}

Add a plugin to the project-level build.gradle:

plugins {
  ...
  alias(libs.plugins.jetbrains.kotlin.serialization) apply false
}

and add it to the dependencies at the module level build.gradle:

plugins {
  ...
  alias(libs.plugins.jetbrains.kotlin.serialization)
  id("kotlin-parcelize") // needed only for non-primitive classes
}
  
depencencies {
  ...
  implementation(libs.androidx.navigation.compose)
  implementation(libs.kotlinx.serialization.json)
}
Navigation between 2 screens

Let’s start with a simple example: navigating between 2 screens. ScreenOne and ScreenTwo. Screens will contain only one title text and button to move forward or back.

@Composable
fun FirstScreen(onNavigateForward: () -> Unit) {
    SimpleScreen(text = "First Screen", textButton = "Go forward") {
        onNavigateForward()
    }
}

@Composable
fun SecondScreen(onNavigateBack: () -> Unit) {
    SimpleScreen(text = "Second Screen", textButton = "Go back") {
        onNavigateBack()
    }
}

@Composable
fun SimpleScreen(text: String, textButton: String, onClick: () -> Unit) {
    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize()
    ) {
        Text(text)
        Button(onClick = onClick) {
            Text(textButton)
        }
    }
}
Declaring routes

Firstly, we will declare our routes with custom type. You can use pure object instances, but the sealed class can also be used for better generalisation. Let’s declare two screens in the form of data classes:

@Serializable
sealed class Routes{
    @Serializable
    data object FirstScreen : Routes() // pure data object without any primitive

    @Serializable
    data class SecondScreen(val customPrimitive: String) : Routes() // data class with custom primitive
}

Notice the @Serializable annotation above all classes. We need to make our classes serializable, so the arguments can be passed around.

Feel free to customize your routes and primitives inside of them as you like. How to pass more complex classes will be shown later on.

Creation of routes and passing the data around

In the following example, the Activity contains NavController (to control navigation) and NavHost (to handle all possible routes).

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            TypeSafeNavigationJetpackComposeTheme {
                val navController = rememberNavController()
                NavHost(
                    navController = navController,
                    startDestination = Routes.FirstScreen, // custom type for first screen
                ) {
                    composable<Routes.FirstScreen> { // custom type as generic 
                        FirstScreen(onNavigateForward = {
                            // passing object for seconds class
                            navController.navigate(
                                Routes.SecondScreen(customPrimitive = "Custom primitive string") 
                            )
                        })
                    }
                    composable<Routes.SecondScreen> {backstackEntry ->
                        // unpacking the back stack entry to obtain our value
                        val customValue = backstackEntry.toRoute<Routes.SecondScreen>()
                        Log.i("SecondScreen", customValue.customPrimitive)
                        SecondScreen(onNavigateBack = {
                            navController.navigate(
                                Routes.FirstScreen
                            )
                        })
                    }
                }
            }
        }
    }
}

Let’s go over it step by step.

Firstly, we need to declare the controller and host for the navigation. In the new version, constructors accept custom types, not only strings. That is why, we can pass our data class and everything is fine.

val navController = rememberNavController()
NavHost(
    navController = navController,
    startDestination = Routes.FirstScreen, // custom type
) { ... }

Secondly, to declare the path in the host, the composable is used as before with a small addition of generic type, which determines, which class belongs to the destination.

composable<Routes.FirstScreen> { // custom type as generic 
    ...
}

Thirdly, to call another screen in, invoke the controller as usual, but pass your data class with the values, which you need.

navController.navigate(
    Routes.SecondScreen(customPrimitive = "Custom primitive string") 
)

Fourthly, to get your values back, use the backStackEntry to get your value and use the value for your next screen.

composable<Routes.SecondScreen> {backStackEntry ->
    val customValue = backStackEntry.toRoute<Routes.SecondScreen>()
    ...
}

And that is it! If you do not pass any complex data among the screens, you are good to go. But, if you want to pass custom data types and organize your screen a bit better, read further.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

Passing complex data classes

There might be a need to pass something more complex between the screens than primitives only. Here is an additional data class, which will become part of the input for the second screen.

@Serializable
@Parcelize
data class ScreenInfo(val route: String, val id: Int) : Parcelable

@Serializable
sealed class Routes {

    @Serializable
    data object FirstScreen : Routes()

    @Serializable
    data class SecondScreen(val screenInfo: ScreenInfo) : Routes()

}

The important difference is that we need to add @Parcelize annotation and extend the class with Parcelable at the same time.

Afterwards, the composable destination needs to know how to serialize and deserialize this custom method. It comes with a special NavType abstract class, which we need to inherit in the following manner:

val ScreenInfoNavType = object : NavType<ScreenInfo>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): ScreenInfo? =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            bundle.getParcelable(key, ScreenInfo::class.java)
        } else {
            @Suppress("DEPRECATION") // for backwards compatibility
            bundle.getParcelable(key)
        }

        
    override fun put(bundle: Bundle, key: String, value: ScreenInfo) =
        bundle.putParcelable(key, value)

    override fun parseValue(value: String): ScreenInfo = Json.decodeFromString(value)

    override fun serializeAsValue(value: ScreenInfo): String = Json.encodeToString(value)

    override val name: String = "ScreenInfo"

}

It is a mapper, where we show the navigation framework how to serialize and deserialize our custom data class. At the same time, how to pick it up from Android Bundle and put it into it.

composable<Routes.SecondScreen>(
    typeMap = mapOf(typeOf<ScreenInfo>() to ScreenInfoNavType)
) { backStackEntry ->
    val parameters = backStackEntry.toRoute<Routes.SecondScreen>()
    // use the parameters
}

Afterwards, you are ready to use the data types to any kind of your liking.

Simplified mapper

Here is a class template for the mapper, where you can supply the class type and the serializer, so you do not have to reiterate the code for the mapper every time.

class CustomNavType<T : Parcelable>(
    private val clazz: Class<T>,
    private val serializer: KSerializer<T>,
) : NavType<T>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): T? =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            bundle.getParcelable(key, clazz) as T
        } else {
            @Suppress("DEPRECATION") // for backwards compatibility
            bundle.getParcelable(key)
        }

    override fun put(bundle: Bundle, key: String, value: T) =
        bundle.putParcelable(key, value)

    override fun parseValue(value: String): T = Json.decodeFromString(serializer, value)

    override fun serializeAsValue(value: T): String = Json.encodeToString(serializer, value)

    override val name: String = clazz.name
}

With this class in hand, you can convert data classes to NavTypes with easier and less boilerplate code:

composable<Routes.SecondScreen>(
    typeMap = mapOf(typeOf<ScreenInfo>() to CustomNavType(ScreenInfo::class.java, ScreenInfo.serializer()))
) { backStackEntry ->
    val parameters = backStackEntry.toRoute<Routes.SecondScreen>()
    // use the parameters
}
Nesting the navigation

If the app contains a lot of screens, it can get quickly messy. Luckily, there is a way how to split screens into graphs, so it is not cluttered at one place.

fun NavGraphBuilder.mainGraph(navController: NavController) {
    composable<Routes.FirstScreen> {
        FirstScreen(onNavigateForward = {
            navController.navigate(
                Routes.SecondScreen()
            )
        })
    }
    composable<Routes.SecondScreen>() {
        SecondScreen(onNavigateBack = {
            navController.navigate(
                Routes.FirstScreen
            )
        })
    }
}

NavGraphBuilder can be used with custom naming to your set of screens, where you put the screen into composables as above. Otherwise, it is the same as plain composables.

NavHost then looks like this:

val navController = rememberNavController()
NavHost(
    navController = navController,
    startDestination = Routes.FirstScreen,
) {
    mainGraph(navController)
}

More about nesting the navigation can be found here:

https://betterprogramming.pub/android-jetpack-compose-and-nesting-navigation-f72df5a84691?source=post_page—–337ec177026e——————————–

Conclusion

Even though the implementation is still not straightforward, it is a step in the right direction. With specified types, you will spend less time searching for, which string has a typo in it.

Last pieces of advice:

  • separate your navigation into graphs, so you have a good preview of your app
  • do not put too much data into navigation logic — e.g. pass the ID of the item and load all the details on the next screen
  • do not pass NavController into the UI composables – keep your UI clean from navigation logic, so you can test UI more easily

If you want to add animation between the screen, I recommend reading this article:

https://tomasrepcik.dev/blog/2023/2023-10-29-android-compose-animations/?source=post_page—–337ec177026e——————————–

Thanks for reading and follow for more!

The full code example is here:

https://github.com/Foxpace/JetpackCompose-TypeSafe-Navigation?source=post_page—–337ec177026e——————————–

Resources:

https://developer.android.com/jetpack/androidx/releases/navigation?source=post_page—–337ec177026e——————————–#2.8.0-alpha08

https://developer.android.com/develop/ui/compose/navigation?source=post_page—–337ec177026e——————————–

https://developer.android.com/guide/navigation/design/kotlin-dsl?source=post_page—–337ec177026e——————————–

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu