Portal Berita dan Informasi Terbaik di Indonesia

The State of Navigation in Jetpack Compose | by Kaustubh Patange | Jun, 2022

Investing in good navigation framework from start will help you save tons of migration work afterwards

Photo by Mick Haupt on Unsplash

It’s been about a year since Google announced Jetpack Compose’s 1.0 stable release, meaning developers can now create production-ready apps using its UI toolkit but, should you? Keep in mind that whatever I’ve said in this article is purely my opinion, so if there is anything you don’t agree then let me know through comments or Twitter 🙂

Over the course of time, the term “stable” has been misused a lot & it feels to be true for Jetpack Compose. Even though they (Google) say it is production-ready you should really do your research when using it for a big project like checking active issues on the issue-tracker, write sample apps to see how it performs i.e whether the toolkit provides the necessary widgets? Does it affect the size & performance of the release app? And most importantly does it affect developer productivity & what about tooling support? This is not only the case for Jetpack Compose but for any library/framework you wish to use. One of the techniques they’ve used to get away with not being called as “alpha” is by introducing @Experimental annotations to mark some features as unstable (not that these features don’t work, but the API could change breaking the binary compatibility with the previous versions). You should really take such things into account when choosing a completely new framework for writing UIs.

So when will it get stable for production use? Don’t get me wrong you can still use it but there are some gotchas you should really know if you are using it in apps that are consumed by millions of users. Keep in mind that the existing View-based toolkit had 10 years of time to mature so practically it makes sense we should at least expect around 3–4 years for Jetpack Compose to catch up & become stable so as to be adopted by teams on a larger scale. Even today if you see, the adoption of Kotlin is ~70% for Android & these are metrics captured from Google Play apps only, there is still a gap of ~30%. My point is don’t just become a fanboy, do your research, for every nice thing about the framework you should be able to list at least 2 bad things. If you really fail to do so (after extensive researching), congratulations the framework/library is ready for your use.

Now coming back to Jetpack Compose, if you are willing to adopt Jetpack Compose then the correct way to introduce this framework is to transition slowly and keep using Fragments instead of pure composable function. One of the things about Jetpack Compose is its interoperability with the existing View system through many interoperability APIs like ComposeView & AndroidView to support libraries like Exoplayer/Media3, etc. within a composable function.

The reason I brought this topic to attention is that when you create an application it is more likely that it will be composed of more than one screen so as to move onto a different screen you will need some kind of navigation system. On Desktop, this was not challenging due to concepts like Primary & Secondary Windows (dependent or independent) which OS manages for you. This changes when you develop apps for mobile devices or any device that is not a desktop, in order to save memory & support smaller screens lot of optimizations are put in place for eg: you only see one screen at a time, the other might be sleeping (Lifecycle.onStop); multi-tasking is still in its early stage considering the current state of foldable devices & are not much powerful as Desktop systems. So in order to move from one screen to another, a navigation system should do things like, cancel any ongoing job in the previous screen, release all the objects which are no longer in use so they can be garbage collected (in order to save memory) & a smooth transition that catches user’s attention so that they know there is a context switch.

When you are developing Android apps that have multiple screens, the one thing you should focus more on is managing object allocation & how much memory they are taking. Consider a ViewModel scoped to a Fragment, so when you move/navigate to a different Fragment whatever you are observing in the previous Fragment through ViewModel should be canceled to avoid unnecessary CPU, memory utilization & memory leaks. Luckily components like Fragment, ComponentActivity are lifecycle aware (LifecycleOwner) & have a SavedStateRegistry so any off-thread jobs you execute using LifecycleOwner.lifecycleScope will be automatically canceled when the owner moves to the destroyed state (navigating to another screen, application closed, etc.) & all the states you put in SavedStateHandle in ViewModel would be safely stored in a Bundle (onSaveInstanceState) for situations like process death.

In Compose UI you don’t have such solid components that provide these optimization benefits out of the box, all you do is describe your UI in a function & it just renders that on the screen. So effectively each destination in Compose is a composable function & what you want to achieve is to make them aware of Lifecycle, ViewModelStore & SavedStateRegistry by either managing yourself or through a navigation library that does it for you.

