Skip to content
Shushant TiwariMobile engineer & maker
← All stories

Comprehensive Guide to Migrating to Nested Navigation Graphs in Jetpack Compose

Migrating from multiple activities to nested Compose navigation graphs, with examples for clearer and more maintainable app flows.

Shushant TiwariMobile engineer. Building tools that help us see.
Contents

As Android applications grow and evolve, so should their architecture. The multi-activity model, once the default for Android apps, introduces complexities that hinder scalability, testability, and maintainability. To solve these challenges, migrating to a single-activity architecture with nested navigation graphs in Jetpack Compose offers a modern and efficient solution. This approach allows you to modularize your app, streamline state management, and improve both performance and testability.

In this guide, we’ll walk through the entire migration process, from setting up a single-activity architecture to modularizing navigation and implementing scalable, testable features. Whether you’re migrating an existing project or starting a new one, this guide will help you build a robust, maintainable, and future-proof Android application.

Why Migrate to a Single-Activity Architecture with Nested Navigation?

Before diving into the steps, let’s quickly review the key reasons why this migration is beneficial:

  1. Modularization: With nested navigation graphs, you can break your app into independent, reusable modules that can be easily maintained and scaled.
  2. Scalability: Adding new features becomes seamless without disrupting the core navigation structure.
  3. Performance: Reduces memory and performance overhead associated with managing multiple activity lifecycles.
  4. Testability: Centralized navigation and declarative UI components make it easier to write unit and UI tests, enabling a strong Test-Driven Development (TDD) approach.
  5. Future-Proof: This architecture sets up your app for future enhancements like dynamic feature delivery, cross-platform support (Android Auto, Wear OS), and better handling of deep links.

Steps to Migrate to Nested Navigation Graphs in Jetpack Compose

1. Centralize Navigation in a Single Activity

The first step in migrating to this modern architecture is to replace multiple activities with a MainActivity that acts as the host for the entire navigation graph. This simplifies the app structure and consolidates navigation into one central place.

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AppNavigation()  // Host for the navigation graph
        }
    }
}

2. Set Up Jetpack Navigation Compose

Jetpack Navigation Compose allows you to manage navigation in a declarative way. To begin, add the navigation-compose dependency to your build.gradle.kts file:

dependencies {
    val nav_version = "2.8.5"

    implementation("androidx.navigation:navigation-compose:$nav_version")
}

Next, define a NavHost within your AppNavigation composable function. The NavHost will manage navigation across all features of your app.

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = "feature1") {
        feature1NavGraph(navController)
        feature2NavGraph(navController)
        // Add more feature graphs here
    }
}

3. Create Nested Navigation Graphs for Each Feature

To maintain modularity, each feature of your app should have its own navigation graph. This helps isolate the navigation logic for each feature, making it easier to manage and scale.

Feature 1 Navigation Graph Example:

fun NavGraphBuilder.feature1NavGraph(navController: NavController) {
    navigation(startDestination = "feature1Screen", route = "feature1") {
        composable("feature1Screen") {
            Feature1Screen(navController)
        }
        composable("feature1Detail/{id}") { backStackEntry ->
            val id = backStackEntry.arguments?.getString("id")
            Feature1DetailScreen(navController, id)
        }
    }
}

Feature 2 Navigation Graph Example:

fun NavGraphBuilder.feature2NavGraph(navController: NavController) {
    navigation(startDestination = "feature2Screen", route = "feature2") {
        composable("feature2Screen") {
            Feature2Screen(navController)
        }
        composable("feature2Detail") {
            Feature2DetailScreen(navController)
        }
    }
}

Sure! Below is a detailed guide article that aligns with the concepts discussed previously, providing step-by-step instructions for migrating to a modular, single-activity architecture with nested navigation graphs using Jetpack Compose:

Comprehensive Guide to Migrating to Nested Navigation Graphs in Jetpack Compose

As Android applications grow and evolve, so should their architecture. The multi-activity model, once the default for Android apps, introduces complexities that hinder scalability, testability, and maintainability. To solve these challenges, migrating to a single-activity architecture with nested navigation graphs in Jetpack Compose offers a modern and efficient solution. This approach allows you to modularize your app, streamline state management, and improve both performance and testability.

In this guide, we’ll walk through the entire migration process, from setting up a single-activity architecture to modularizing navigation and implementing scalable, testable features. Whether you’re migrating an existing project or starting a new one, this guide will help you build a robust, maintainable, and future-proof Android application.

Why Migrate to a Single-Activity Architecture with Nested Navigation?

Before diving into the steps, let’s quickly review the key reasons why this migration is beneficial:

  1. Modularization: With nested navigation graphs, you can break your app into independent, reusable modules that can be easily maintained and scaled.
  2. Scalability: Adding new features becomes seamless without disrupting the core navigation structure.
  3. Performance: Reduces memory and performance overhead associated with managing multiple activity lifecycles.
  4. Testability: Centralized navigation and declarative UI components make it easier to write unit and UI tests, enabling a strong Test-Driven Development (TDD) approach.
  5. Future-Proof: This architecture sets up your app for future enhancements like dynamic feature delivery, cross-platform support (Android Auto, Wear OS), and better handling of deep links.

Steps to Migrate to Nested Navigation Graphs in Jetpack Compose

