25

The app I'm building uses compose navigation with routes. The challenge is that the start destination is dynamic.

Here is a minimal example:

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

        setContent {
            val navController = rememberNavController()

            NavHost(
                navController = navController,
                startDestination = "dynamic/1", // doesn't work
                // startDestination = "static", // workaround
            ) {
                composable(
                    route = "dynamic/{$ARG_ID}",
                    arguments = listOf(navArgument(ARG_ID) { type = NavType.StringType }),
                ) {
                    val id = it.arguments?.getString(ARG_ID)
                    Text("dynamic route, received argument: $id!")
                }
                // part of the workaround
                // composable(
                //  route = "static",
                // ) {
                //  LaunchedEffect(this) {
                //      navController.navigate("dynamic/1")
                //  }
                // }
            }
        }
    }

    companion object
    {
        const val ARG_ID = "id"
    }
}

The app crashes with

java.lang.IllegalArgumentException: navigation destination route/1 is not a direct child of this NavGraph

The problem only exists if the "dynamic" route is used as start destination. This can be verified by using startDestination = "static".

Although, the "static" route workaround works I'm looking for a solution without it because it kind of obfuscates the code and also creates an additional entry in the back stack.

-> Full code sample to reproduce the issue

Related SO questions

Edit:

I want to stress that the original sample used to not contain the "static" composable. I only added the "static" composable to have a working startDestination and to prove that the "dynamic" composable can be navigated to.

Update:

Even switching to the query parameter syntax for optional arguments, providing a default value, and setting the start destination without any argument does not work.

The following variation

NavHost(
    navController = navController,
    startDestination = "dynamic",
) {
    composable(
        route = "dynamic?$ARG_ID={$ARG_ID}",
        arguments = listOf(navArgument(ARG_ID) { type = NavType.StringType; defaultValue = "1" }),
    ) {
        val id = it.arguments?.getString(ARG_ID)
        Text("dynamic route, received argument: $id!")
    }
}

Leads to the exception

java.lang.IllegalArgumentException: navigation destination dynamic is not a direct child of this NavGraph
Peter F
  • 3,633
  • 3
  • 33
  • 45
  • Does this answer your question? [Compose Navigation - navigation destination ... is not a direct child of this NavGraph](https://stackoverflow.com/questions/69038660/compose-navigation-navigation-destination-is-not-a-direct-child-of-this-na) (answer from Compose maintainer) – Phil Dukhov Dec 18 '21 at 14:03
  • @PhilipDukhov No, unfortunately it doesn't. Reading all comments the accepted answer seems to not fully resolve the original issue there... or did you catch something I can apply to my sample? I'm going to add the question to my related questions list. – Peter F Dec 18 '21 at 14:11
  • 1
    Rather than have `static` call `navController.navigate("dynamic/1")`, just have it call your real content composable for the dynamic scenario, passing in `"1"` as a hard-coded value, rather than getting it from the arguments `Bundle`. That avoids the additional entry in the back stack, and it would not seem to be especially complex. – CommonsWare Dec 18 '21 at 14:18
  • @CommonsWare Thanks! Could you please elaborate where you'd call `navController.navigate("dynamic/1")` and what you would set as `startDestination`? – Peter F Dec 18 '21 at 14:23
  • 1
    "Could you please elaborate where you'd call navController.navigate("dynamic/1")" -- I suggested *not* doing that. Keep the `static` route, but have it call the same function that your `dynamic` route does, just with a hard-coded `"1"` parameter, rather than getting the value from the arguments bundle. – CommonsWare Dec 18 '21 at 14:55
  • @CommonsWare Thank you, I finally got your point. What I still don't get is why start destinations with arguments don't work. I see this as an unpredictable limitation or simply a bug. The workaround with a "static" route requires to take this additional route into account in the back stack, e.g. when using `popUpTo()` and for the decision which bottom bar item needs to be marked selected given the current route. – Peter F Dec 18 '21 at 15:09
  • OK, have you tried [using `defaultValue`](https://developer.android.com/reference/kotlin/androidx/navigation/NavArgument#defaultValue()) on your `dynamic` argument, to perhaps avoid setting a value when it is your start destination? That might require you to make it an [optional argument](https://developer.android.com/jetpack/compose/navigation?hl=en#nav-with-args). – CommonsWare Dec 18 '21 at 15:16
  • @CommonsWare I extended the original post with a sample using optional arguments. This stills throws the same exception. "dynamic" only works as `startDestination` if I remove any arguments from the `composable` `route` but then the route can't be navigated to with arguments - all call-side arguments are ignored if only declared in the `arguments` list but not mentioned in the route. – Peter F Dec 18 '21 at 15:42
  • 4
    The string `"dynamic"` you use for your `startDestination` needs to match, character by character, the exact string you use in your `composable` `route`: it needs to be `"dynamic?$ARG_ID={$ARG_ID}"`. – ianhanniballake Dec 18 '21 at 20:49
  • 1
    Thank you so much @ianhanniballake! I always assumed that `startDestination` needed to be a concrete route (containing the arguments' values). Wouldn't it be a cool feature if that was possible? Then, arguments wouldn't require a default value and could be declared as truly mandatory. – Peter F Dec 19 '21 at 10:34
  • this is a crazy question :) – MohammadBaqer Oct 15 '22 at 12:09

4 Answers4

33

Full credit goes to ianhanniballake, who explained the solution to me in a comment. I'm going to show the working version of my code sample here.

The big insight to me was:

startDestination must not match a composable route in the sense of pattern matching but it must be exactly the same string.

That means an argument can't be set via startDestination directly but has to be set via the argument's defaultValue.

Here is the working sample:

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

        setContent {
            val navController = rememberNavController()

            NavHost(
                navController = navController,
                // 1st change: Set startDestination to the exact string of route
                startDestination = "dynamic/{$ARG_ID}", // NOT "dynamic/1", provide arguments via defaultValue
            ) {
                composable(
                    route = "dynamic/{$ARG_ID}",
                    // 2nd change: Set startDestination argument via defaultValue
                    arguments = listOf(navArgument(ARG_ID) { type = NavType.StringType; defaultValue = "1" }),
                ) {
                    val id = it.arguments?.getString(ARG_ID)
                    Text("dynamic route, received argument: $id!")
                }
            }
        }
    }

    companion object
    {
        const val ARG_ID = "id"
    }
}