Now jumping to the actual topic the first thing you should look at in a navigation library is whether each screen manages Lifecycle & SavedStateRegistry so that you can scope ViewModels to the screen & ensure that when the screen is destroyed the ViewModels are also cleared from the memory. Compose has rememberSaveable which stores the value across configuration change & process death. The way they work is by hooking to LocalSaveableStateRegistry which is a composition local for your nearest SaveableStateHolder. This interface is responsible for storing rememberSaveable‘s value to the Activity’s or Fragment’s SavedStateRegistry with a unique string key, it does this by registering a lambda using SavedStateRegistry.registerSavedStateProvider that saves all the values of rememberSaveables’ in a bundle during onSaveInstanceState & restores during the first call to the rememberSaveable. You must make sure that the navigation library of your choice is correctly implementing SaveableStateHolder, to verify this just make sure the following pseudo implementation exists somewhere in the internals of the navigation library of your choice,

In Compose, we describe UI in Kotlin which is a statically typed programming language, so it is natural that we should use this type system extensively even for navigation i.e passing & resolving typed arguments when moving from one screen to another. The destination should also be strongly typed i.e represented by some entity object which then becomes easier for us to specify it during navigation (nice IDE auto-complete). However, this is not the case with some libraries (we will talk about it later).

Another thing you should expect from a navigation library is a good animation system. In Compose we have great animation APIs one of which is AnimatedContent. Even though it is experimental, it is quite powerful i.e you can chain multiple enter/exit transitions fadeIn() + scaleIn(), etc. Since screens in Compose is nothing but a @Composable function, when you are moving from one screen to other you are basically changing the entire content of the screen. AnimatedContent is one best use-case for such transition.

Now that you know the context we are in, let’s talk about the official support of the Navigation Component for Jetpack Compose i.e “navigation-compose”.

In “navigation-compose”, each destination is a NavBackStackEntry which is a Lifecycle, ViewModelStore & SavedStateRegistry owner. This helps to incorporate support for scoped ViewModels along with SavedStateHandleas SavedStateRegistry performs save & restore operations whenever an appropriate lifecycle event occurs i.e when a destination goes from onStart -> onStop (destination in backstack due to forward navigation, all states are saved including rememberSaveables’) -> onDestroy (the destination is removed from the backstack due to backward navigation). You can confirm this by adding a LifecycleEventObserver to NavBackStackEntry.lifecycle. Though I would still like built-in support for navGraph scoped ViewModels (just like Navigation Component) without me manually finding parent NavBackStackEntry & scoping ViewModel to it (an extension function would be great).

The navigation here is string (URI) based which is a major drawback. I get it why they took this approach to have built-in support for deeplinks just like Navigation Component for Fragments but they’ve overthrown type-safety while navigating & as well as passing arguments to the destination. Deeplinks are important but an implicit handling is not needed, one can always write a helper class to manage deeplinks & route them to the required destination with a few lines of code. This way you can separate & customize the logic according to your need.

Here you pass arguments & required destination by concatenating strings as destination/{arg1}&{arg2}. So you first have to convert your typed object to a URI based representation ignoring escape characters like “&”,
“%20” (space), “/”, “?”, “@”, etc. i.e bye-bye to passing parcelables, serializables as arguments. It clearly seems that they want us to avoid passing typed objects as parameters & use ViewModel or any state object to retrieve data from the cache (database) i.e you pass an “id” as an argument & retrieve the object from the database, however, sometimes this is not feasible & there may be a case where you want to show an in-memory temporary cache of an object & destroy it immediately when not needed. For eg: showing a payment success response in a dialog with some additional info so that when a user closes the dialog that information no longer exists.

Currently, “navigation-compose” has a default Cross-fade animation (with no ability to disable them) when navigating to other destinations. Full support for animations is supported through an incubation library project “Accompanist” (navigation-animation) & its setup looks like the below,

