Android Jetpack Navigation From iOS Developer Perspective
From an iOS developer perspective, how screens are presented in an Android is quite strange. The first time I developed an Android app, I was taught to create an Activity per screenful of content. Then, they have introduced me to Fragments, at the same time warning me about their cumbersome behavior and the lack of a proper constructor.
I was so confused and, searching the web for a simple alternative to navigate from a list to a detail screen, I encountered Conductor project. Basically, they embrace a single Activity structure of the project and they introduce Controller objects able to present Views. It sounds a lot like UIViewController
, isn’t it?
More recently, I googled again, and I discovered Android team has introduced (many months ago) a Jetpack component, called Navigation. At a first glance it’s very similar to Apple’s storyboards and I would like to try it from an iOS developer perspective.
Installation
I create a new project with Android Studio (version higher that 3.3), selecting Empty Activity template, with Kotlin, AndroidX support, and API 19 as minimum deployment target.
To include Navigation support in your project, add the following dependencies to your app’s build.gradle
file (check latest available version on Jetpack page):
Add also this option to android
group:
To enable SafeArgs, you need to add dependency to your project’s build.gradle
file:
At this point your project should launch properly on emulator with a white Hello World screen.
Push and Pop
Let’s push our first screen on navigation stack. I know, push is not the proper verb in Material Design world, but I am trying to translate from my iOS mindset.
First of all, I’ll create a MasterFragment
and a DetailFragment
using Android Studio wizard. I choose a blank fragment, with XML layout, but without any method or interface.
Then, I add a navigation graph to project: right-click on the res
directory and select New > Android Resource File. Resource type must be Navigation. I have chosen to call it main
, because it reminds me about Main.storyboard file.
Click on Add Destination button and add our two destinations.
Select masterFragment
and drag from it, to detailFragment
to create an action (a segue) from these two destinations.
Last step is to tell where this graph should be displayed. Open activity_main.xml
layout file, and insert a NavHostFragment
. Its XML will be like this:
Please note app:defaultNavHost="true"
attribute ensures that NavHostFragment
intercepts the system Back button. Only one NavHost
can be the default.
Navigating to a destination is done using a NavController
, an object that manages app navigation within a NavHost
. Each NavHost
has its own corresponding NavController
. You can retrieve a NavController
by using one of the following methods:
So, it’s trivial to push detail fragment responding to a button tap:
Now, what about navigation bar? I know, it doesn’t exist in Android. But I want top bar to behave like that! First of all, we disable global action bar by changing adding these attributes to theme in res/values/styles.xml
.
Then, add a Toolbar
inside activity_main.xml
.
This is much more flexible than iOS counterpart, because you could place bar wherever you like. You could also insert toolbars inside fragment, if you don’t need a fixed navigation bar on top. In MainActivity
you need to configure navigation controller to hook to this toolbar:
You can animate transitions by selecting action in navigation graph, and picking you animation type from right sidebar. You can even design custom transitions.
Passing Parameters
When you develop for Android, you have to forget the pass-the-baton approach you would use on other platforms. Fragment initializers are a nightmare — an we are avoiding them completely by using Jetpack Navigation, thumbs up — and activity are recreated from scratch often, even when user rotates its device.
Navigation controller navigate()
method can be called with a Directions
object. SafeArgs package is able to sythentesize direction objects from navigation graph. If you select a destination in your graph, you can add an argument from editor right sidebar.
Then, if you select the action, you can set default values for each argument. If an argument type supports null values, you can declare a default value of @null
.
You can pass and receive almost every primitive type that can be serialized inside a bundle. In general, you should strongly prefer passing only the minimal amount of data between destinations. For example, you should pass a key to retrieve an object rather than passing the object itself, as the total space for all saved states is limited: your app will crash if available space finishes.
A class is created for each destination where an action originates. The name of this class is the name of the originating destination, appended with the word Directions. Click on Build > Rebuild Project, if necessary to see the autogenerated class. This class has a method for each action defined in the originating destination.
A class is created for the receiving destination. The name of this class is the name of the destination, appended with the word Args. You can use Kotlin delegation to grab arguments:
Then, you will use them in your code:
This technique could be used to get custom titles for screens. Insert {title}
as label for destination. Then, you can use it as a parameter in direction class
Passing Back Results
Because we cannot keep references of fragments, we cannot even think about delegation or to set callback to communicate results from detail to master.
First option you have is to pass back an elementary value inside the backstack entry. You should not abuse this feature, because space is limited — but it could be handy. In master fragment you start observing a key:
Detail fragment can update result value when it desires:
Suggested option is to create a shared ViewModel
. I’m not too excited about this architecture, because it smells like glorified global state to me. Not to mention the waste of memory in a deep navigation tree. But, if you structure you data properly, this pattern could be quite powerful.
First of all let’s create a ViewModel
. I will adopt the more recent variant, the one with SavedStateHandle
in order to support process death, not only configuration changes (like device rotation). Because you are writing inside this handle, you still need to be aware of bundle size when you pass around state. If you don’t save to bundle, say for volatile data, you have no limit except RAM size. I want to expose only immutable LiveData
and to provide methods to update values: this will help sooner than you think.
For simplicity, let’s create view model in our single activity scope:
Then master fragment could subscribe to view model in order to update an example text view:
Also detail fragment should be completely driven by view model observation, even for first EditText
field content:
Whenever text field is edited, view model has to remain the single source of truth.
Since master fragment is observing a shared view model instance, label value is automatically correct as soon as we come back.
Modal flow
Even if modal presentation of screens is not a Material Design concept, we can simulate a modal flow with nested navigation graphs.
Say we have a wizard: it could be a checkout flow, a login flow. Suppose we want to collect name and surname of a user. I create two fragments in main navigation graph.
Then, I group them in a nested graph using the little button on Android Studio editor: I’ll call this nested graph wizard.
If you double click this nested graph, you will see inside, and you can connect destinations with an action.
More interesting is the action you can create to bring the wizard on screen: I want a global action, because I would like to display wizard both from master and detail fragments.
I don’t want to enter too much inside specific code implementation, I only want to cover main aspects. First of all, wizard needs a view model. It will have name
and surname
live data fields, with associated methods to mutate contents. This view model will be shared inside sub-navigation graph to cover all wizard steps. Once navigation inside nested graph will be finished, view model will be disposed. This is good not to waste resources.
Wizard results will be delivered by activity shared view model, like before.
So, coming back to navigation, you present this dedicated flow by firing global action:
First wizard step can navigate to second step in the classic way. More interesting is the final step of navigation: back stack is popped until the head of the nested graph, including last screen.
Tab Bar
Tab bar in Material Design is called bottom navigation. First of all, you create a menu that will be used to populate bar of tabs. You can specify secondary menu category for item, if you want to navigate back in the reverse order in which you have clicked the icons.
You specify items of menu, then you insert a BottomNavigationView
in activity layout, and associate it with menu_bottom_nav
.
Then, you insert destinations inside navigation graph. It’s key to use same identifiers both for destinations and menu items.
When you configure top bar, you need to specify destinations reachable via global navigation UI, not to show back button icon. Note start destination is always considered a top-level destination.
If you execute app, you will see navigation is automatically handled horizontally between tabs:
If you navigate to detail, you switch to another tab, and you return to the first one, you will notice navigation stack state is lost. If you read to Material Design guidelines, it seems it is a desired behaviour:
When you select a bottom navigation item (one that’s not currently selected), […] the app navigates to a destination’s top-level screen. Any prior user interactions and temporary screen states are reset, such as scroll position, tab selection, and in-line search.
If you want to circumvent this silly behavior, you just need to use a different NavHostFragment
. This way, each tab would have a different NavigationController
, a separated navigation graph and a different toolbar to show. Outer navigation controller would be in charge only to orchestrate tab changes. Anyway, I don’t want to implement this, because it’s a bit off this article intent.
Side menu
Discouraged by Apple, hamburger menu is widely embraced by Material Design.
First of all, you need to change activity layout, in order to have a root DrawerLayout
. Remember this layout expects to have two children: first one is content; second one is a NavigationView
. I’ll use same menu resource created for bottom navigation:
Now we just need to hook drawer to navigation controller:
You launch app, et voilà: drawer displays if you tap hamburger menu, items in drawer drives you to correct destination, and hamburger menu mutates to a back arrow if you push a destination on stack.
Having both tab bar and hamburger menu at the same time, it’s kind of dumb. It would be smart to display bottom navigation bar only in portrait orientation. It’s possible by moving things a little bit around. First of all, I isolate top bar and navigation host in a separate layout file. Then, I create a separate layout for activity in landscape orientation.
In normal activity_main.xml
I have left an <include>
to content, plus the BottomNavigationView
, everything contained inside a root ConstraintLayout
. In landscape variant, I’ve kept the root DrawerLayout
. Activity code now produces two warnings, since bottomNavigationView
and drawerNavigationView
are now optional. This is easy fixed using ?
syntax.
Alerts
To show alert popups, you use DialogFragment
destinations. It’s just like another push:
To come back, user can tap on shadowed space or on a custom OK button:
This presentation is much more similar to iOS modal view controller: nothing stands on your way if you would like to implement a wizard inside a dialog.
Bottom sheets
To show bottom sheets, you need to create a subclass of BottomSheetDialogFragment
. You reference it in navigation graph as a normal destination. Every piece of code is identical to alert case.
Source code
You can see Android Studio test project checking out this Git repo.