Navigation 3 - Part 2 - Dual Pane Examples


Fake magazine cover

Part 2

I was originally going to stick with two parts for this guide, but there was just far too much content to cram it in. I think the final version would have introduce far too many concepts and this would have been a nightmare to explain and learn from.

Rather than jump straight from the basic introduction to the final muscle car example, I decided to add an intermediate step. So here we are, the added part two.

Focus

This part will focus on adding dual-pane support and and a backstack guard to handle that (our previous version will not). We will briefly cover size restrictions for dual-pane testing as well as hierarchical back vs chronological back.

We are reusing most of the code from our previous examples so the code listing will be short and simple - perfect for learning. This does mean you will need the previous examples and of course the dependencies to get everything working.

There are two solutions: Dual Pane Example 1 and Dual Pane Example 2. You can skip to Example 2 if you just want a working solution, but I would recommend reading Example 1 if you want to improve your understanding of why we needed to make these changes.

Note: My emulator is configured for testing Process Death to ensure nothing strange happens with these solutions. Everything works as expected. I would strongly encourage people to test for this on a regular basis when using new systems.

Dual Pane Example 1

A lot of the code below appears the same as before, so we will focus on the changes. We can start with how to add dual pane support. Coming up with the solution was dual pane pain, but was very satisfying to get it all working smoothly.

NavigationApp

The first difference you will notice is listDetailStrategy. This is implemented in NavDisplay with sceneStrategies  = listOf(listDetailStrategy). As you can infer from the name, this implies we are splitting our scenes into list and detail. This allows NavDisplay to automatically handle dual pane support for these elements.

We then need to add the relevant metadata to our entries: ListDetailSceneStrategy.listPane and .detailPane so NavDisplay knows what is what.

You will also notice detailPlaceholder = { DetailPlaceholder() }. I'll go into that in a moment.
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun NavigationApp() {
val backStack =
rememberNavBackStack(NavigationScreen.Home)

val listDetailStrategy =
rememberListDetailSceneStrategy<NavKey>()

NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
transitionSpec = TransitionAnimations
.forwardTransition,
popTransitionSpec = TransitionAnimations
.popTransition,
predictivePopTransitionSpec = TransitionAnimations
.predictivePopTransitionSpec,
sceneStrategies = listOf(listDetailStrategy),
entryProvider = entryProvider<NavKey> {
entry<NavigationScreen.Home> {
GuardedNavigateDualPane(backStack) {
HomeScreen()
}
}
entry<NavigationScreen.List>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = { DetailPlaceholder() }
)
) {
GuardedNavigateDualPane(backStack) {
ListScreen()
}
}
entry<NavigationScreen.Detail>(
metadata = ListDetailSceneStrategy.detailPane()
) { key ->
GuardedNavigateDualPane(backStack) {
BasicNav1.DetailScreen(key.id)
}
}
}
)
}

DetailPlaceholder

This is the screen that gets displayed if we are in dual pane and no item from our list has been displayed.

When testing, make sure you have an expanded screen. I used Pixel 9a emulator and also tested on a tablet layout.

@Composable
private fun DetailPlaceholder() {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"Select something",
style = MaterialTheme.typography.bodyLarge
)
}
}
Empty detail illustration

GuardedNavigateDualPane

Finally, we need to update GuardedNavigate as our previous solution does not work with dual pane.

Previous Solution

Our previous solution used guardedNavLambda to check the calling screen was at the end of the stack before adding the new entry. If you clicked more than once, it would only accept the first click as the backstack would then change to hold the selected details.

val guardedNavLambda = remember {
{ screen: NavigationScreen ->
if (backStack.lastOrNull() == key)
backStack.add(screen)
}
}

This was safe for single pane as you could never select from the list when a detail screen was being displayed, but that obviously doesn't work for dual pane. It was a good solution for the problem, but it didn't scale.

New Solution

The new solution does things a little differently as we now need to allow transitions when a detail screen is already displayed.

