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:

  1. hasLoggedIn = true
  2. hasLoggedIn = false
  3. 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!

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.

when (viewState) {
    is ViewState.LoggedIn -> {
        HomeNavHost( // contains home & detail destinations
            onLogOut = {
                // navigate to terms here?

                viewModel.setHasLoggedIn(false)

		// navigate to terms here?
            }
        )
    }
    is ViewState.NotLoggedIn -> {
        LoginNavHost() // contains login & terms destinations
    }
}

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!