Navigation 3 - Part 1 - Basic Examples

Navigation3 Introduction Image

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

A quick overview of the changes:
  • 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 - 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

NavDisplay is the host of our navigation. We have our backStack*, which is a list of entries showing us where we are. You can think of this as a breadcrumb trail and we are always at the last entry in the list. Pressing the back button on your device will remove that last entry and take us the previous entry. onBack is doing exactly that.

Finally, we have our entryProvider which is a map of our NavigationScreen entries and the associated Composable to display.

HomeScreen and ListScreen take in a lambda navigating to the list page and detail page (with ID) respectively.

* If you are interested in backstacks, you may want to check out my LightNav V1 post which covers this subject in a little more detail (we build our own light-weight navigation system for small test projects).
@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).
It can also go out of sequence if tapping buttons during transitions

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)

We will use a function to prevent us flooding the backstack. This function is backed by a static CompositionLocal.
object BasicNav2 {
private val LocalNavigate = staticCompositionLocalOf<(NavigationScreen)
-> Unit> { error("No LocalNavigate provided") }
...

LocalNavigate is our static CompositionLocal. This allows us to call navigation from any point without needing to pass around lambdas to the functions. It also prevents unnecessary recomposition of children.

As a general rule, staticCompositionLocalOf should be used in the following situations:
  • The value rarely changes
  • When everything below it will change when it changes
Good examples are Remember Lambdas, Theme / Configs (set at root and rarely / never changed), LocalContext and LocalNavigate. Never use them for things you expect to change frequently.

In my usage here, the value never changes after it is initially set by GuardedNavigate (see below).

Let's see how this affects our Home and List screens:
@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)
)
}
)
}
As you can see, we don't pass any lambdas into these functions now.

GuardedNavigate (uses LocalNavigate)

There is quite a lot to look at in GuardedNavigate. This is actually quite complicated so don't worry if you don't understand everything if you are just trying to get it all working. I'll try to break it down the best I can. 

  • 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.

Smoothly animated navigation menu

Far better than the failed attempt!

Next?

In Part 2, we were originally going to look at a much more attractive example themed around American muscle cars! This will add ViewModels and multi-pane support.

However, it ended up being too complex. Instead, Part 2 will focus on dual pane support and part 3 (added) will be the Muscle Car App with viewModel support.

Next! Animation

Links

My links

GitHub Gists

Blog Posts

This Series

Other Related

Official links

Google:

Kotlin:

Comments

Popular Posts