Navigation 3 - Part 1 - Buggy Old Example 2

 

Cute cartoon image of bugs on a phone holding a sign saying "No nice Navigation3 for you!"

Broken Example 2

I want to publish this in addition to the original blog post so you can see my first broken solution. I think many people are reluctant to publish their mistakes, but I believe it can be a useful learning tool. I will keep most of the text exactly as originally published but I've changed some of the naming to make it clear that it should not be used. I'll add some additional comments at the end.

Original Posting:

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.

LocalNavigate (for GuardedNavigate)

We will use a function to prevent us flooding the backstack. This function is backed by a static CompositionLocal.
object BasicNav2OldBuggy {
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 LambdasTheme 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. 

I have added a few supporting links as you would likely need to do more of your own research on these topics to get a fuller understanding of exactly what is going on here.

  • GuardedNavigate takes in a navigation target and screen to display. 
  • lifecycle is used to track when we drop from RESUMED (not animating so fully interactive in the forground) to STARTED (animating a transition) and is monitored by our LaunchedEffect to update hasNavigated.
  • hasNavigated is our guard. It returns true during a navigational transition animation.
  • currentOnNavigate by rememberUpdatedState tracks our most recent navigation action so we can track updates. Without this, our guardedNavLambda would only take the first value passed into the function and fail to update for future actions.
  • LaunchedEffect monitors our lifecycle to keep hasNavigated updated.
  • Within the LaunchedEffect, we use snapshotFlow to convert our compose state into a Flow so we can observe lifecycle changes inside the coroutine.
  • guardedNavLambda prevents us stacking clicks and making a mess of our backstack.   
  • CompositionLocalProvider injects the initial value to LocalNavigate that allows us to navigate without having to pass lambdas everywhere.

@Composable
private fun GuardedNavigateOldBuggy(
onNavigate: (NavigationScreen) -> Unit,
content: @Composable () -> Unit
) {
val lifecycle =
LocalLifecycleOwner.current.lifecycle
var hasNavigated by remember {
mutableStateOf(false)
}
val currentOnNavigate by
rememberUpdatedState(onNavigate)

LaunchedEffect(lifecycle) {
snapshotFlow { lifecycle.currentState }
.collect {
if (it == Lifecycle.State.RESUMED)
hasNavigated = false
}
}

val guardedNavLambda =
remember {
{ screen: NavigationScreen ->
if (!hasNavigated) {
hasNavigated = true
currentOnNavigate(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.

Note: We are using GuardedNavigate on our detail screen. We don't actually need it here as we cannot navigate anywhere deeper from there. I have left that for consistency and to allow for deeper navigation if required.

NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<NavigationScreen.Home> {
GuardedNavigateOldBuggy(
{ backStack.add(it) }
) { HomeScreen() }
}
entry<NavigationScreen.List> {
GuardedNavigateOldBuggy(
{ backStack.add(it) }
) { ListScreen() }
}
entry<NavigationScreen.Detail> { key ->
GuardedNavigateOldBuggy(
{ backStack.add(it) }
) { BasicNav1.DetailScreen(key.id) }
}
}
)

Result

I thought this was good, but it actually contains another bug! If you navigated into a list item and then pressed back during the transition animation you would not be able to choose any other options in the list. The only choice was to jump back out to the home screen then head back in again...

Animation of bug

The same thing can also happen on the main screen. If that happens there, you have to kill the app and then relaunch it for it to be functional.

As well as the bugs, I wasn't really very happy with the default transition animation as it feels a little slow and can look ugly when navigating two pages quickly. We'll fix that as well.

I missed this when I was first published so I quickly unpublished the blog and started working on a solution. It's already working and testing so I will hopefully post that later today.

Comments / Thoughts

Navigation3 is not so friendly at the start. The default behavior is not what you want for a good user experience. I think this is a little disappointing as I believe it would be far better to have a very solid smooth default and allow for customization on top of that. The way it is now, we have to customize to give a good experience.

Comments

Popular Posts