17

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")
}
Pierre Vieira
  • 2,252
  • 4
  • 21
  • 41
  • 2
    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? – ianhanniballake Sep 04 '21 at 22:59
  • 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): https://github.com/PierreVieira/AndroidApps/tree/main/Compose/Udemy/projeto-planetas 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! – Pierre Vieira Sep 05 '21 at 01:20
  • 2
    That project is specifically **not** following [the documentation](https://developer.android.com/guide/navigation/navigation-pass-data#supported_argument_types) 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)? – ianhanniballake Sep 05 '21 at 01:26
  • 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 overview – Pierre Vieira Sep 05 '21 at 01:44
  • 1
    The documentation you sent me says that "The Navigation library supports the following argument types:" [including parcelable objects] – Pierre Vieira Sep 05 '21 at 01:48

3 Answers3

29

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.

Marc Plano-Lesay
  • 6,808
  • 10
  • 44
  • 75
ianhanniballake
  • 191,609
  • 30
  • 470
  • 443
  • 43
    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 funny – gaohomway Sep 20 '21 at 05:30
  • 3
    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. – MightySeal Sep 28 '21 at 15:32
  • 1
    @MightySeal - 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). – ianhanniballake Sep 28 '21 at 16:30
  • @ianhanniballake wow, that wasn't so obvious for me, thanks! – MightySeal Sep 28 '21 at 21:16
  • 4
    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 worse – M. Reza Nasirloo Oct 05 '21 at 14:29
  • 1
    @ianhanniballake 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? – Tonnie Nov 10 '21 at 17:22
  • 2
    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 answer – user924 Mar 29 '22 at 08:29
  • how to build a local repository layer for only the 2 screen in compose? – Kassadin Apr 07 '22 at 11:28
  • 1
    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 storage – MikkelT Nov 27 '22 at 18:18
  • @MikkelT - 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. – ianhanniballake Nov 27 '22 at 19:58
  • 1
    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? – MikkelT Nov 27 '22 at 20:47
  • 4
    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. – Malcolm Jan 25 '23 at 15:40
  • 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? – user924 Mar 20 '23 at 15:06
  • 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 UX – Sourav Bagchi Jul 27 '23 at 18:50
6

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"
)
Mahdi Zareei
  • 1,299
  • 11
  • 18
2

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

EmmaJoe
  • 362
  • 4
  • 9