You can declare enter, exit, popEnter, or popExit transition as arguments to composable functions. These animations are implemented using AnimatedContent which is why you get lambda of type AnimatedContentScope. The only problem I feel with this system is you lose flexibility when defining animations. As above, if you see for enterTransition we’ve declared fadeIn() + slideIn() when the starting destination is “Second” (i.e coming from “Second” to “First”) & fadeIn for other cases. There is no flexibility apart from knowing the current (initialState) or target (targetState) destination & declaring animations according to it. This hugely promotes an imperative style where you must pre-assume animations based on state (destination) values.

If you take an example from the Fragments world, we useFragmentTransaction.setCustomAnimations(..) to define animations for any followed fragment operations (like replace, add, remove). This chain gives us enough flexibility to set animations based on any logical conditions & you can also refer to any local variable in those conditions.

You are defining animations during navigating to a destination (a more flexible & declarative approach i.e not just only limited to doing conditional animations based on initial or target destination but a lot more), this is not possible with Accompanist’s navigation-animation. On the positive side, I think they went with this imperative approach because then you have all animations defined in one place. For a reader, this would become easy to take a look at which animation will trigger just by looking at the navigation setup.

Compose Destinations:

If you are using “navigation-compose” then chances are you already have heard about this library (if you haven’t then take a look here). In a nutshell, Compose Destinations is a code generating library based on KSP which attempts to solve the problems with “navigation-compose”. The resulting navigation setup using this library removes most of the boilerplate which would you have to then write manually. The library solves the string-based navigation with typed-based by generating destinations annotated with @Destination on a composable function. The typed parameter of the composable function becomes the argument for the navigation with an implicit DestinationsNavigator argument that helps to manage navigation for the given NavGraph.

Nested navigation or defining your custom navigation is also straightforward, just make sure you don’t forget to annotate your nested navigation graph with the parent’s annotation (eg:@RootNavGraph) or be ready to get runtime crash when moving to any nested destination. This is the only real problem I’ve with this library, even though we have got typed navigation it is not typed to the current navigation graph so you can easily navigate to any destination without it being nested or parent since they all are of type Direction.

The other problem for me is that it is a code generation library, no matter what you do it will always affect build time + when cloning a fresh project there will be always some types, properties, or methods which does not exist unless you run build for the first time. For any change, you need to run build or kspDebugKotlin, this slows down your developer productivity as well as code reviews will become harder since destinations are scattered across the project so without a cumulative search for @Destination (using a Find tool) it will be hard to know which destinations contribute to which navigation graph.

You may feel like I might be over-exaggerating but again for me, I would generally avoid such code generation solutions in a big project that has many developers contributing to it & technically would move to other navigation libraries or might come up with our own navigation system if that is feasible.

My main motive behind this discussion (above) is to not convey any hate towards the “navigation-compose” library. It has its pros & its cons but here cons basically outlive the pros so yeah it is bad. Libraries like Compose Destinations can help you avoid some of the pain points but it is still generating the same code which you otherwise have to write manually. My suggestion would be to explore some other options (given below), there are some great developers who have made their custom solutions for navigation in compose & I think you should really check them out.

If you are coming from “navigation-compose” & want to switch over to a similar library, this would be most likely your first choice. The API is somewhat similar to that of “navigation-compose” (as the name suggests it’s “navigation-compose” but reimagined). The documentation is pretty good in my opinion.

Here you’ve NavComponentEntry instead of NavBackStackEntry , you’ve built-in typed navigation with typed arguments (parcelable & serializable are also supported). Here, NavController has a generic type <T> so it can inherit & will only allow navigation of type T. Destinations can be of any type but for the best use case, we should use enums (if you don’t want to pass arguments for eg: in a bottom navigation setup) or a sealed class where each constructor parameters of the class become the arguments for the destination.

Just like “navigation-compose” you’ve NavHost&AnimatedNavHost. Dialogs are also supported through a DialogNavHost & its implementation/usage differs a lot that of “navigation-compose”. I like this idea where dialogs are treated as separate navigation & not composed as a part of NavHosti.e one of its child destinations so you’ve separate NavController to manage them. The APIs provided by the NavController are much more flexible & explicit, you can easily get a snapshot of the backstack using NavController.backstack. Basically, it has all features of the “navigation-compose” library (excluding built-in support for deeplinks).

