Photo by Aaron Burden on Unsplash
Hello android developers, lets be honest, how many times have you pulled your hair out trying to manage navigation? Its been a nightmare, right? We have all been writing custom code make it suits our use case.
From now, we have a new relief called Navigation 3. A new navigation compose navigation library that fits all our use cases at ease. It uses a backstack which is handled by the developers, that means, we have a full control over our navigation backstack.
Lets Clear a Big Misconception
Before going further, we need to understand one golden rule: Navigation is NOT your UI.
Think of it like this. You go to a restaurant. The menu is your navigation. It tells you what dishes are available (screens) and helps you which dish you want to order (screen to show). The actual food on your plate is the UI. The menu does not care if your biryani is spicy or your lemon mint is salty. It just guides you to it.
Similarly, your navigation logics should only care about which screen to show and when it should happen. It should not be tightly coupled with your UI components, like directly passing the backstack into the screen and drilling it to components also. It limits the component reusability and difficulty in testing screens.
The Real-World Tension: The Old Way vs. The Compose Way
We all know, Jetpack Compose is all about states. You describe your UI based on the current state, and when the state changes, the UI will automatically updates. Simple, right? Then why not the old navigation system works the same way?
The reason why because the old Navigation Compose library was more like event-based. Here is where the actual problem arise.
Imagine you’re navigating from your `Home` screen to your `Profile` screen.
1. The Click: The user taps the “Profile” button.
2. The Split-Second Confusion: Your app’s main state (viewmodel) instantly changes to say, “Okay, we are on the Profile screen now.” But it sends an event to the Navigation library.
3. The Lag: For a moment, your app state thinks it’s on “Profile,” but the navigation library’s internal state is still on “Home.” They are out of sync!
4. The Catch-Up: The navigation library finally processes the event, updates its own state, and then the UI changes.
This little sync mismatch results unpredictable behaviour and a lot of debugging tension. In Navigation 3, instead of working with events, we have more control on backstack state. We can create our own backstack and manage it in our app.
val backstack = remember { mutableStateListOf<Any>() } NavDisplay( // Similar to NavHost in old Nav library. backStack = backstack, ... )
By having this, we can add or remove entries from the backstack (at any index, just like List). This Navigation 3 throws away event-based model and brings state-based just like Compose. There is no events here, directly updating the navigation state, which prevents the issues which I mentioned above and also it gives more flexibility on handling navigation states on real world use cases.
So, how does the same flow work with Navigation 3?
1. The Click: User taps the “Profile” button.
2. One State to Rule Them All: You update your app’s state to “Profile.”
3. That’s It! Navigation 3 is constantly watching your app state. The moment it sees the state change to “Profile,” it automatically shows the Profile screen.
There is no separate navigation state. Your app state is the single source of truth, and everything else just follows its lead. So simple and so powerful!
Lets get into the implementation:
Implementing Navigation 3 is pretty straightforward, but with more flexibility in defining navigation flows, supporting dynamic UI patterns like bottom sheets, and handling backstack behavior seamlessly in Jetpack Compose.
Navigation 3 Dependencies
In libs.version.toml
[versions] nav3 = "1.0.0-alpha01" viewmodel = "1.0.0-alpha01" [libraries] # Core Navigation 3 libraries androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3" } # Optional add-on libraries androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "viewmodel" } [plugins] jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"}
In build.gradle (app)
plugins { ... alias(libs.plugins.jetbrains.kotlin.serialization) } dependencies { ... implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.lifecycle.viewmodel.navigation3) }
Also, update your compile SDK to 36.
NavDisplay Setup
When creating a new nav key, we should extend the NavKey
from Nav3. Additionally, make sure your entry is Serializable. Lets create two new nav entries.
import androidx.navigation3.runtime.NavKey @Serializable data object NotesList: NavKey @Serializable data class NoteDetail(val noteId: String): NavKey
We can create our app’s navigation state by two ways:
- mutableStateListOf(…)
- rememberNavBackStack(…)
Using rememberNavBackStack()
will automatically remembers the hierarchy across configuration change and process death.
To associate the screens to the key, it should be done through entryProvider
parameter in NavDisplay
. It gives navKey
as a parameter, and expect a NavEntry
. We can use the navKey
to associate the screen using when
statement.
In this example, we are returning NavEntry
to the entryProvider
. NavEntry
accepts three parameters:
- key: The
navKey
of the screen, which we get fromentryProvider
itself. - metadata (optional): This accepts a map to provide additional information about the behaviour of the screen to display.
- content: The Composable screen content which we need to display.
Before we going further, lets complete our NotesListScreen
and NoteDetailScreen
.
NotesListScreen.kt
@Composable | |
fun NotesListScreen( | |
navigateToDetail: (entry: NoteDetail) -> Unit = {} // Passing callback from NavDisplay | |
) { | |
LazyColumn( | |
modifier = Modifier.fillMaxSize() | |
) { | |
items(100) { index -> | |
Text( | |
text = "Note #$index", | |
modifier = Modifier | |
... | |
.clickable { | |
val entry = NoteDetail(noteId = "$index") | |
navigateToDetail(entry) | |
} | |
) | |
} | |
} | |
} |
In this screen, we are displaying a list of Text showing Note Id. Passing the navigation callback into the screen will helps in separation of UI and Navigation logics.
NoteDetailScreen.kt
@Composable | |
fun NoteDetailScreen(note: NoteDetail) { | |
Box( | |
modifier = Modifier | |
.fillMaxSize(), | |
contentAlignment = Alignment.Center | |
) { | |
Text("Note Id: ${note.noteId}") | |
} | |
} |
In this screen, we are displaying a simple Text showing the received Note Id from the parameter.
If we run the app, we can see something like this:

