Using Navigation Architecture Component in a large banking app
Navigation library from Jetpack has recently reached RC1 and all Android developers should start considering it for new apps. I’m responsible for app architecture of Air Bank Germany — a new mobile-first German bank. Our app has a multi-module, single-activity architecture with ViewModels from Architecture Components. Integrating Navigation Component was a logical step, but it wasn’t without a few gotchas. In this blogpost, I want to share how we solved them. It’s a more advanced post, so I assume the readers’ knowledge of the official documentation.
Where to put the navigation XML file in a multi-module project?
Multi-module projects is a recommended way how to structure new apps. Advantages are faster build times and a better separation of concerns in the codebase. This is a simplified diagram of our Gradle modules:
There are a couple of options where to put the navigation XML file:
The ‘app’ module
The official documentation and Android Studio assumes this. But you need to be able to navigate from feature to feature (and features don’t have dependency on ‘app’ module). This could be solved by putting all the ids of nav destinations into ids.xml file inside the ‘common-android’ module. Then the navigation works, but you can’t use Safe Args. You need to manually construct Bundles for passing arguments to destinations.
Main graph in ‘app’ module, subgraphs in feature modules
It’s similar to having a large graph in the ‘app’ module, but more structured. You can use Safe Args within a feature, but not when navigating to a different feature. A workaround with the ids in ‘common-android’ module can be used as well.
The ‘common-android’ module
All features have dependency to ‘common-android’, so you can use Safe Args for navigating anywhere. However, it has two drawbacks:
- Since ‘common-android’ module doesn’t have dependency on features, Fragment classes are red in Android Studio. You don’t have auto-complete for Fragment classes, but it compiles without any problems.
- There is a bug which prevents generating intent filters for deep links. It’s already assigned, so hopefully it will be fixed in the final release.
Those two issues are not a blocker for us and using Safe Args anywhere is really a great advantage. So, our preferred option is having the file in ‘common-android’ module.
How to navigate from ViewModels?
Our app uses the recommended MVVM architecture from Android Architecture Components. The documentation shows how to start navigation from Fragments, but this logic should be in ViewModels. We try to put all boilerplate code to BaseFragment and BaseViewModel to simplify code of all other Fragments/ViewModels.
Commands
We use the command pattern to communicate between ViewModel and Fragment. These are the commands that we use for navigation:
sealed class NavigationCommand {
data class To(val directions: NavDirections): NavigationCommand()
object Back: NavigationCommand()
data class BackTo(val destinationId: Int): NavigationCommand()
object ToRoot: NavigationCommand()
}
ViewModel posts them into a LiveData object which Fragments listen to. But it needs to be a one-time only event. For that, we use the SingleLiveEvent from architecture blueprints.
BaseFragment
BaseFragment listens to navigation commands from a ViewModel like this:
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
vm?.navigationCommands?.observe { command ->
when (command) {
is NavigationCommand.To ->
findNavController().navigate(command.directions)
…
ViewModel
BaseViewModel has these helper methods:
fun navigate(directions: NavDirections) {
navigationCommands.postValue(NavigationCommand.To(directions))
}
And then navigating from a ViewModel is really simple like this:
navigate(CardListFragmentDirections.cardDetail(cardId))
How to use arguments in ViewModels?
ViewModels typically need navigation arguments to load some data. Our BaseFragment automatically passes arguments to a ViewModel like this:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
vm?.setArguments(arguments)
vm?.loadData()
}
}
Then the ViewModel can use arguments easily like this:
override fun loadData() {
val cardId = CardDetailFragmentArgs.fromBundle(args).cardId
load(cardsRepository.getCard(cardId)) {
…
How to show a login screen with deep links?
The official documentation doesn’t describe conditional navigation with enough detail. It basically suggests to handle conditional navigation at the final destination. For example, going to the profile screen, then to the login screen, and pop it when the user logs in. We don’t prefer this approach for the following reasons:
- The login screen should behave like another root destination. When the user presses the back button on the login screen, the app should close. The user should not go to the previous screen — because the previous screen requires a login, so the user would end up in an infinite loop.
- Especially with deep links, the target Fragment should not be created at all when the user is not logged in. Creating the Fragment takes some resources, it starts making some network calls which fail because the user is not logged in, etc. It’s better if the login is showed before all this happens.
We experimented with replacing the navigation graph in a NavHostFragment and creating a different root for the login screen. But that didn’t work after configuration changes. At the end, we decided to use a different Activity for the login. Our app is not strictly single-activity anymore, but the login is a separate flow and it makes sense for this case. The login can have its own navigation graph and it works as we want — as another navigation root.
The app is launched directly or via deep links through MainActivity. We can prevent loading any Fragments by finishing the MainActivity as soon as possible and showing LoginActivity:
override fun onStart() {
super.onStart()
if (sessionRepository.isLoggedOut()) {
startActivity<LoginActivity>()
finish()
}
}
But what about deep links? Fortunately, we can pass all intent arguments to LoginActivity:
val intent = Intent(this, LoginActivity::class.java)
intent.putExtra("deepLinkExtras", this.intent.extras)
startActivity(intent)
finish()
And when the user logs in, we can pass arguments back to MainActivity:
val intent = Intent(this, MainActivity::class.java)
intent.putExtras(this.intent.getBundleExtra("deepLinkExtras"))
startActivity(intent)
finish()
This way, we always show the login when needed, deep links work in both cases (user is logged out or logged in) and the back button works as expected.
We had one issue with this approach: After logging in from a deep link, the root destination wasn’t added to the backstack in the MainActivity. We solved it by changing launchMode of all Activities to “singleTask”.
How to navigate to dialogs?
Our design has a lot of bottom sheet dialogs. They contain some multi-step flows like activating a card. We need to pass arguments to the dialogs so it’s convenient to keep them in the navigation graph as other Fragments. There is a feature request for official support, but it doesn’t have much priority. Fortunately, the library is pretty extensible and other navigation destination types are possible. We have used this gist for implementing DialogNavigator.
Then you can specify dialog fragments like this:
<dialog
android:id="@+id/nav_close_account"
android:name="de.innoble.abx.closeacc.AccountCloseConfirmDialog"
<argument
android:name="iban"
app:argType="string" />
</dialog>
Safe args and everything else work the same as with regular Fragments.
However, there is one big difference — dialogs are not added to the back stack. When there are multiple dialogs and the back button is pressed, the user expects to see the fragment underneath, not the previous dialog. It has an annoying side effect: when navigating from a dialog, you need to use FragmentDirections of the Fragment underneath, not the dialog. It’s a little counter-intuitive, but it’s quickly discovered during development (the app crashes with exception “navigation destination is unknown to this NavController”).
How to navigate back with a result?
Something similar to startActivityForResult()
would be really handy. Google recommends using a Shared ViewModel for this, but the API is not intuitive. There is a feature request about this, but it has a low priority. We are using our own solution until the official support arrives. First define this interface:
interface NavigationResult {
fun onNavigationResult(result: Bundle)
}
Implement this interface in the Fragment in which you want to receive the result. Sending the result from another Fragment has to be routed through an Activity. Add this method to your Activity:
Note that this solution works only with Fragment navigation destinations.
TLDR;
- In a multi-module project, put navigation XML into ‘common-android’ module.
- Navigate from ViewModels via commands which are SingleLiveEvents. Put all boilerplate into BaseFragment/BaseViewModel.
- Use Safe Args and pass Fragment arguments automatically to ViewModel.
- Use a different Activity for login screens. Pass Intent extras back and forth to support deep links.
- Include dialogs in a nav graph with the help of this gist. Beware that dialogs are not added to the back stack.
- Use our solution for navigating back with a result until the official support arrives.