How the Navigation System Works in GSYGithubAppCompose with Navigation Compose

GSYGithubAppCompose implements a lightweight abstraction over Jetpack Navigation Compose using a custom GSYNavigator wrapper and CompositionLocal injection to provide type-safe, centralized routing throughout the app.

GSYGithubAppCompose is a GitHub client built with Jetpack Compose that demonstrates modern Android architecture patterns. The navigation system in GSYGithubAppCompose with Navigation Compose leverages a custom wrapper pattern to decouple screens from the NavController while maintaining full access to navigation operations like pushing, popping, and replacing destinations.

The Entry Point: GSYNavHost

The navigation architecture centers on a custom GSYNavHost composable defined in core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/Navigation.kt. This wrapper initializes the navigation controller and exposes it to the entire composable tree through a custom CompositionLocal.

@Composable
fun GSYNavHost(
    modifier: Modifier = Modifier,
    startDestination: String,
    navController: NavHostController = rememberNavController(),
    builder: NavGraphBuilder.() -> Unit
) {
    val navigator = GSYNavigator(navController)
    CompositionLocalProvider(LocalNavigator provides navigator) {
        NavHost(
            navController = navController,
            startDestination = startDestination,
            modifier = modifier,
            builder = builder
        )
    }
}

This implementation performs three critical tasks:

  • Creates a GSYNavigator instance that wraps the standard NavHostController.
  • Provides this navigator via LocalNavigator so any descendant composable can access navigation actions without explicit parameter passing.
  • Delegates to the standard NavHost composable to render the actual navigation graph.

Centralized Navigation Logic with GSYNavigator

The GSYNavigator class located in core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/GSYNavigator.kt encapsulates all navigation operations. It exposes three primary methods that handle common routing scenarios:

class GSYNavigator(val navController: NavController) {

    fun back(route: String? = null, inclusive: Boolean = false) {
        if (route != null) {
            navController.popBackStack(route, inclusive)
        } else {
            navController.popBackStack()
        }
    }

    fun navigate(route: String, builder: NavOptionsBuilder.() -> Unit = {}) =
        navController.navigate(route, builder)

    fun replace(route: String) {
        navController.navigate(route) {
            popUpTo(navController.graph.id) { inclusive = true }
            launchSingleTop = true
        }
    }
}

