How pass parcelable argument with new version of compose navigation?
Asked Answered
M

3

19

I had an app made with jetpack compose that worked fine until I upgraded the compose navigation library from version 2.4.0-alpha07 to version 2.4.0-alpha08 In the alpha08 version it seems to me that the arguments attribute of the NavBackStackEntry class is a val, so it can't be reassigned as we did in the 2.4.0-alpha07 version. How to solve this problem in version 2.4.0-alpha08?

My navigation component is this:

@Composable
private fun NavigationComponent(navController: NavHostController) {
    NavHost(navController = navController, startDestination = "home") {
        composable("home") { HomeScreen(navController) }
        composable("details") {
            val planet = navController
                .previousBackStackEntry
                ?.arguments
                ?.getParcelable<Planet>("planet")
            planet?.let {
                DetailsScreen(it, navController)
            }
        }
    }
}

The part where I try to make the navigation happen to the details page is in this function:

private fun navigateToPlanet(navController: NavHostController, planet: Planet) {
    navController.currentBackStackEntry?.arguments = Bundle().apply {
        putParcelable("planet", planet)
    }
    navController.navigate("details")
}

I've already tried simply applying to the recurring arguments of the navigateToPlanet function using apply but it doesn't work, the screen opens blank without any information. This is the code for my failed attempt:

private fun navigateToPlanet(navController: NavHostController, planet: Planet) {
    navController.currentBackStackEntry?.arguments?.apply {
        putParcelable("planet", planet)
    }
    navController.navigate("details")
}
Margarite answered 4/9, 2021 at 21:45 Comment(5)
This has never been the right way of doing things. Where does your first destination gets its Planet object from? What is your single source of truth (your repository, etc.) that you get your Planet objects from? Why doesn't your details destination retrieve the planet from that single source of truth?Decrescent
Yes friend, this worked well before the alpha-08 version of navigation component. The component is being called in my MainAcitivy, and I haven't posted the entire code because I believe that the abstraction of the functions informed in the problem description would be enough. But anyway, it follows the github of the project (the navigation is in alpha-07): github.com/PierreVieira/AndroidApps/tree/main/Compose/Udemy/… It's just a simple jetpack compose study project that worked well for navigation before the aplha-08 version Thanks for taking the time to help!Margarite
That project is specifically not following the documentation which exactly calls out that you shouldn't be passing Parcelables at all. Do you mind answering my questions? Where do your Planet objects come from? What is your single source of truth (your repository)?Decrescent
My planets are internally created objects. It's just a static list of fixed objects that have name, description and image information. This list of planets I transmit to my PlanetCard inside a LazyColumn as follows: @Composable private fun PlanetList(navController: NavHostController) { LazyColumn { itemsIndexed(Planet.data) { _, planet -> PlanetCard(planet, navController) } } } The github link I sent you has more details of the code as a whole. It's a simple code. It's easy to have the general overviewMargarite
The documentation you sent me says that "The Navigation library supports the following argument types:" [including parcelable objects]Margarite
D
34

As per the Navigation documentation:

Caution: Passing complex data structures over arguments is considered an anti-pattern. Each destination should be responsible for loading UI data based on the minimum necessary information, such as item IDs. This simplifies process recreation and avoids potential data inconsistencies.

You shouldn't be passing Parcelables at all as arguments and never has been a recommended pattern: not in Navigation 2.4.0-alpha07 nor in Navigation 2.4.0-alpha08. Instead, you should be reading data from a single source of truth. In your case, this is your Planet.data static array, but would normally be a repository layer, responsible for loading data for your app.

This means what you should be passing through to your DetailsScreen is not a Planet itself, but the unique key that defines how to retrieve that Planet object. In your simple case, this might just be the index of the selected Planet.

By following the guide for navigating with arguments, this means your graph would look like:

