Navigation Compose
In this post, I'm going to talk about some of the pitfalls of Jetpack Navigation in Compose, and how to avoid them. Understanding these problems and finding their solutions has been quite a journey for me. This post will try to guide you through that journey - including some examples of bad practices!
Let's Begin
Suppose we have an app with a Home screen and Detail screen.
In order to navigate between these screens, we can use a NavHost
and NavController
. The startDestination
is home
, and when the user clicks a button, they're taken to the detail
destination
.
@Composable
fun MyNavHost(navController: NavHostController = rememberNavController()) {
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
HomeScreen(
onNavigateToDetailScreen = {
navController.navigate("detail")
}
)
}
composable("detail") {
DetailScreen()
}
}
}
Conditional Navigation
What if before they get to the home
screen, the user needs to login. If they're not logged in, they see the login
screen. Otherwise, they see the home
screen.
Assume we have a ViewModel
which keeps track of the logged in state:
class AppViewModel() {
val hasLoggedIn: Flow<Boolean> = ...
fun setHasLoggedIn(value: Boolean){ ...
}
We could render the NavHost
if the user is logged in, or otherwise render the LoginScreen
@Composable
fun ComposeNavigationApp(
viewModel: AppViewModel
) {
val hasLoggedIn by viewModel.hasLoggedIn
.collectAsState(initial = false)
if (hasLoggedIn) {
MyNavHost()
} else {
LoginScreen(
onLoginClicked = {
viewModel.setHasLoggedIn(true)
}
)
}
}
This works, although there is a subtle problem. The initial value of hasLoggedIn
is false
, so the LoginScreen
is rendered momentarily, even if the user is logged in.
View states
There are 3 possible states that we need to account for:
hasLoggedIn = true
hasLoggedIn = false
hasLoggedIn = unknown
An idiomatic way to handle this in Kotlin/Compose is to define these states in a sealed classs, and then expose them to our Composable:
sealed class ViewState {
object Loading: ViewState() // hasLoggedIn = unknown
object LoggedIn: ViewState() // hasLoggedIn = true
object NotLoggedIn: ViewState() // hasLoggedIn = false
}
class AppViewModel() {
val hasLoggedIn: Flow<Boolean> = ...
val viewState = hasLoggedIn.map { hasLoggedIn ->
if (hasLoggedIn) {
ViewState.LoggedIn
} else {
ViewState.NotLoggedIn
}
}.stateIn(initial = ViewState.Loading, ...
}
@Composable
fun ComposeNavigationApp(
viewModel: AppViewModel
) {
val viewState by viewModel.viewState.collectAsState()
when(viewState) {
is ViewState.Loading -> {
LoadingView()
}
is ViewState.LoggedIn -> {
MyNavHost()
}
is ViewState.NotLoggedIn -> {
LoginScreen()
}
}
}
By introducing LoadingView
, we now have a screen to display when we're not yet sure if the user is logged in.
Using sealed classes to represent screen/view states is pretty common practice in Compose, and it can make it really easy to understand all the distinct states your screen will be rendered in. However, rendering different screens based on these states is a sort of reactive navigation. Using the NavController
maps more closely to imperative navigation. Mixing reactive and imperative paradigms together can lead to some very confusing problems!
Navigating Deeper
What if we want to be able to navigate from our login
screen to another screen, maybe a terms
screen? In our implementation above, LoginScreen()
exists outside of the NavHost
, so if we ask the NavController
to navigate to terms
, we're going to encounter an error.
Maybe we could set login
as our startDestination
, and then navigate to home
once the user is logged in?
Principles of navigation
Lucky for us, Google have written a bunch of navigation principles for us to follow. And, their first point is:
Every app you build has a fixed start destination.
The principles go on to say:
An app might have a one-time setup or series of login screens. These conditional screens should not be considered start destinations because users see these screens only in certain cases.
One of the main reasons temporary/conditional screens should not be used as a start destination, is to properly support deep linking.
Ian Lake touches on this in the following video:
In summary:
Deep links have multiple entry points, so users won't always see your start destination as their first screen. Android itself will restore users back to exactly where they were.. the start destination.. is automatically put on the backstack when you login. Hitting back and returning to your login screen isn't a good look.
If you can be restored at any destination by the Android system, then each destination that requires login needs to conditionally navigate to your login screen. If the user logs in successfully, the login screen gets popped off the back stack, and tada, you're back where you were..
Note: Deep links are not the only reason to avoid having a conditional screen as your start destination. Performance is another consideration. Usually conditional screens such as login or onboarding are only shown to the user once. The other 99% of the time the user launches the app, they're going to be taken to the main content. Navigating the user via a conditional screen that is immediately dismissed is a waste of precious start up time!
Ok, we're not supposed to use login
as a startDestination
. What other bad ideas can we come up with?
Multiple NavHosts
We could move the login
& terms
destinations into their own NavHost
:
when (viewState) {
is ViewState.Loading -> {
LoadingView()
}
is ViewState.LoggedIn -> {
HomeNavHost() // contains home & detail destinations
}
is ViewState.NotLoggedIn -> {
LoginNavHost() // contains login & terms destinations
}
}
So, we render a different NavHost
based on our viewState
, and each NavHost
sort of encapsulates its own destinations. This sounds good in theory, right? Well.. it gets complicated. Using multiple NavHosts
is not recommended!
One of the problems with having multiple NavHosts
, is you have to keep in mind which one is being rendered before you try to navigate somewhere. If you try to navigate to the terms
screen (in the HomeNavHost
) while the viewState
is LoggedIn
, you'll get an error - as your HomeNavHost
has no knowledge of the terms
destination.
As you build up more states, more destinations and possibly more NavHosts
, it's going to get even harder to keep track.
An example:
Let's say there's a new requirement in your app. There's a log-out button on the home
screen, and when the user logs out, they should be directed to the terms
screen.
We barely have to write any code to know this is going to be a pain.
Where do we call navController.navigate(terms)
?
If we do this before viewModel.setHasLoggedIn(false)
, we'll get an error, as the HomeNavHost
doesn't know about the terms
screen!
We could fix that by adding the terms
destination to the HomeNavHost
. Now, we'll be able to navigate to the terms
screen. Then our viewState
will change to NotLoggedIn
, our NavHost
will change to LoginNavHost
and we'll no longer be looking at the terms
screen!
If we do this after viewModel.setHasLoggedIn(false)
, we'll be hoping that the log out process has completed, viewState
has changed and LoginNavHost
is rendered in time to handle our navigate call. There's plenty of room for this to go wrong. Flaky errors abound!
The issue is that we're relying on ViewState
to be our source of truth for which screen to render. But, we're also sometimes relying on our NavController
to decide which screen to render. We have multiple sources of truth. Someone must be lying!
Note: There is another common case where it's really tempting to use multiple NavHosts
(when implementing Bottom Navigation). I hope to cover this in a separate post.
More view states
So what should we do? I guess we could introduce a new ViewState
to keep track of when we should go to the terms screen after the user has logged out.
sealed class ViewState {
object Loading: ViewState() // hasLoggedIn = unknown
object LoggedIn: ViewState() // hasLoggedIn = true
object NotLoggedIn: ViewState() // hasLoggedIn = false
object Terms: ViewState()
}
This is another bad idea. How many states might you end up with? You can have a ViewState
of NotLoggedIn
, and imperatively navigate to the terms
screen via the NavController
. Or, you can have a ViewState
of Terms
and render the terms
screen. What happens when the user wants to navigate away from the terms
screen? If we call navController.popBackStack()
, but we're on ViewState.Terms
, nothing will happen. It's a mess!
Multiple NavHosts are bad
Having multiple NavHosts
is generally a bad idea. It can be hard to keep track of which one is current. Rendering different NavHosts
based on some state introduces the multiple sources of truth problem. Which screen should be rendered right now? The one the navController
navigated to? Or the one the ViewState
wants to display?
Single NavHost
Here's a SingleNavHost
, which contains all destinations:
@Composable
fun SingleNavHost(
startDestination: String,
navController: NavHostController = rememberNavController()
) {
NavHost(
navController = navController,
startDestination = startDestination
) {
composable("home") {
HomeScreen()
}
composable("detail") {
DetailScreen()
}
composable("login") {
LoginScreen()
}
composable("terms") {
TermsScreen()
}
}
}
A single NavHost
means we don't need to introduce intermediate ViewStates
in order to render content that's not backed by a destination
in the NavHost
. Instead, all of our destinations are available, and can be navigated to via navController.navigate()
. If you arrive at a screen and you want to navigate away from it, you can call navController.popBackStack()
. You don't have to think about whether your NavHost
is still available. You have a single source of truth for navigation.
Sounds good, but how do we implement our conditional navigation with a single NavHost
?
Multiple Start Destinations
We could try using a different startDestination
, based on our logged in state:
@Composable
fun ComposeNavigationApp(
viewModel: AppViewModel
) {
val hasLoggedIn by viewModel.hasLoggedIn
.collectAsState(initial = false)
if (hasLoggedIn) {
SingleNavHost(startDestination = "home")
} else {
SingleNavHost(startDestination = "login")
}
}
But, this is cheating! That's not really a single NavHost
. There are two instances, and we just render a different one depending on our logged in state. We have a dynamic start destination. The principles of navigation already stated that we should have a fixed start destination. Let's look for a better solution!
A Better Way
.. is to stick to our single NavHost
, and move the conditional navigation logic to the home
screen!
Since every app should have a fixed start destination, and our conditional screen shouldn't be considered a start destination, then what other choice do we have?
@Composable
fun SingleNavHost(
viewModel: AppViewModel,
navController: NavHostController = rememberNavController()
) {
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
HomeScreen(
viewModel = viewModel,
onNavigateToLoginScreen = {
navController.navigate("login")
}
)
},
composable("login") {
LoginScreen()
}
...
@Composable
fun HomeScreen(
viewModel: AppViewModel,
onNavigateToLoginScreen: () -> Unit = {}
) {
val viewState by viewModel.viewState.collectAsState()
when (viewState) {
AppViewModel.ViewState.Loading -> {
LoadingView()
}
AppViewModel.ViewState.NotLoggedIn -> {
LaunchedEffect(viewState) {
onNavigateToLoginScreen()
}
}
AppViewModel.ViewState.LoggedIn -> {
HomeScreenContent()
}
}
}
Huh, that was actually pretty easy. If the ViewState
changes to NotLoggedIn
, we navigate to the login
screen.
We've already realised one major advantage with this approach: Once our ViewState
changes and causes us to navigate away from the home
screen, the home
screen is no longer observing viewState
, so subsequent changes won't have any affect until we return. So if we're on the login
screen, and we want to navigate to terms
, we don't have to worry about viewState
changes swapping the NavHost
out from under us.
Secondly, once the user has finished logging in via the login
screen, we can just call navController.popBackStack()
, and we end up back on the home
screen. At this point, home
screen starts observing viewState
again, recognises that we're logged in, and renders the HomeScreenContent()
. Even better, our login
screen is no longer on the back stack! So we can't accidentally return to it with the back button.
Handling login failure
There's one last piece remaining. As it stands, the user launches the app, the home
screen is rendered, the app determines the user is not logged in, and navigates them to the login
screen. But, what if the user presses back? The system will pop the back stack, and we'll end up on the home
screen - which will then direct us back to the login
screen. We're stuck in a loop!
There are a couple of ways to deal with this, depending on what you think should occur when the user chooses not to log in from the login screen.
The simplest approach, is to just exit the app if the user presses back from the login
screen:
@Composable
fun LoginScreen(onExitApp: () -> Unit) {
BackHandler(enabled = true) {
onExitApp()
}
onExitApp
can be passed up the call hierarchy until you reach your host Activity
, at which point you can call Activity.finish()
. On Android S (API 31) and above, the system actually calls Activity.moveTaskToBack()
rather than finish()
- so you might want to follow that behaviour. See the docs on onBackPressed and moveTaskToBack for more info.
Alternatively, you could store some state on the AppViewModel
when the user navigates back from the log in screen - to indicate log in was not successful. The home
screen could observe this state, and render some Composable
content indicating that the user is required to log in. If they press back again, the system will exit the app for us.
Conclusion
Using a single NavHost
to hold our possible navigation destinations makes it really easy to reason about our code. We don't run into unexpected errors due to destinations being unavailable, and we don't have to keep track of which NavHost
is the current one.
Moving our conditional navigation logic into the destinations that require it means that we shift back to an imperative style of navigation, with a single source of truth. We call navController.navigate()
where required, rather than sometimes observing ViewState
to render screens, and other times using the nav controller.
Lastly, by using an appropriate start destination, we ensure that the user won't be presented with any unexpected screens when following a deeplink, or using the back button.
Happy coding!