Here is a simple example of how to set up this library,

The only problem I found with this library is that it does not have built-in support for ViewModels injected using Hilt, however, this is not a big problem because luckily you can copy-paste this snippet into your source code & use the new hiltViewModel() function for creating ViewModels annotated with @HiltViewModel. For Dagger, you’ve to override your Activity’s or Fragment’s getDefaultViewModelProviderFactory() method to propagate your custom ViewModelFactory as currently NavComponentEntry does not provide a way to set custom ViewModelFactory.

The first multiplatform navigation library for Jetpack Compose currently supports Android & Desktop yet to have support for IOS for a true KMM.

The navigation system is stack-based somewhat similar to Fragments, there is no well defined typed navigation instead you define your destination by creating a class & extend it either byScreen or AndroidScreen , so any parameters to this class become the arguments for that destination & will be saved across configuration change & process death due to Screen being serializable. AndroidScreen is a special implementation of Screen which is Lifecycle, ViewModelStore & SavedStateRegisty aware, so it provides support for scoped ViewModels as well (for the multiplatform applications you would use ScreenModel instead of ViewModel).

There is a Navigator class for managing navigation hosted in aNavigator composable, I know its confusing with the names but a simple example with voyager looks like below,

Navigator class has various operations defined by its Stack API, there is great documentation written by the author.

Implementing animations/transitions are simple, just wrap the content with either FadeTransition , ScaleTransition , SlideTransition or you can create your own using the raw ScreenTransition API.

The library has support for deeplinks however its usage is quite different, basically, once you know the destination where you want to navigate (through intent.getExtras()) for eg: ThirdScreen, you’ll need to chain the previous screens (eg: FirstScreen,SecondScreen) to the Navigator(..)composable as well (one overload accepts List<Screen>), this way the last added item in the list will be the first one to be displayed & pop will actually go back to previous screens. One problem with this approach is that suppose you’ve to navigate to a deeply nested navigation with arguments, chances are you’ve to pass them through every Navigator(..) composable & also have to main separate logic for them.

Apart from this, the library has built-in support for bottom sheet, tab & bottom-bar navigation which is very easy to set up & very well illustrated in the docs.

One of the things I like about this library is that the documentation is really good covering most of its API usage neatly with proper linking to code samples. The library is ready to use in production & I think at this point some people already have. The issues listed on Github are more likely feature requests & bugs related to some very specific use cases. If you check the closed issues you’ll understand how much the library has matured before coming to its stable 1.0 release.

Another library for handling navigation is navigator-compose. The library also supports Fragment navigation through its parent library called navigator.

The navigation setup is similar to that of “compose-navigation-reimaged”. Just like that, it supports type-safe navigation through only sealed classes whose constructor parameters become the arguments for that destination. Here you’ve got a Controller<T> class which you would use to manage navigation of type T. It also provides a wide range of flexible APIs for managing the navigation backstack.

In this, each destination that you would create by extending Route interface has a LifecycleController . This controller is Lifecycle, ViewModelStore & SavedStateRegisty owner which means the library has support for scoped ViewModels & also supports ViewModel injected through Hilt using a separate add-on library. There is great documentation here that shows how Lifecycle Event change occurs when navigation backstack changes.

Apart from Controller<T> you’ve got a gigantic class called ComposeNavigator which has a global view of the navigation backstack which is why the navigation here is queue-based (in a map) i.e it knows about its parent & child navigation. ComposeNavigator class provides similar APIs to that of Controller<T> but any change could also affect the parent navigation, for eg: there is an API in ComposeNavigator class called goBackUntil which as the name suggests pops to the required destination (including the one from multiple hierarchies of parent navigations as well) & it does this by looking through the whole navigation queue.

Here is a simple example of how to set up this library,

