androidkotlinmobile-applicationjetpack-compose-navigation

How do you handle navigation in deeply nested composables in Jetpack Compose?


I am learning Mobile App Development and this is my first StackOverflow post!

I'm implementing topBar, bottomNavigationBar, and general navigation from pages to pages when I came across Jetpack Compose Navigation. How do you handle navigation in deeply nested composables in Jetpack Compose?

Context of my application:

I see that in documentation, it says in red warning to not pass NavControllers through Composables and expose an event instead.

Here's something similar to what I implemented. I have SettingScreen, SomeScreen and CardFrame1 composable functions. CardFrame1 has a button to go to SomeScreen:

@Serializable
object Main
@Serializable
object Setting

// this is being called form MainActivity
@Composable  
fun AppHost() {
    navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Screen.Main.route
    ) {
        composable(Screen.Main.route) {
            MainScreen(
                onNavigateToMeasure = {
                    navController.navigate(Screen.SomeScreen.route)
                }
            )
        }

        composable(Screen.Setting.route) {
            SettingPage(
                onNavigateToSetting1 = {
                    navController.navigate(Screen.Test1.route)
                }
            )
        }
    }
}

@Composable
fun SettingPage(someOnClick: () -> Unit){
  Column(){
    CardFrame1(someOnClick)
  }
}

in order to use navController.navigate(Screen.Test1.route) in a Button in CardFrame1(Button(onClick = someOnClick){}) for navigation.

I realized that:

  1. I would have to pass this lambda function(() -> unit) every time I have a Button or any other trigger to navigate to a different composable
  2. if the navigation is used in layer of composable functions that are deeper, then I would need to pass the lambda through all of them as parameters.

Given that the documentation discourages passing NavController into composables directly, what is the best way to handle navigation in deeply nested composables?

Would a centralized navigation class (e.g., a NavigationManager or NavigationEventBus) be a good alternative? Are there any established best practices for reducing navigation boilerplate while keeping composables reusable?

I want to follow Jetpack Compose best practices and maintain testability, reusability, and separation of concerns. Any guidance with references to official recommendations would be greatly appreciated.


Solution

  • You're on the right track! This is indeed the recommended approach. Google suggests following the Now in Android codebase as a best practice. If you explore their implementation, you'll find a similar pattern. Let me break it down for you.

    1. Structuring the NavHost

    The NavHost is responsible for defining your navigation graph. Instead of passing the NavController directly to screens, it's recommended to pass lambdas for navigation actions. This keeps the UI layer loosely coupled from the navigation logic.

    Here’s an example from the NiaNavHost:

    @Composable
    fun NiaNavHost(
        appState: NiaAppState,
        onShowSnackbar: suspend (String, String?) -> Boolean,
        modifier: Modifier = Modifier,
    ) {
        val navController = appState.navController
        NavHost(
            navController = navController,
            startDestination = ForYouBaseRoute,
            modifier = modifier,
        ) {
            forYouSection(
                onTopicClick = navController::navigateToTopic,
            ) {
                topicScreen(
                    showBackButton = true,
                    onBackClick = navController::popBackStack,
                    onTopicClick = navController::navigateToTopic,
                )
            }
            ...
        }
    }
    

    2. Defining Navigation Extensions

    Instead of passing the NavController to composables, Now in Android defines extension functions on NavController for navigation actions. This keeps the navigation logic encapsulated.

    Here's an example from TopicNavigation.kt:

    @Serializable
    data class TopicRoute(val id: String)
    
    fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) {
        navigate(route = TopicRoute(topicId)) {
            navOptions()
        }
    }
    
    fun NavGraphBuilder.topicScreen(
        showBackButton: Boolean,
        onBackClick: () -> Unit,
        onTopicClick: (String) -> Unit,
    ) {
        composable<TopicRoute> {
            TopicScreen(
                showBackButton = showBackButton,
                onBackClick = onBackClick,
                onTopicClick = onTopicClick,
            )
        }
    }
    

    3. Passing Lambdas Down the UI Tree

    In the composables, the lambdas for navigation are passed to only the necessary components instead of the NavController. For example, in ForYouScreen.kt, the navigation actions are passed explicitly.

    While this approach might result in passing lambdas through multiple layers, it remains the best practice as of now. It promotes modularity, improves testability, and helps avoid unnecessary recompositions.