1. Centralize Navigation in a Single Activity

The first step in migrating to this modern architecture is to replace multiple activities with a MainActivity that acts as the host for the entire navigation graph. This simplifies the app structure and consolidates navigation into one central place.

Code Example:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AppNavigation()  // Host for the navigation graph
        }
    }
}

2. Set Up Jetpack Navigation Compose

Jetpack Navigation Compose allows you to manage navigation in a declarative way. To begin, add the navigation-compose dependency to your build.gradle file:

implementation("androidx.navigation:navigation-compose:2.6.0")

Next, define a NavHost within your AppNavigation composable function. The NavHost will manage navigation across all features of your app.

Code Example:

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

NavHost(navController = navController, startDestination = "feature1") {
        feature1NavGraph(navController)
        feature2NavGraph(navController)
        // Add more feature graphs here
    }
}

3. Create Nested Navigation Graphs for Each Feature

To maintain modularity, each feature of your app should have its own navigation graph. This helps isolate the navigation logic for each feature, making it easier to manage and scale.

Feature 1 Navigation Graph Example:

fun NavGraphBuilder.feature1NavGraph(navController: NavController) {
    navigation(startDestination = "feature1Screen", route = "feature1") {
        composable("feature1Screen") {
            Feature1Screen(navController)
        }
        composable("feature1Detail/{id}") { backStackEntry ->
            val id = backStackEntry.arguments?.getString("id")
            Feature1DetailScreen(navController, id)
        }
    }
}

Feature 2 Navigation Graph Example:

fun NavGraphBuilder.feature2NavGraph(navController: NavController) {
    navigation(startDestination = "feature2Screen", route = "feature2") {
        composable("feature2Screen") {
            Feature2Screen(navController)
        }
        composable("feature2Detail") {
            Feature2DetailScreen(navController)
        }
    }
}

4. Refactor Features into Composable Screens

After modularizing your navigation, you’ll need to refactor each feature into Composable Screens. This allows you to take advantage of Jetpack Compose’s declarative UI structure.

@Composable
fun Feature1Screen(navController: NavController) {
    Column {
        Text("Feature 1 Screen")
        Button(onClick = { navController.navigate("feature1Detail/123") }) {
            Text("Go to Detail")
        }
    }
}



@Composable
fun Feature1DetailScreen(navController: NavController, id: String?) {
    Column {
        Text("Feature 1 Detail Screen: $id")
        Button(onClick = { navController.popBackStack() }) {
            Text("Back")
        }
    }
}

5. Manage Global UI Elements (Toolbars, Navigation Bars, etc.)

If your app has global UI elements, such as toolbars or bottom navigation, they should be managed centrally in the MainActivity or within the Scaffold composable. This ensures consistency and prevents code duplication.

@Composable
fun MainScreen() {
    val navController = rememberNavController()

    Scaffold(
        bottomBar = {
            BottomNavigation {
                BottomNavigationItem(
                    label = { Text("Feature 1") },
                    selected = false,
                    onClick = { navController.navigate("feature1") }
                )
                BottomNavigationItem(
                    label = { Text("Feature 2") },
                    selected = false,
                    onClick = { navController.navigate("feature2") }
                )
            }
        }
    ) {
        NavHost(navController = navController, startDestination = "feature1") {
            feature1NavGraph(navController)
            feature2NavGraph(navController)
        }
    }
}

6. Implement Test-Driven Development (TDD) with Compose

With Jetpack Compose, it becomes significantly easier to implement Test-Driven Development (TDD). The declarative nature of Compose makes writing UI tests straightforward. Use ComposeTestRule to test navigation flows, ensuring your app behaves as expected.

@Test
fun testNavigationToFeature1Detail() {
    composeTestRule.setContent {
        AppNavigation()  // Start navigation graph
    }
    // Simulate navigation and validate transitions
    composeTestRule.onNodeWithText("Go to Detail").performClick()
    composeTestRule.onNodeWithText("Feature 1 Detail Screen: 123").assertIsDisplayed()
}

Deep links are crucial for directing users to specific destinations within your app. Jetpack Compose’s navigation library supports deep links natively, allowing you to handle URLs that trigger navigation to particular screens.

composable(
    "feature1Detail/{id}",
    deepLinks = listOf(navDeepLink { uriPattern = "yourapp://feature1/{id}" })
) {
    Feature1DetailScreen(navController, it.arguments?.getString("id"))
}

Conclusion

Migrating to a single-activity architecture with nested navigation graphs using Jetpack Compose offers a future-proof, scalable, and maintainable solution for Android apps. By centralizing navigation logic, modularizing features, and embracing Test-Driven Development (TDD), you can create apps that are easier to maintain, scale, and test. Additionally, this architecture prepares your app for emerging use cases, such as dynamic feature injection, cross-platform support, and handling deep links.

With this guide, you now have a solid foundation to start migrating your Android apps to this modern architecture. Whether you’re working on a new project or refactoring an existing one, the modular approach ensures that your app is not only more maintainable but also more adaptable to the future.

Originally published on Medium on 14 Dec 2024. Republished here by the author.

Examples and opinions reflect the original publication date.

Keep the conversation going.

Have a thought to add?Join the conversation on Medium.