The approach equally works with the argument provided in the form of a query parameter.

To be honest, I see this as a small limitation because the start route now dictates what has to be the defaultValue. I might want to set a different defaultValue or none at all. Yet, in most cases this should be the most elegant solution.

Peter F
  • 3,633
  • 3
  • 33
  • 45
  • 3
    @ianhanniballake would be great to have that info in the official docs – M Tomczynski Aug 05 '22 at 13:03
  • 2
    Thanks for this answer, your assumption was also my intuitive sense; if Compose Navigation is supposed to operate similarly to URL navigation, I think it would work in that way. I agree with @MTomczynski, this edge case should be in the official documentation. – whoisthemachine Nov 05 '22 at 16:33
  • 1
    end of the day! we can't change the default value dynamically. for __startDestination__ – Mohd Qasim Jan 04 '23 at 12:15
1

All credit goes to ianhanniballake and Peter. In my case I didn't add any additional (mandatory key/optional key) in route for the argument data. I kept the route clean like below:

Nav graph:

 navigation(route = Route.Root.route, startDestination = Route.SubmitForm.route) {

    composable(
        route = Route.SubmitForm.route,
        arguments = listOf(
            navArgument(ARG_KEY) {
                type = NavType.StringType
                defaultValue = JsonConverter.toJson(user, User::class.java)
            },
        )
    )
}

Route sealed class:

sealed class Route(val route: String) {
    object MyRoute : Route("$ROOT/submit-form")
}

And in view model just get the data like this:

@HiltViewModel
class MyViewModel @Inject constructor(
    stateHandle: SavedStateHandle,
) : ViewModel {

    lateinit var user

    init {
        user = stateHandle.get<String>(ARG_NAME) // Supported data types
    }
}

It worked for me.

Ariful Haque
  • 183
  • 1
  • 9
1

If your first screen requires dynamic data fetched from an api or something like that, the default value won't do it.

It's not the cleanest solution. But you can compose an empty dummy screen first, after it loads you can navigate to the actual home screen


    var isPlaced by remember { mutableStateOf(false) }

    LaunchedEffect(isPlaced) {
        if (isPlaced) {
            navController.navigate("home/some-argument") {
                launchSingleTop = true
            }
        }
    }


    NavHost(
        navController = navController,
        startDestination = "start"
        ) {

            composable("start") {
                Box(
                    modifier = Modifier.onGloballyPositioned {
                        isPlaced = true
                    }
                ) {}
            }

            composable(
                "home/{argument}",
            ) { backStackEntry ->
                backStackEntry.arguments?.getString(argument)?.let {
                HomeScreen(it)
                }
            }
        }
Aezakme
  • 11
  • 2
0

should not be using dynamic route value in "startDestination" NavHost --> navController.navigate(<dynamic route ‌>)

Ho Binh
  • 351
  • 1
  • 3
  • 8