Simple and Clean Navigation for Jetpack Compose

Unlock seamless navigation for your Jetpack Compose apps, across all devices (including Wear OS!).

Feeling lost in Jetpack Compose navigation? You’re not alone! While there are many ways to approach it, this guide focuses on a simple and elegant method that’s perfect for beginners. We’ll skip the basics and dive right into practical techniques for a smooth navigation experience.

This article has an updated version with new type safe navigation located here.

First let’s check some of the ideas you can find on the internet.

Pass navigationHost into composable functions.

To enable navigation between screens, we’ll pass an instance of the navigation host to each composable in your app. This navigation host acts as a central point for controlling the navigation flow within your application, regardless of the number of screens you have.

Image this situation:

@Composable
fun MyNavGraph(navController: NavHostController) {
    NavHost(
        navController = navController, startDestination = "Screen1"
    ) {
        composable("Screen1") {
            Screen1()
        }
        composable("Screen2") {
            Screen2()
        }
        composable("Screen3") {
            Screen3()
        }
        composable("Screen4") {
            Screen4()
        }

    }
}

Following that idea you will have to pass NavHostController instance into each composable, lets call them screens

@Composable
fun MyNavGraph(navController: NavHostController) {
    NavHost(
        navController = navController, startDestination = "Screen1"
    ) {
        composable("Screen1") {
            Screen1(navController)
        }
        composable("Screen2") {
            Screen2(navController)
        }
        composable("Screen3") {
            Screen3(navController)
        }
        composable("Screen4") {
            Screen4(navController)
        }

    }
}

Actual navigation will be done inside composable function.

@Composable
fun Screen1(navController: NavHostController) {
    Column {
        TextButton(onClick = { navController.popBackStack() }) {
            Text("Go Back")
        }
    }
}

But what if Screen1 has separate composable which also should navigate to another screen?

@Composable
fun Screen1(navController: NavHostController) {
    Column {
        TextButton(onClick = { navController.popBackStack() }) {
            Text("Go Back")
        }
        MyComponent(navControlller)
    }
}

@Composable
fun MyComponent(navController: NavHostController) {
    Column {

        Text("This is a separate composable")
        
        TextButton(onClick = { navController.navigate("Screen2") }) {
            Text("Go to Screen 2")
        }
    }
}

While passing the navigation controller to every composable is common, it can clutter your UI code, especially when navigation decisions rely on complex logic. Remember, composables should focus on presenting the UI.

With ViewModels injected through Dependency Injection (DI), directly passing the navigation controller becomes cumbersome. To address this complexity, let’s explore a cleaner approach.

Expose callback from composable up to parent

Lets take the previous example. Navigation graph in this case will look like this:

@Composable
fun MyNavGraph(navController: NavHostController) {
    NavHost(
        navController = navController, startDestination = "Screen1"
    ) {
        composable("Screen1") {
            Screen1(onNavigateBack = {
                                         navController.popBackStack()
                                     },
                   onNavigateToScreen2 = {
                                             navController.navigate("Screen2")
                                         }
                   )
        }
        composable("Screen2") {
            Screen2()
        }
        composable("Screen3") {
            Screen3()
        }
        composable("Screen4") {
            Screen4()
        }

    }
}

Screen1 composable function will look like this

@Composable
fun Screen1(onNavigateBack: () -> Unit,
            onNavigateToScreen2: () -> Unit) {
    Column {
        TextButton(onClick = { onNavigateBack() }) {
            Text("Go Back")
        }
        MyComponent(navControlller, onNavigateToScreen2 = onNavigateToScreen2)
    }
}

@Composable
fun MyComponent(onNavigateToScreen2: () -> Unit) {
    Column {

        Text("This is a separate composable")
        
        TextButton(onClick = { onNavigateToScreen2() }) {
            Text("Go to Screen 2")
        }
    }
}

While these approaches might work initially, maintaining complex navigation logic within composables can lead to challenges down the line. To ensure a clean and scalable solution, let’s explore an alternative approach I found particularly useful. It simplifies code and promotes a clear separation of concerns.

My Solution

To achieve clean and centralized navigation management, we’ll create a singleton class responsible for navigation logic. This class won’t directly expose or store the navigation controller, promoting a loose coupling between UI and navigation. To inject this navigation functionality into our ViewModels, we’ll leverage Dependency Injection (DI).

Class contains three separate parts. First is a NavigationCommand enum. It is used to tell custom navigator what to do