Navigation Demo on Compose Navigation 3 Library
Facing IllegalStateException on App Boot:
java.lang.IllegalStateException: No NavigationEventDispatcher was provided via LocalNavigationEventDispatcherOwner
If you are getting this error when running, change your activity-compose
version into 1.12.0-alpha01
Now, Lets explore some other parameters of NavDisplay briefly:
SceneStrategy:
NavDisplay( sceneStrategy = SinglePaneSceneStrategy(), )
- Scene Strategy determines how the screens are visually displayed in your app.
- It controls whether the navigation stack should show single screen at a time or multiple screens together.
- By default, it uses
SinglePaneSceneStrategy
, only top most screen is visible. - We can customise this behaviour to create custom adaptive layouts.
OnBack:
NavDisplay( ... onBack = { entries: Int -> if (backstack.isNotEmpty()) { backstack.removeLastOrNull() } } )
- Called when system back press is called. It gives entries count, represents the number of scenes to be removed, typically used on custom
SceneStrategy
.
Entry Decorators:
- It accepts list of
NavEntryDecorators
, which adds behaviour like saved state or viewModel retention to screens. - There are some decorators:
–rememberSceneSetupNavEntryDecorator()
— Lifecycle setup.
–rememberSavedStateNavEntryDecorator()
— Saves state.
–rememberViewModelStoreNavEntryDecorator()
— Retains viewModel.
Size Transform: — Animates size changes during transition. By default, no transition size animation should be occured.
Transition Spec & Pop Transition Spec: —
NavDisplay( ... transitionSpec = { ContentTransform( fadeIn(tween(300)), fadeOut(tween(300)) ) } )
- Defines enter and exit animations for forward or backward navigation.
Predictive Pop Transition Spec: — Advanced animation for predictive back gesture.
Job Offers
Summary
Navigation 3 is not just an upgrade, its a fundamental shift toward more maintainable, scaleable and user-friendly navigation in compose apps. Nav3 represents a leap forward in clarity and control. By providing declarative navigation graphs, type-safe routes and custom scene strategies, developers can now build more intrutive and adaptive navigaion flows with ease.
My Articles:
This article was previously published on proandroiddev.com.