Navigation 3 - Part 1 - Basic Examples
Overview
Navigation3, the Compose-first Navigation library, was released as stable back in mid November ('25). April 22nd it was updated to 1.1.1. Let's take a look at it.
There are three (originally planned for two) parts to this series. In Part 1, we will deal with a minimal example of navigation as well as a refined version that fixes some issues. In Part 2, we will cover dual pane support. Part 3 we will dive into a more complex version that supports Wider Screens (two-pane layout) and ViewModels.
This first post is designed for anyone interested in getting Navigation3 up and running as quickly as possible and exploring how to improve the basics. The follow up will be a good post for a more realistic use case and will have some nice visuals (in the screenshots / animations at least). We'll keep the visuals simple for this first one and limit the different pages to slight color variations to help understand the transitions.
Ideally, I would make a Part 4 or even a Part 5 to go into more detail. I do not think I will have time for that, unfortunately. Splitting this up into three was already a lot more work than I had originally hoped.
Important Warning: The solutions presented below (Example 1 and Example 2) do not work for dual pane. You can jump ahead to Part 2 if you are just looking to solve that. This part focuses on the introduction and solutions for single pane issues.
Introduction to Navigation3
- Multi-pane support is one of the top improvements. Now we must support many different screen sizes, this feature makes life much easier for developers. I expect to see future improvements here. Part 2 will cover this.
- Whereas Navigation2 was imperative, Navigation3 allows us to work in a state-driven declarative environment more suited to Compose.
- The state is now exposed rather than hidden internally, this means we have more control over how everything works.
- We have direct access to the backstack via List<NavKey> that we can modify directly.
- It also uses a modular approach that should be much easier to work with.
Should I Upgrade
If you use Compose, probably. If you are Multiplatform, definitely. If you maintain a legacy XML App, stick with Navigation2 for now.
Dependencies & Support Classes (Both Parts)
Dependencies and support classes (navigation & colors) are available as a separate blog entry to keep this clean. All links repeated below. Parts 1 and 2 Dependencies & Support Classes
Navigation Primer
Before we get into examples, I want to quickly go over what we are doing when using Navigation3.
- Navigation tools allow us to move between different screens and back again.
- We do that by storing a list of destinations - a backstack. Our last entry being our current screen and each entry before a trail leading back to where we started.
- This history list allows the back button to return to where we started one step at a time.
- Navigation3 also manages ViewModel Lifecycles, which is extremely complex to do manually.
- It also makes Deep Linking easier (although that is not covered in these posts).
Basic Example 1
I'll avoid posting all the layout details and focus on the details related to Navigation3. Full code listings are available on the GitHub Gist links.
NavigationScreen
This is our Serializable interface that holds our screen types. We have our Home screen, a List screen, and our Details screen. The Detail screen takes in an id associated with the data to display.
@Serializable
sealed interface NavigationScreen : NavKey {
@Serializable
data object Home : NavigationScreen
@Serializable
data object List : NavigationScreen
@Serializable
data class Detail(val id: Int) : NavigationScreen
}
NavDisplay & NavBackStack
@Composable
private fun NavigationApp() {
val backStack =
rememberNavBackStack(NavigationScreen.Home)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<NavigationScreen.Home> {
HomeScreen() { backStack.add(NavigationScreen.List) }
}
entry<NavigationScreen.List> {
ListScreen() { id ->
backStack.add(NavigationScreen.Detail(id)) }
}
entry<NavigationScreen.Detail> {
key -> DetailScreen(key.id)
}
}
)
}
HomeScreen & List Screen
Aside from the basic layout, these are actually very simple. The button simply uses the Lambda to take us to the correct list / detail page as defined above.
@Composable
fun HomeScreen(onGoToList: () -> Unit) {
...
Button(onClick = onGoToList) { Text("Go to List") }
...
}
@Composable
fun ListScreen(onSelectItem: (Int) -> Unit) {
...
items(10) { index ->
ListItem(
headlineContent = { Text("Item $index") },
modifier = Modifier.clickable { onSelectItem(index) }
)
}
...
}
Result
That's all there is to it! It's actually quite simple to get it up and running if you have a good template.
Full code listing Example 1 GitHub Gist.
Thanks to @Serializable, this is lifecycle safe so it will save your current position on Process Death. Here are some images of it in action (Main / List / Detail pages).
Annoying Bug
There is a bug with this code. If you click a list item more than once, it will add multiple copies of the screen to the backstack. This means we need to click back more than once to return to the previous screen. It's not always obvious, but it could very easily annoy your user as it is such a common action.
There are two good ways to test this:
- On a physical device, press multiple list items at the same with different fingers. You can get seven or eight detail screens to stack up on the backstack.
- On an emulator, use a keyboard to select a button and hold down the space bar. This will stack up a bunch of entries (I was seeing three).
We fix that for V2.
Basic Example 2
Our second version does things a little differently. We'll reuse a lot of the Composables from the first so you will need to add those yourself if you are only creating version 2.
Broken Solution
Below is actually an updated solution for Example 2. I originally had quite a complex solution which introduced a new bug where the buttons would sometimes be unresponsive during the transition animation when a user might want to quickly press it.
This led to a very frustrating user experience (I hate this type of thing) so I spent quite a bit of time reworking my solution and trying to figure everything out. While I was at it, I also decided it was a good time to fix the animations as they looked a bit ugly.
I wrote up a blog post with the old outdated code as well as the bug details. This may be interesting for anyone looking to learn more about Navigation3. It also contains some cool tricks in there (but the end result wasn't quite what we need). I would suggest at least skimming it.
Working Solution
Let's continue with a working solution.
LocalNavigate (for GuardedNavigate)
object BasicNav2 {
private val LocalNavigate = staticCompositionLocalOf<(NavigationScreen)
-> Unit> { error("No LocalNavigate provided") }
...
- The value rarely changes
- When everything below it will change when it changes
@Composable
private fun HomeScreen() {
val navigate =
LocalNavigate.current
BasicNav1.HomeScreen {
navigate(NavigationScreen.List)
}
}
@Composable
private fun ListScreen() {
val navigate =
LocalNavigate.current
BasicNav1.ListScreen(
{
index -> navigate(
NavigationScreen.Detail(index)
)
}
)
}
GuardedNavigate (uses LocalNavigate)
- GuardedNavigate takes in a key, navigation target, and screen to display.
- key is our NavigationScreen currently being displayed (passed in from the entry)
- guardedNavLambda prevents us stacking clicks by checking if key is still the last entry on the backstack. It will block the call if the transition is in flight (current screen no longer top).
- CompositionLocalProvider injects the initial value to LocalNavigate that allows us to navigate without having to pass lambdas everywhere. That's why our Home and List screens look much tighter now.
@Composable
private fun GuardedNavigate(
key: NavigationScreen,
backStack: NavBackStack<NavKey>,
content: @Composable () -> Unit
) {
val guardedNavLambda = remember {
{ screen: NavigationScreen ->
if (backStack.lastOrNull() == key)
backStack.add(screen)
}
}
CompositionLocalProvider(
LocalNavigate provides guardedNavLambda,
content = content
)
}
NavigationApp
We also need to adjust our NavigationApp to use our GuardedNavigate. This is quite simple. We are now passing the actions to GuardedNavigate and not the screen Composables. We also have to pass a key.
You may also note that we have added animations:
- transitionSpec - navigating deeper
- popTransitionSpec - navigating back
- predictivePopTransitionSpec - navigating back with back button / predictive gesture
Note: We are using GuardedNavigate on our detail screen. We don't actually need it here as we cannot navigate anywhere deeper, but I have left that for consistency and to allow for deeper navigation if required.
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
transitionSpec = TransitionAnimations
.forwardTransition,
popTransitionSpec = TransitionAnimations
.popTransition,
predictivePopTransitionSpec = TransitionAnimations
.predictivePopTransitionSpec,
entryProvider = entryProvider<NavKey> {
entry<NavigationScreen.Home> { key ->
GuardedNavigate(key, backStack) { HomeScreen() }
}
entry<NavigationScreen.List> { key ->
GuardedNavigate(key, backStack) { ListScreen() }
}
entry<NavigationScreen.Detail> { key ->
GuardedNavigate(key, backStack) {
BasicNav1.DetailScreen(key.id)
}
}
}
)
TransitionAnimations
Here are the animations for clarity. I have moved the ContentTransform out and given them clear names. Notice that both pops share the same animation (moving back to left from right).
object TransitionAnimations {
private val screenInLeftOldOutRight = slideInHorizontally(
animationSpec = tween(150)
) { width -> -width } togetherWith slideOutHorizontally(
animationSpec = tween(150)
) { width -> width }
private val screenInRightOldOutLeft = slideInHorizontally(
animationSpec = tween(150)
) { fullWidth -> fullWidth } togetherWith slideOutHorizontally(
animationSpec = tween(150)
) { fullWidth -> -fullWidth }
val forwardTransition:
AnimatedContentTransitionScope<Scene<NavKey>>.() ->
ContentTransform = { screenInRightOldOutLeft }
val popTransition:
AnimatedContentTransitionScope<Scene<NavKey>>.() ->
ContentTransform = { screenInLeftOldOutRight }
val predictivePopTransitionSpec:
AnimatedContentTransitionScope<Scene<NavKey>>.(Int) ->
ContentTransform = { _ -> screenInLeftOldOutRight }
}
Result
This is much better. Everything works smoothly and we have nice animations.
The fast animations are a vital part of keeping the UI responding properly. If you remove these, you will notice the UI looks and feels far worse. The animations look almost corrupt and the main screen button sometimes stops functioning when you are transitioning back from the list.
Important: Keep the animations fast for a smooth user experience!
Full code listing - Example 2 GitHub Gist.
Far better than the failed attempt!
Next?
Links
My links
GitHub Gists
Blog Posts
This Series
- Navigation 3 Part 1 - Basic Examples - this
- Navigation 3 Part 2 - Dual Pane Examples
- Navigation 3 Part 3 - Muscle Car App
- Navigation 3 Part 1, 2 and 3 Dependencies (Shared)
- Navigation 3 Part 1 - Buggy Old Version
Other Related
- LightNavMenu v1 Blog Post
- LightNavMenu v2 Blog Post (will be posted one day - I have been using it for ages now)
- Process Death Part 1
- Process Death Part 2
- Why I use Objects for Composables
Official links
Google:
- Navigation3
- Navigation2 -> 3 Migration
- Nav3 Recipes (GitHub)
- Nav3 Recipes (GitHub) - Passing ViewModels
- Backstack
- CompositionLocal
- ViewModel
- StateFlow and SharedFlow
- Recomposition


Comments
Post a Comment