Animations in this library are also pretty straightforward but the only thing is that they are not implemented using AnimatedContent API but are by manually interpolating between current -> target destination & modifying the graphics layer. This actually provides much more flexibility but could take away simplicity when defining your custom animations (although there is a PR to upgrade the animation system to AnimatedContent so if you are a user of the library you can convince the author to upgrade it). Currently, the library provides built-in Fade & Slide transitions.

As you can see the animations here are much more flexible i.e logic can be much more dynamic. Similar DSL is provided for popUpTo i.e popping to the required destination.

The library is still in alpha which means the API change could break the backward compatibility.

The library is an add-on to the simple-stack library. simple-stack is a backstack library & has been there for quite a long time, so you can say it is one of the most matured navigation frameworks among all of them. A lot of concepts are carried over from simple-stack library to its compose-integration. The library is an abstracted navigation framework, which means it does not directly depend on the implementation detail on what component is used for navigation but rather how navigation should proceed (state change), which is why the library has separate implementations for navigation of plain Views, Fragments & Composable functions. This is done through theStateChanger interface (ViewStateChanger for View, FragmentStateChangerfor Fragment & ComposeStateChanger for Compose).

Each destination here is a class extending from either DefaultViewKey (for View-based navigation), DefaultFragmentKey (for Fragment-based navigation) or DefaultComposeKey (for Composable-based navigation). For Compose, you can override a method called ScreenComposable where you would normally put your composable content. Similar to other navigation libraries we saw, the constructor parameters of this class become the arguments for that destination.

There is no ViewModel here but a concept called Scoped Services. The idea is you bind services (which is usually a class) to a destination with a tag scoped to that destination (very similar to scoped ViewModels) & use it using a lookup(...) function in your component (could be a Fragment or a Composable function). The services aka the bounded classes will live as long as the destination is in the backstack. So suppose you set up bottom-navigation in a destination A, you can then lookup(..) for a service scoped to A in any child destination of tab & suddenly you’ve automatically shared the given service with the children of bottom navigation. In the Navigation Component world, this is what you refer to as navigation graph scoped ViewModels (created using navGraphViewModel<T>()), the only difference is the implementation is platform agnostic. Apart from this, the scoped services can also implement interfaces like Bundleable to have support for save/retrieve state to/from a StateBundle (similar to SavedStateHandle).

A simple setup using this library would look like this,

Animation between navigation is done by modifying the graphics layer through interpolating between the current & target destination (similar to 3. navigator-compose). Unfortunately, the library does not provide any built-in transitions but the default transition here is Slide. You can customize the animation through an optional parameter of ComposeStateChanger constructor, where you can specify your custom implementation of AnimationConfiguration. You can customize the animation for different destinations using stateChange parameter (topPreviousKey() & topNewKey()).

Apart from the animation system, the library is excellent. Yes, it has some learning curve but sure it’s worth investing in. Like I said the library can become platform-agnostic & if so there is a high chance we can see its usage in a multiplatform project.

I know there are many navigation libraries & I’ve might only covered a few. The reason I picked these libraries is that I’ve seen them grow & also used them for doing POC to decide which one to use for my next personal projects, so I can say I’m familiar with most of the concepts each of them has to offer. Also, in the discussion above I’ve only covered a few of the top features that I like/dislike about the libraries, it is important that you check them out yourself & maybe get familiar with the internal workings of them.

Another major aspect is that each of these libraries is tested through unit testing, instrumentation testing, or both (on CI as well). This is very important as it tells how serious the maintainer/author is about the project.

If you ask for my opinion on which navigation library should you use for your next project that will be in pure Compose & no fragments then my suggestion would be to go with voyager (2nd). The reason being it is the only library that is used by some of the people I know in the community for a serious production app that has a very high user base (around ~5M+ downloads). If they’ve chosen this library then they must surely have done their research. Also, in my opinion, this is the only library I feel is close to stable covering a variety of use cases (soon 1.0 stable release).

Hope you like this discussion, if you’ve any doubts or concerns let me know through comments or Twitter 🙂




Source by [author_name]

Leave a Reply

Your email address will not be published.