Navigating in Compose: Criteria

Navigating between screens is a common act in an Android app… though, as Zach Klippenstein noted, “screen” is a somewhat amorphous term. Naturally, we want to be able to navigate to different “screens” when those screens are implemented as composables.

How we do this is a highly contentious topic.

Roughly speaking, there seems to be four major categories of solutions:

  • Use the official Jetpack Navigation for Compose

  • Use some sort of wrapper or helper around Navigation for Compose — Rafael Costa’s compose-destinations library is an example

  • Use Jetpack Navigation, but use the “classic” implementation instead of Navigation for Compose, using fragments to wrap your screen-level composables

  • Use some separate navigation implementation, such as Adriel Café’s Voyager library

I cannot tell you what to use. I can tell you that you should come up with a set of criteria for judging various navigation solutions. Based on a survey of a bunch of navigation solutions, here are some criteria that you may want to consider.

Table Stakes

If your navigation solution does not support forward navigation to N destinations, or if it does not support back stacks (e.g., a goBack() function to pop a destination off the stack and return to where you had been before), use something else.

Compile-Time Type Safety

One key point of using Kotlin, and Java before it, is type safety. The more type safety we get, the more likely it is that we will uncover problems at compile-time, rather than only via testing or by the app going 💥 for your users.

…For Routes/Destinations

When you tell the navigation solution where to navigate to in forward navigation, you may want to prefer solutions where the identifier is something that is type safe. Some solutions use strings or integers to identify routes or destinations. That makes it very easy to do some really scary things, like compute a destination using math. Generally, primitives-as-identifiers offer little compile-time protection. You might prefer solutions that use enums, sealed class, marker interfaces, or other things that identify what are and are not valid options.

(and if you are asking yourself “what about deeplinks?”, that is covered a bit later)

…For Arguments

Frequently, our screens need data, whether an identifier (e.g., primary key) or the actual data itself. So, we want to be able to pass that data from previous screens. All else being equal, you might want to prefer solutions that offer compile-time type safety, so you do not wind up in cases where you provide a string and the recipient is expecting an Int instead.

A related criteria is “content safety”. You might want to prefer solutions where your code can just pass the data, without having to worry about whether it complies with any solution-specific limitations. For example, if the solution requires you to URL-encode strings to be able to pass them safely, that is not great, as you will forget to do this from time to time. Ideally, the solution handles those sorts of things for you.

…For Return Values

At least for modal destinations, such as dialogs, we often need to pass back some sort of “result”. For example, we display a dialog to allow the user to pick something, and we need the previous screen to find out what the user selected. Sometimes, there are ways of accomplishing this outside of a navigation solution, such as the dialog updating some shared data representation (e.g., shared Jetpack ViewModel) where the previous screen finds out about results reactively. But, if the navigation solution you are considering offers return values, and you intend to use them, you might want to prefer ones where those return values are also type-safe and content-safe.

IOW, forward-navigation arguments should not get all the safety love.

Support for Configuration Change and Process Death

Like it or not, configuration changes are real. Birds, perhaps not.

One way or another, your app needs to be able to cope with configuration changes, and your navigation solution should be able cope as well, to support your app. This includes both retaining the navigation data itself across configuration changes and, ideally, having a pattern for app data for your screens to survive as well (e.g., Navigation for Compose’s per-route ViewModel support).

Related is process death:

  • The user uses your app for a while

  • The user gets distracted by some not-a-bird for a while, and your app’s UI moves to the background

  • While in the background, Android terminates your process to free up system RAM

  • The user returns to your app after your process dies, but within a reasonable period of time (last I knew, the limit was 30 minutes, though that value may have changed over the years)

Android is going to want to not only bring up your app, but pretend that your process had been around all that time. That is where “saved instance state” comes into play, and ideally your navigation solution advertises support for this, so your back-stack and so on get restored along with your UI.

Hooks For Stuff You Might Use

Only you know what your app is going to need to do in terms of its UI. Or perhaps your designers know, or your product managers. Or, hey, maybe you are just spraying pixels around like Jackson Pollock sprayed paint. Who am I to judge?

Regardless, there may be some things that you want in your app’s UI or flow that tie into what you will need out of your navigation solution.

Tabs, Bottom Sheets, Pagers, Etc.

Many apps use these sorts of UI constructs. It may not be essential that they be handled via a navigation solution — you might be able to model them as being “internal implementation” of a screen, for example. But, it would be good to get a sense of what patterns are established, if any, for a particular navigation solution to tie into these kinds of UI constructs. For example, if you need to able to not only navigate to a screen, but to a particular tab or page within that screen, it would be nice if the navigation solution supported that. Perhaps not essential, but nice.

And, for some of these UI constructs, you might be seeking to have multiple back stacks. For example, you might want to have it so that back navigation within a tab takes you to previous content within that tab, rather than going back to other tabs that the user previously visited. Support for multiple back stacks seems to be a bit of an advanced feature, so if this is important to you, see what candidate navigation solutions offer.

Deeplinks are popular. Here, by “deeplink”, I not only mean situations where a destination is triggered from outside of the app, such as from a link on a Web page. I also mean cases where a destination is determined at runtime based on data from an outside source, such as a server-driven “tip of the day” card that steers users to specific screens within the app.

If you think that you will need such things, it will be helpful if your navigation solution supports them directly. That support may not be required — just as your other app code can navigate to destinations, your “oh, hey, I got a deeplink” code can navigate to destinations. However, a navigation solution may simplify that, particularly for cases where the deeplink is from outside of the app and you need to decide what to do with the already-running app and its existing back stack.

When evaluating deeplink support, one criteria that I will strongly suggest is: deeplinks should be opt-in. Not every screen in your app should be directly reachable by some outside party just by being tied into some navigation system — that can lead to some security problems.

Also, consider how data in the deeplink will get mapped to your arguments (at least for routes that take arguments). Some navigation solutions will try to handle that automatically for you, but be wary of solutions that use deeplinks as an excuse to be weak on type safety. Ideally, there should be an unambiguous way to convert pieces of a deeplink (e.g., path segments, query parameters) to navigation arguments, but in a way that limits any “stringly typed” logic to deeplinks themselves and does not break type safety elsewhere.

Transitions

Your designers might call for a specific way to transition from screen X to screen Y, such as a slide, a fade, a slide-and-fade, a fireworks-style explosion destroying X with Y appearing behind it, etc. Ideally, the navigation solution would handle those sorts of transitions, particularly if you need to control the back-navigation transition as well (e.g., the exploded fireworks somehow reassembling themselves into a screen, because that sounds like fun).

Development Considerations

Does the library have clear documentation? Does it seem to be maintained? Does it have a clear way of getting support? Does it have a license that is compatible with your project? These are all common criteria for any library, and navigation solutions are no exception.

Beyond that, navigation solutions have a few specific things that you might want to consider, such as:

  • How easily can you support navigation where the destinations might reside in different modules? Particularly for projects that go with a “feature module” development model, it is likely that you need a screen in module A to be able to navigate to a screen in module B.

  • Are there clear patterns for using @Preview? In principle, a navigation solution should not get in the way of using @Preview for screen-level composables, but it would be painful if it did.

  • Does the solution work for development targets beyond Android? Perhaps you are not planning on Compose for Desktop or Compose for Web or Compose for iOS or Compose for Consoles. If you are, you are going to want to consider if and how navigation ties into your Kotlin/Multiplatform ambitions.


This is not a complete list — if there are things that you think are fairly popular that I missed, reach out!