navigate() pushes a new destination onto the stack using the standard Navigation Compose API. `back()`` pops the back stack, optionally to a specific route. *`replace()`* is a specialized operation that clears the entire back stack and sets the new route as the sole entry—critical for authentication flows where you want to prevent users from returning to login screens after successful authentication.

Accessing the Navigator via CompositionLocal

Rather than threading the NavController through every composable in the tree, GSYGithubAppCompose uses a CompositionLocal to make the navigator available implicitly. The LocalNavigator definition in GSYNavigator.kt throws an error if accessed outside a GSYNavHost:

val LocalNavigator = compositionLocalOf<GSYNavigator> {
    error("No LocalNavigator given")
}

Screens retrieve the navigator using LocalNavigator.current, enabling clean composable signatures that don't require navigation parameters. This pattern appears throughout the feature modules, from the welcome flow to repository details.

Defining Routes and Destinations

The navigation graph is declared in app/src/main/java/com/shuyu/gsygithubappcompose/MainActivity.kt using the GSYNavHost DSL. Routes are registered as strings, with dynamic segments enclosed in curly braces to accept arguments:

GSYNavHost(startDestination = "welcome") {
    composable("welcome") { WelcomeScreen() }
    composable("login")   { LoginScreen() }
    composable("home")    { HomeScreen() }
    composable("search_route") { SearchScreen() }
    composable("person/{username}") { backStackEntry ->
        val username = backStackEntry.arguments?.getString("username")
        ProfileScreen(username)
    }
    composable("repo_detail/{userName}/{repoName}") { backStackEntry ->
        val userName = backStackEntry.arguments?.getString("userName")
        val repoName = backStackEntry.arguments?.getString("repoName")
        RepoDetailScreen(userName, repoName)
    }
}

Dynamic route segments like {username} are extracted from the NavBackStackEntry.arguments bundle, allowing screens to receive parameters in a type-safe manner.

Practical Navigation Patterns

Lists throughout the app navigate to detail views by constructing route strings with interpolated IDs. For example, navigating to a user profile from a list item uses the person/{username} route pattern:

@Composable
fun UserItem(user: User) {
    val navigator = LocalNavigator.current
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .clickable { navigator.navigate("person/${user.login}") }
    ) {
        // Avatar, name, and other user details
    }
}

Similar patterns appear in core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/RepositoryItem.kt for navigating to repository detail screens.

Handling Authentication Flows

The replace() method proves essential for authentication state changes. After successful login or when determining the initial route, screens call navigator.replace() to set a new root destination without keeping previous screens in the back stack:

@Composable
fun WelcomeScreen(viewModel: WelcomeViewModel = hiltViewModel()) {
    val navigator = LocalNavigator.current
    val destination by viewModel.navigationDestination.collectAsState()

    LaunchedEffect(destination) {
        destination?.let { navigator.replace(it) }
    }
    // Welcome UI
}

This pattern ensures users cannot navigate back to the welcome or login screens using the system back button once authenticated.

Back Navigation Implementation

The top app bar components handle back navigation consistently by reading the navigator from the composition local. In core/ui/src/main/java/com/shuyu/gsygithubappcompose/core/ui/components/GSYTopAppBar.kt, the back button invokes navigator.back():

IconButton(onClick = { navigator.back() }) {
    Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}

This centralizes back-stack management and ensures consistent behavior across all screens featuring a toolbar.

Summary

  • GSYNavHost in Navigation.kt initializes the navigation graph and provides a wrapped navigator via CompositionLocal.
  • GSYNavigator in GSYNavigator.kt encapsulates NavController operations, offering navigate(), back(), and replace() methods for stack management.
  • LocalNavigator allows any composable to access navigation actions without parameter drilling.
  • Routes are defined as strings in MainActivity.kt, supporting dynamic arguments through URL-like patterns.
  • The replace() method clears the back stack entirely, making it ideal for authentication state transitions where previous screens should not remain accessible.

Frequently Asked Questions

How does GSYGithubAppCompose avoid passing NavController to every screen?

GSYGithubAppCompose uses a CompositionLocal named LocalNavigator to provide the GSYNavigator instance throughout the composable tree. Screens simply call LocalNavigator.current to access navigation methods, eliminating the need to pass the controller through every intermediate composable. This approach keeps UI components clean and focused on presentation logic while maintaining full navigation capabilities.

What is the difference between navigate() and replace() in GSYNavigator?

The navigate() method pushes a new destination onto the existing back stack, preserving the user's navigation history so they can return to previous screens. In contrast, replace() clears the entire back stack using popUpTo(navController.graph.id) { inclusive = true } before navigating, making the new destination the only entry in the stack. This latter pattern is specifically used for authentication flows to prevent users from returning to login screens after successful authentication.

How does GSYGithubAppCompose handle dynamic route arguments?

Dynamic arguments are defined using curly brace syntax in route strings, such as "person/{username}" or "repo_detail/{userName}/{repoName}". When registering the composable destination in MainActivity.kt, the app extracts these values from the NavBackStackEntry.arguments bundle. This allows screens to receive parameters like usernames or repository names in a type-safe manner while maintaining clean URL-like routing structures.

Where is the navigation graph defined in the GSYGithubAppCompose codebase?

The navigation graph is defined in app/src/main/java/com/shuyu/gsygithubappcompose/MainActivity.kt using the custom GSYNavHost composable. This file declares all route patterns and their associated screen composables, serving as the single source of truth for the app's navigation structure. The graph includes static routes like "welcome" and "home", as well as dynamic routes that accept path parameters for user profiles and repository details.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →