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 destinationNavigationCommand.GO_BACK
: Go back one screenNavigationCommand.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)