Navigation from list to detail will always fire, but instead of removing and adding the last element, it checks to see if the same screen type (in our case the detail) already exists and replaces it with the new entry.

We are using it::class == screen::class to check if we already have the same type in the list rather than comparing ids which can be different for the same class type. This ensures we only have one of each type in our backstack.

If there are no screens of the current type in the list, it simply adds the selection to the backstack.

This leads us nicely to hierarchical back vs chronological back. From the names, you may be able to tell hierarchical relates to structure (layers) and chronological relates to time. For this app, we are using hierarchical back. Chronological back would be better suited when the user needs a history of the pages they viewed.

Also of note, we no longer need to pass the key into the function. We add entries with a simple GuardedNavigateDualPane(backStack) { screen }.

@Composable
private fun GuardedNavigateDualPane(
backStack: NavBackStack<NavKey>,
content: @Composable () -> Unit
) {
val guardedNavLambda: (NavigationScreen) -> Unit =
remember(backStack) {
{ screen: NavigationScreen ->
val existing = backStack.indexOfLast {
it::class == screen::class
}
if (existing >= 0) {
backStack[existing] = screen
} else {
backStack.add(screen)
}
}
}
...
}

Working

This solution works. There's nothing wrong with it, but a little improvement always feels nice.


Animated Dual Pane solution

- Tested with a 1080 x 2424 - 412 x 924 dp (420dpi) screen - Pixel 9a emulator.

Dual Pane Example 2

GuardedNavigate has been removed! Instead, we just have use the same lambda from above.

State Holder

And of course, we can extract that for easier reuse and for a cleaner NavigationApp. I aim to try to put longer code sections into State Holders composables where I can to keep the code easy to read and maintain.

We simply call it with val guardedNavLambda = rememberDualPaneNavLambda(backStack).
@Composable
fun rememberDualPaneNavLambda(
backStack: NavBackStack<NavKey>
): (NavigationScreen) -> Unit =
remember(backStack) {
{ screen: NavigationScreen ->
val existing = backStack
.indexOfLast { it::class == screen::class }
if (existing >= 0) {
backStack[existing] = screen
} else {
backStack.add(screen)
}
}
}

Final NavigationApp

So we now have our final version with dual pane support. Notice we have moved CompositionLocalProvider over NavDisplay. We no longer need to reference GuardedNavigateDualPane everywhere so this looks much cleaner as well.

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun NavigationApp() {
val backStack =
rememberNavBackStack(NavigationScreen.Home)

val guardedNavLambda = rememberDualPaneNavLambda(backStack)

val listDetailStrategy =
rememberListDetailSceneStrategy<NavKey>()

CompositionLocalProvider(
LocalNavigate provides guardedNavLambda
) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
transitionSpec = TransitionAnimations
.forwardTransition,
popTransitionSpec = TransitionAnimations
.popTransition,
predictivePopTransitionSpec = TransitionAnimations
.predictivePopTransitionSpec,
sceneStrategies = listOf(listDetailStrategy),
entryProvider = entryProvider<NavKey> {
entry<NavigationScreen.Home> { HomeScreen() }
entry<NavigationScreen.List>(
metadata = ListDetailSceneStrategy.listPane(
detailPlaceholder = {
DetailPlaceholder()
}
)
) { ListScreen() }
entry<NavigationScreen.Detail>(
metadata =
ListDetailSceneStrategy.detailPane()
) { key -> BasicNav1.DetailScreen(key.id) }
}
)
}
}

Summary

So we now have smooth, Process Death safe, dual pane navigation using the new Navigation3 library. I think that's something to celebrate!

Android celebrating

Next Up - Muscle Car App

I will likely need a little time to get this done, but Part 3 of this series will be the Muscle Car App with viewModel support. Again, I will make sure it is Process Death safe!

Links

My links

GitHub Gists

Official links

Google:

Kotlin:

Comments

Popular Posts