@Composable
private fun NavigationComponent(navController: NavHostController) {
    NavHost(navController = navController, startDestination = HOME) {
        composable(HOME) { HomeScreen(navController) }
        composable(
            "$DETAILS/{index}",
            arguments = listOf(navArgument("index") { type = NavType.IntType }
        ) { backStackEntry ->
            val index = backStackEntry.arguments?.getInt("index") ?: 0
            // Read from our single source of truth
            // This means if that data later becomes *not* static, you'll
            // be able to easily substitute this out for an observable
            // data source
            val planet = Planet.data[index]
            DetailsScreen(planet, navController)
        }
    }
}

As per the Testing guide for Navigation Compose, you shouldn't be passing your NavController down through your hierarchy - this code cannot be easily tested and you can't use @Preview to preview your composables. Instead, you should:

  • Pass only parsed arguments into your composable
  • Pass lambdas that should be triggered by the composable to navigate, rather than the NavController itself.

So you shouldn't be passing your NavController down to HomeScreen or DetailsScreen at all. You might start this effort to make your code more testable by first changing your usage of it in your PlanetCard, which should take a lambda, instead of a NavController:

@Composable
private fun PlanetCard(planet: Planet, onClick: () -> Unit) {
    Card(
        elevation = 4.dp,
        shape = RoundedCornerShape(15.dp),
        border = BorderStroke(
            width = 2.dp,
            color = Color(0x77f5f5f5),
        ),
        modifier = Modifier
            .fillMaxWidth()
            .padding(5.dp)
            .height(120.dp)
            .clickable { onClick() }
    ) {
       ...
    }
}

This means your PlanetList can be written as:

@Composable
private fun PlanetList(navController: NavHostController) {
    LazyColumn {
        itemsIndexed(Planet.data) { index, planet ->
            PlanetCard(planet) {
                // Here we pass the index of the selected item as an argument
                navController.navigate("${MainActivity.DETAILS}/$index")
            }
        }
    }
}

You can see how continuing to use lambdas up the hierarchy would help encapsulate your MainActivity constants in that class alone, instead of spreading them across your code base.

By switching to using an index, you've avoiding creating a second source of truth (your arguments themselves) and instead set yourself up to write testable code that will support further expansion beyond a static set of data.

Decrescent answered 5/9, 2021 at 2:39 Comment(14)
Although the document is official, is it really reasonable? After more than ten years of development, it has become a habit to pass objects between pages. Suddenly, I only pass the unique identifier, which will increase a lot of work for developers. My object is temporarily passed to the next page. The object is meaningless when the user exits this page. Do I have to store it locally? Android's design is really funnyGoltz
Moreover, sometimes introduces a lot of work which I as a developer wouldn't do. For instance, currently this library can't use file path as an argument, it simply crashes the app. Thus I have to pass an id, make a query and so on, which increases complexity and makes UX way worse.Crypt
@Crypt - you absolutely can use a file path as an argument. Just make sure you are using Uri.encode() to encode your string (Navigation will automatically decode your string).Decrescent
@Decrescent wow, that wasn't so obvious for me, thanks!Crypt
In my case I wanted to pass an Ids data class which lets me retrieve data throughout multiple sources, with this limitation imposed, people are passing JSON string which is even worsePelasgian
@Decrescent I did try using the identifier to pass parcelable but that forced an API Call to get the list of items - then iterate through the list to match the passed id and return the item from the list. The app didn't require loading time when passing the parcelable but it did need extra secs when using the identifier. What do you think about the delay?Wainscoting
but actually Navigation Component for Fragments supported passing parcelable and serializing objects without any issues, it's just not possible with Navigation Component for Compose anymore, you would need to create custom NavType, which is quite ugly. So I guess the only way is to use only primitive parameters, ids and so on, to get objects from repository later as described in this answerUxmal
how to build a local repository layer for only the 2 screen in compose?Isothermal
So the compose-navigation team wants me to redownload the data everytime I need it? If I only have the id of, say an concert, I would need to fetch that from the backend for every concert-related route and subroute? And please don't say "Roomdb"... sometimes you don't want to keep your session-scoped data in persistent storageCapri
@Capri - absolutely not. Just like how you shouldn't redownload your data after a config change or if you open a deep link into your app that is already open. A data layer does not need to involve persistent storage - it can also be an in memory cache. Okhttp has support for both disk and in memory cache, which you should be using to avoid redownloading data in every case, no matter what Navigation system you are using.Decrescent
Okhttp, memory cache.. That seems kind of overkill and incredibly error prone. How about just making it possible to pass data classes (non-serialized, by reference) via compose navigation?Capri
Sorry for being blunt, but I think this is a very poor decision to forbid passing objects. There are many cases where the data doesn't come from a repository. For example, what if I need to collect analytics data across several screens? Before I could incrementally gather the data and pass from screen to screen as an object, now Navigation forces us to introduce mutable state, which wasn't needed previously, and make sure it's managed properly. How is that a good design? We can't use Navigation with the kind of approach which forces us to create an error-prone architecture.Form
it was not recommended for client-server apps, because you can store the data in database/datastore. What about apps which don't need database at all?Uxmal
Already fetched a long string from network but can't pass this string to next screen. Uri.encode ruins this long string. So now I need to again fetch the same string from network in next screen which worsen the UXPlatino
P
12

you can pass an argument like this

val data = DestinationScreenArgument(title = "Hello")

navController.currentBackStackEntry?.savedStateHandle?.apply {
   set("detailArgument", data)
}

navController.navigate(Screen.DetailScreen.route)

and get the argument in the destination like this

val detailArgument = navController.previousBackStackEntry?.savedStateHandle?.get<DestinationScreenArgument>(
    "detailArgument"
)
Phagy answered 27/6, 2022 at 7:37 Comment(0)
L
5

A very simple and basic way to do is as below

1.First create the parcelable object that you want to pass e.g

@Parcelize
data class User(
    val name: String,
    val phoneNumber:String
) : Parcelable

2.Then in the current composable that you are in e.g main screen

 val userDetails = UserDetails(
                            name = "emma",
                             phoneNumber = "1234"
                            )
                        )
navController.currentBackStackEntry?.arguments?.apply {
                            putParcelable("userDetails",userDetails)
                        }
                        navController.navigate(Destination.DetailsScreen.route)

3.Then in the details composable, make sure you pass to it a navcontroller as an parameter e.g.

@Composable
fun Details (navController:NavController){
val data = remember {
        mutableStateOf(navController.previousBackStackEntry?.arguments?.getParcelable<UserDetails>("userDetails")!!)
    }
}

N.B: If the parcelable is not passed into state, you will receive an error when navigating back

Lovemaking answered 12/2, 2022 at 16:3 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.