Compose Navigation with LocalNavController

Compose Navigation with LocalNavController

Posted 3/3/2024

This is a quick tutorial on how to set up a LocalNavController on your project that you can use to simplify how you perform navigation on Jetpack Compose projects.

Before I begin, let me speak to the issue I ran into that brought me down this little rabbit hole.

On a project I recently worked on we were using androidx.navigation:navigation-compose to handle navigation in our Jetpack Compose application. I won't go into how this works but you can check the docs if you are not familiar: Compose Navigation Docs. The path that we took had us setting up extension functions for each navigation we wanted to perform, like this:

fun NavController.navigateToLogin() {
    navigate(route = "login")
}

And then passing that extension into the screen parameters like this

@Composable
fun PrimaryNavGraph(
    modifier: Modifier = Modifier,
        navController: NavController,
) {
    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = "home"
    ) {
        composable(
            route = "login",
            enterTransition = slideIn,
            exitTransition = slideOut,
            popEnterTransition = slideIn,
            popExitTransition = slideOut,
        ) {
            LoginScreen(
                navigateToLogin = navController::navigateToLogin
            )
        }
    }
}

Now this works great for simple navigation or routes that dont spiderweb out into many different places. However, on that project I mentioned, we had some routes that had up to 5 or 6 navigation functions passed to the screen. Now passing functions to methods isn't typically a problem, except for any time you want to add a new navigation function to the screen, you then need to then go back and pass that function into the screen. In my opinion this created some uncessesary friction and added a lot of noise to Screen Composables.

After a lot of deliberation, I decided that my solution would be to create a LocalNavController that I could pull into any component inside the scope of the CompositionLocalProvider at the root of my app. In order to realize this I started by creating a composition local, which turns out is really easy:

// Declarations.kt
val LocalNavController =
    compositionLocalOf<NavHostController> { error("No NavController") }

After you do that, in your MainActivity or whereever the root of your navigation is you want to provide your nav controller.

// MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
                
        // ...
                
        setContent {
            val navController = rememberNavController()
                        
            YourTheme {
                CompositionLocalProvider(
                    LocalNavController provides navController,
                ) {
                    PrimaryNavGraph()
                }
            }
        }
    }
}

Then in your navigation you can pull the nav controller out using LocalNavController.current and pass that to your NavHost and viola. Your app can now access the nav controller and its extensions from anywhere in your app.

@Composable
fun PrimaryNavGraph(
    modifier: Modifier = Modifier,
) {
    val navController = LocalNavController.current

    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = "home"
    ) {
        composable(
            route = "login",
            enterTransition = slideIn,
            exitTransition = slideOut,
            popEnterTransition = slideIn,
            popExitTransition = slideOut,
        ) {
            LoginScreen()
        }
    }
}

So now that you have this set up, any time you want to access your nav controller from a composable is the following, keep in mind your extensions must be public in order to import them properly.

// In a composable context

val navController = LocalNavController.current

// using a LaunchedEffect here for example, you can call these functions
// from wherever you were normally calling your navigation functions
LaunchedEffect(someValue) { 
    if (someValue) {
        navController.popBackStack() // or whatever navigation function you want
    }
}

A couple of the benefits include:

  • Cleaning up the parameter list of your Screen Composables
  • Reducing the friction needed to add a new navigation function to a screen or component.
  • Not having to pass basic functions like popBackStack around to many places.

I hope this little guide inspires you to adopt this architecture in your own projects. And if you don't like this approach I thank you for taking the time to read this post anyways!

Finally if you are curious what this looks like in practice you can check out my manga reader Ink, which uses this pattern to great effect.

Ink Github

blog compose-navigation-with-localnavcontroller