enum class NavigationCommand {
    /**
     * Navigate to destination
     */
    NAVIGATE,

    /**
     * Go back
     */
    GO_BACK,

    /**
     * Navigate back to specific destination
     */
    GO_BACK_TO,

    /**
     * Navigate back to specific destination, inclusive=true
     */
    GO_BACK_TO_INCLUSIVE,

    /**
     * Navigate to destination and remove current view from nav stack
     */
    NAVIGATE_AND_CLEAR_CURRENT,

    /**
     * Navigate to destination and remove all previous destinations making current one as a top
     */
    NAVIGATE_AND_CLEAR_TOP
}

What type of dirrent navigation you do?

NavigationCommand.NAVIGATE: Simple navigation to destination

NavigationCommand.GO_BACK: Go back one screen

NavigationCommand.GO_BACK_TO: This command navigates back to a specific screen in your app’s navigation stack. It essentially removes all screens between the current screen and the target screen.

NavigationCommand.GO_BACK_TO_INCLUSIVE: This command builds upon GO_BACK_TO by also removing the destination screen itself from the navigation backstack. In simpler terms, it navigates back and removes both the current screen and the target screen.

NavigationCommand.NAVIGATE_AND_CLEAR_CURRENT: Navigates to another screen removing current screen from navigation stack.

NavigationCommand.NAVIGATE_AND_CLEAR_TOP: This command navigates to a specific screen and clears the navigation backstack. In simpler terms, it replaces all previous screens with the new destination, effectively preventing the user from navigating back to those screens. This is useful for situations where you want a clear starting point or want to prevent the user from accessing previous screens for specific reasons (e.g., login flow).

Next part is a small data class which will be passed into custom navigator. Fields are self-explanatory

data class NavigationAction(
    val navigationCommand: NavigationCommand,
    val destination: String = "",
    val parcelableArguments: Map<String, Parcelable> = emptyMap(),
    val navOptions: NavOptions = NavOptions.Builder().build()
)           

And the main class of custom navigator


@Singleton
class CustomNavigator @Inject constructor() {
    private val _navActions =
        MutableSharedFlow<NavigationAction?>(replay = 1, onBufferOverflow = BufferOverflow.DROP_LATEST)

    val navActions: SharedFlow<NavigationAction?> = _navActions

    fun navigate(navAction: NavigationAction?) {
        _navActions.tryEmit(navAction)
    }

    fun navigate(destination: String) {
        navigate(NavigationAction(NavigationCommand.NAVIGATE, destination = destination))
    }

    fun navigateAndClear(destination: String) {
        navigate(NavigationAction(NavigationCommand.NAVIGATE_AND_CLEAR_TOP, destination = destination))
    }

    fun goBack() {
        navigate(NavigationAction(NavigationCommand.GO_BACK))
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    fun runNavigationCommand(action: NavigationAction, navController: NavHostController) {
        when (action.navigationCommand) {
            NavigationCommand.GO_BACK -> navController.navigateUp()
            NavigationCommand.GO_BACK_TO -> navController.goBackTo(action.destination, inclusive = false)

            NavigationCommand.NAVIGATE -> navController.navigate(action.destination) {
                launchSingleTop = true
            }

            NavigationCommand.NAVIGATE_AND_CLEAR_CURRENT -> navController.navigate(action.destination) {
                navController.currentBackStackEntry?.destination?.route?.let {
                    popUpTo(it) { inclusive = true }
                }
            }

            NavigationCommand.NAVIGATE_AND_CLEAR_TOP -> navController.navigateAndReplaceStartRoute(action.destination)
            NavigationCommand.GO_BACK_TO_INCLUSIVE -> navController.goBackTo(action.destination, inclusive = true)
        }

        _navActions.resetReplayCache()
    }

    fun goBackTo(destination: String) {
        navigate(NavigationAction(NavigationCommand.GO_BACK_TO, destination = destination))
    }

    fun goBackTo(destination: String, inclusive: Boolean) {
        if (inclusive) {
            navigate(NavigationAction(NavigationCommand.GO_BACK_TO_INCLUSIVE, destination = destination))
        } else {
            navigate(NavigationAction(NavigationCommand.GO_BACK_TO, destination = destination))
        }
    }

    fun navigateAndClearCurrentScreen(destination: String) {
        navigate(NavigationAction(NavigationCommand.NAVIGATE_AND_CLEAR_CURRENT, destination = destination))
    }
}

fun NavHostController.navigateAndReplaceStartRoute(newHomeRoute: String) {
    popBackStack(graph.startDestinationId, true)
    graph.setStartDestination(newHomeRoute)
    navigate(newHomeRoute)
}

fun NavHostController.goBackTo(routeName: String, inclusive: Boolean = false) {
    popBackStack(routeName, inclusive)
}

Using a centralized navigation manager:

We’ll leverage a singleton class, injectable through Dependency Injection (DI), to manage navigation throughout your app. This class provides functions like navigate and goBack for easy navigation control within your ViewModels (or composable functions). To initiate navigation, simply call the appropriate function with the desired route name

Simplified Navigation with Predefined Functions:

This navigation class offers convenience functions like navigate to simplify navigation calls. Instead of manually creating a NavigationAction object, you can directly pass the destination route name. The class handles creating the NavigationAction internally for you. However, if you require more granular control over navigation behavior, you can still utilize the navigate(navAction: NavigationAction?) function with a custom NavigationAction instance.

While this class accepts route names as destinations, consider creating a dedicated destination object for improved type safety and maintainability. This object would encapsulate the route information and potentially additional navigation parameters. Here’s an example structure:

object Screens {
    const val SCREEN1 = "Screen1"
    const val SCREEN2 = "Screen2"
    const val SCREEN3 = "Screen3"
    const val SCREEN4 = "Screen4"
}

Examining CustomNavigator class

Centralized Navigation Flow with navActions:

The CustomNavigator class provides a public navActions Flow. This Flow acts as a stream of navigation commands emitted by the navigator. While you can subscribe to this Flow from anywhere in your app, it’s common practice to do so in the MainActivity during its onCreate lifecycle method. This establishes a central location for handling navigation throughout your application.

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    @Inject
    lateinit var navHelper: CustomNavigator

    override fun onCreate(savedInstanceState: Bundle?) {
        setContent{
            val navController: NavHostController = rememberNavController()
            LaunchedEffect(key1 = true) {
                lifecycleScope.launch {
                    repeatOnLifecycle(Lifecycle.State.STARTED) {
                        navHelper.navActions.collect { navigatorState ->
                            navigatorState.parcelableArguments.forEach { arg ->
                                navController.currentBackStackEntry?.arguments?.putParcelable(
                                    arg.key,
                                    arg.value
                                )
                            }
                            navHelper.runNavigationCommand(navigatorState, navController)

                        }
                    }
                }
            }

            MyNavGraph(navController)
        }
    }
}

@Composable
fun MyNavGraph(navController: NavHostController) {
    NavHost(
        navController = navController, startDestination = Screens.SCREEN1
    ) {
        composable(Screens.SCREEN1) {
            Screen1()
        }
        composable(Screens.SCREEN2) {
            Screen2()
        }
        composable(Screens.SCREEN3) {
            Screen3()
        }
        composable(Screens.SCREEN4) {
            Screen4()
        }

    }
}

Leveraging ViewModels for Navigation:

Now that we’ve established a centralized navigation approach, let’s explore how to integrate it with your ViewModels. We’ll create a ViewModel specifically for Screen1 to demonstrate how to trigger navigation commands.

@HiltViewModel
class Screen1ViewModel @Inject constructor(
    private val customNavigator: CustomNavigator
) {
    fun goBack() {
        customNavigator.goBack()
    }

    fun navigateToScreen2() {
        customNavigator.navigate(Screens.SCREEN2)
    }
}

// Screen1 
@Composable
fun Screen1(viewModel: Screen1ViewModel = hiltViewModel() {
    Column {
        TextButton(onClick = { viewModel.goBack() }) {
            Text("Go Back")
        }
        MyComponent{
                viewModel.navigateToScreen2()
        }
    }
}

@Composable
fun MyComponent(onNavigateToScreen2: () -> Unit) {
    Column {

        Text("This is a separate composable")
        
        TextButton(onClick = { onNavigateToScreen2() }) {
            Text("Go to Screen 2")
        }
    }
}

Business Logic and Navigation Decisions in ViewModels:

By centralizing navigation logic within ViewModels, we gain the flexibility to apply business logic before triggering navigation. This empowers your ViewModel to decide on the appropriate destination screen based on various conditions within your application. For instance, the ViewModel could validate user input or check authentication status before initiating navigation.

Explore a Working Example:

To solidify your understanding, I’ve created a sample application showcasing the custom navigation class in action. You can explore the source code and implementation details on GitHub:
Custom class for JetPack Navigation example (github.com)

Spread the love

Related Post