Android Jetpack Fragments & Navigation (HAT 2)

Fragments in Android have had a weird history. When I started Android developing in 2015, the way to add a fragment was to use the FragmentManager and make a transaction

getSupportFragmentManager()
        .beginTransaction()
        .add(R.id.main_container, new HomeFragment())
        .commit();

And you’d have to load it into a container which would usually be a FrameLayout, or if you were being fancy and wanted a tabbed layout or swipeable fragments, you’d use a view pager.

<fragment
    android:id="@+id/home_fragment"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:name="com.ericdecanini.shopshopshoppinglist.mvvm.fragment.home.HomeFragment"
    />

You could also use the <fragment> xml object, but this didn’t offer very much functionality, and most Android Developers still chose to use FrameLayouts to host their fragments.

Right now things are… pretty different. With the rise of Android Jetpack redefining many of our fundamental Android practices, we got a new way to add fragments, and more ways to navigate around them.

What I want to do today is go over the new classes that Jetpack gave us for placing fragments in our activities, new ways to navigate around them, and some challenges that I personally faced with using them in an MVVM architecture.

Fragments

Let’s start with the new FragmentContainerView. This offers so much.

<androidx.fragment.app.FragmentContainerView
    android:id="@+id/fragment_container_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:name="androidx.navigation.fragment.NavHostFragment"
    app:defaultNavHost="true"
    app:navGraph="@navigation/nav_graph" />

First of all, this offers the functionality of both FrameLayout and ye olde XML fragment at the same time, and then some. This was also created to fix a bug where exit and enter transitions when replacing fragments would previously overlap each other. Instead, FragmentContainerView would start the exit animation, and then the enter animation.

And by using this XML code, you literally don’t have to add any Kotlin code to set up the Fragment.

class MainActivity : DaggerAppCompatActivity() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
  }
  
}

This is my Kotlin code that’s hosting the fragment, and this is fully functional. Navigation and everything! And it’s no different from the generated MainActivity class…… Except that it extends DaggerAppCompatActivity, but hey, if you haven’t seen the Dagger MVVM episode, watch it now.

So that’s cool, but is that all that’s different about Fragments in this new Jetpack era?

Navigation

Well, for the fragments themselves, pretty much, but Jetpack also gave us some new functionality in the form of navigation.

These are provided with the following dependencies.

// Navigation
def navVersion = "2.3.2"
implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
implementation "androidx.navigation:navigation-ui-ktx:$navVersion"

Look back at the XML code of the FragmentContainerView. DefaultNavHost, and NavGraph. Both are attributes provided by th e AndroidX Navigation package applicable to fragments.

<fragment
    android:id="@+id/fragment_container_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:name="androidx.navigation.fragment.NavHostFragment"
    app:defaultNavHost="true"
    app:navGraph="@navigation/nav_graph" />

So technically you could do this as well with ye olde XML fragment but don’t do this >.<. There is literally no point.

And I said earlier that with no extra code in MainActivity, even navigation with fragment transactions is handled, and that’s because of these things.

<?xml version="1.0" encoding="utf-8"?>
<navigation
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/nav_graph"
    app:startDestination="@id/homeFragment">

    <fragment
        android:id="@+id/homeFragment"
        android:name="com.ericdecanini.shopshopshoppinglist.mvvm.fragment.home.HomeFragment"
        android:label="HomeFragment" >
        <action
            android:id="@+id/action_homeFragment_to_listFragment"
            app:destination="@id/listFragment" />
    </fragment>

    <fragment
        android:id="@+id/listFragment"
        android:name="com.ericdecanini.shopshopshoppinglist.mvvm.fragment.list.ListFragment"
        android:label="ListFragment" />

</navigation>

It all revolves around an XML navGraph that defines the flow of navigation for the fragment that hosts it. The startDestination is set up here, so as soon as the layout is loaded, homeFragment is immediately loaded into view.

Then how do you navigate to other fragments in the graph?

// In an Activity
findNavController(R.id.fragment_container_view).navigate(R.id.listFragment)

// In a Fragment
findNavController().navigate(R.id.listFragment)

NavController. This thing right here, it handles all your problems. You pass in the id of the fragment you want to navigate to, and if it’s found within the navGraph, it navigates to it. If it’s not, then I think that’s an IllegalArgumentException.

public fun navigate(...) {
    ...

  NavDestination node = findDestination(destId);
  if (node == null) {
      final String dest = NavDestination.getDisplayName(mContext, destId);
      if (navAction != null) {
          throw new IllegalArgumentException("Navigation destination " + dest
                  + " referenced from action "
                  + NavDestination.getDisplayName(mContext, resId)
                  + " cannot be found from the current destination " + currentNode);
      } else {
          throw new IllegalArgumentException("Navigation action/destination " + dest
                  + " cannot be found from the current destination " + currentNode);
      }
  }
}

And if you want to add arguments to the navigation, you just have to make a bundle and pass it in.

val args = Bundle()
args.putInt(ListFragment.KEY_LIST_ID, shoppingList.id)
navController.navigate(R.id.listFragment, args)

I remember back in the day when passing arguments around fragments was a massive pain because it just never seemed to work but this works pretty nice-uu.

So with the rise of Android Jetpack, both fragments and navigation got a shift into a more XML based approach so that we can keep our Kotlin code concerned only about the actual functionality of our app.

Challenges with MVVM

In true MVVM fashion, you don’t want to be handling navigation logic within the view, that being the activity or fragment. You want to handle it in the View Model, or in a separate class that the View Model controls.

class MainNavigatorImpl(
    private val originActivity: AppCompatActivity
) : MainNavigator {

    private val navController: NavController
        get() = originActivity.findNavController(R.id.fragment_container_view)

    override fun goToList() = navController.navigate(R.id.listFragment)

    override fun goToList(shoppingList: ShoppingList) {
        val args = Bundle()
        args.putInt(ListFragment.KEY_LIST_ID, shoppingList.id)
        navController.navigate(R.id.listFragment, args)
    }

}

Let me tell you what I use. A set of classes I call Navigators. For apps that have activities with multiple fragments, I would have one of these for each activity. It takes in an activity as a constructor parameter, because this is something we can do with our current Dagger set up. If you want to see how that was done, watch…. NOW.

This allows us to get the activity’s NavController by also referencing the id of the FragmentContainerView, and that will handle the rest of our navigation based on what was set in the NavGraph.

class HomeViewModel @Inject constructor(
    private val mainNavigator: MainNavigator
): ViewModel() {
  
  val onShoppingListClick: (ShoppingList) -> Unit = { shoppingList ->
        mainNavigator.goToList(shoppingList)
  }
  
  fun navigateToListFragment() = mainNavigator.goToList()
  
}

Then we can inject the Navigator class in our ViewModel and use it however we want to. And if we want to have navigation as an action to follow a click event, we can also do that within the ViewModel as a higher order function, like I’ve done above with onShoppingListClick.

class HomeFragment : BaseFragment<HomeViewModel>() {
  
  private fun initClicks() {
        binding.fab.setOnClickListener { viewModel.navigateToListFragment() }
  }
  
}

class ShoppingListViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {

    fun bind(
        shoppingList: ShoppingList,
        onShoppingListClick: (ShoppingList) -> Unit
    ) {
                ...
        itemView.setOnClickListener { onShoppingListClick(shoppingList) }
    }
}

So now, within our view, whether that be our Fragment, or inside RecyclerViews, we can respond to click events by delegating all navigation logic to the ViewModel. Our MVVM goals have been satisfied.

Unit Testing

How then, shall we test our Navigator implementation? Because most of our navigation work is being done by navController, an Android component provided by the activity, another Android component. And that navController is what we need to verify in our tests, so how are we gonna do that? Well, let me tell you this, I am NOT using Robolectric.

class MainNavigatorImplTest {

    private val activity: AppCompatActivity = mock()
    private val mainNavigator = MainNavigatorImpl(activity)

    private val navController: NavController = mock()

    @Before
    fun setUp() {
        given(activity.findNavController(any())).willReturn(navController)
    }

Even if we mock the navController. Even if we mock the activity, and even if we mock the mocked activity’s findNavController method to return the mocked navController, it won’t work.

java.lang.IllegalArgumentException: ID does not reference a View inside this Activity

You’ll get errors like this one which tie back to the Android system. Of course we will, we’re running this test in the JVM, not an Android environment.

So what’s the solution to this? Mockito isn’t gonna be enough. We need the help of another mocking library. One that is called…

Mockk

Although Mockito is very powerful, it’s situations like these where it can fall short. But since we are in a Kotlin era, we can take advantage of MockK which is a mocking library designed especially for Kotlin.

private val activity: AppCompatActivity = mockk()

every { activity.applicationContext } returns context

And for the most part, MockK can do pretty much the same things as Mockito can, with slightly different syntax… and a bit more.

@Before
fun setUp() {
    mockkStatic(Navigation::class)
    every { Navigation.findNavController(any(), any()) } returns navController
}

MockkStatic. This, it’s truly something amazing. Calling mockkStatic allows you to mock static methods and make them return other objects or mocks you created. This is essential for us to be able to test navigation.

@NonNull
public static NavController findNavController(@NonNull Activity activity, @IdRes int viewId) {
    View view = ActivityCompat.requireViewById(activity, viewId);
    NavController navController = findViewNavController(view);
    if (navController == null) {
        throw new IllegalStateException("Activity " + activity
                + " does not have a NavController set on " + viewId);
    }
    return navController;
}

The activity finds its navController by calling this static method, Navigation.findNavController. Even if we mocked the activity, it still calls this real static method of Navigation, and that would throw an exception because it can’t find a navController, so we needed a way to mock it which Mockk provides.

Now the other solution to this was to use Powermock because that library can also mock static methods, however I found that Mockk is so much easier to use, and it being made for Kotlin works amazingly for apps that are in Kotlin.

private val navController: NavController = mockk(relaxed = true)

With Mockk, it’s important to mock navController setting relaxed to true, as this will remove any behaviour the actual class would have. This means we can verify in isolation how our code makes use of this navController and what arguments it passes into it, without the actual behaviour of the navController getting in our way.

Better Passing Arguments (Safe Args)

<fragment
    android:id="@+id/listFragment"
    android:name="com.ericdecanini.shopshopshoppinglist.mvvm.fragment.list.ListFragment"
    android:label="ListFragment" >

    <argument
        android:name="shoppingListId"
        app:argType="integer"
        android:defaultValue="-1" />

</fragment>

Earlier we passed arguments as a bundle, but you can explicitly set arguments a fragment should expect. This can support the types Integer, Float, Long, Boolean, String, Resource Reference, Parcelables, Serializables, and Enums. Some of these can also support null and default values.

// Top/App-level build.gradle
plugins {
  ...
  id 'androidx.navigation.safeargs'
}

// Top-level build.gradle
dependencies {
    classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navVersion"
}

Although this only really becomes useful if you’re using Safe Args, a Gradle plugin which you can enable by adding the above to your app-level build.gradle file.

override fun goToList(shoppingList: ShoppingList) {
    val action = HomeFragmentDirections.actionHomeFragmentToListFragment(shoppingList.id)
    navController.navigate(action)
}

You can navigate without using a bundle in a way that is more type safe, by using classes that the SafeArgs plugin generates with Gradle.

private val args: ListFragmentArgs by navArgs()

override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
): View {
    val id = args.shoppingListId

Then in the destination, you can use the by navArgs() delegate to get your arguments which has your explicit arguments.

Do take note that the classes can change based on your nav_graph XML.

<argument
    android:name="shoppingListId"
    app:argType="integer"
    android:defaultValue="-1" />

For instance, if you set a default value on the argument

override fun goToList(shoppingList: ShoppingList) {
    val action = HomeFragmentDirections.actionHomeFragmentToListFragment()
    action.shoppingListId = shoppingList.id
    navController.navigate(action)
}

Then the argument becomes not a parameter of the action, but a setter method instead.

What does this mean for testing? It makes navigation so much more testable!

@Test
fun givenNoShoppingList_whenGoToList_thenNavigateToListFragmentWithNoShoppingListId() {
    mainNavigator.goToList()

    val slot = slot<HomeFragmentDirections.ActionHomeFragmentToListFragment>()
    verify { navController.navigate(capture(slot)) }
    assertThrows<NullPointerException> { slot.captured.shoppingListId }
}

Android Intents were hard to test without Robolectric because the Intent class itself is an Android component, so you can’t test things like what extras you put in an intent because doing this within the JVM test environment doesn’t actually perform the acti ons that should be performed by the Intent class and it only produces null results.

But the combination of AndroidX Navigation and SafeArgs means that you have these pure Kotlin classes which are generated by Gradle and have no dependency on the Android system.

In short, you can use an argument captor or slot in the case of Mockk and actually verify the arguments passed during navigation.

More on Nav Graph

What we’ve done above is a rather simple implementation as far as the navigation itself goes, but there’s plenty more to what you can do in a nav graph.

So I’m gonna go through all of them, but in quick succession so you know that these are things that can be done, but do keep in mind that if you want to know about them in more detail, I recommend you go check the official documentation. Now, here we go.

<!-- App Module Navigation Graph -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
           xmlns:app="http://schemas.android.com/apk/res-auto"
           xmlns:tools="http://schemas.android.com/tools"
           app:startDestination="@id/match">

   <fragment android:id="@+id/match"
           android:name="com.example.android.navigationsample.Match"
           android:label="fragment_match">

       <!-- Launch into In Game Modules Navigation Graph -->
       <action android:id="@+id/action_match_to_in_game_nav_graph"
           app:destination="@id/in_game_nav_graph" />
   </fragment>

   <include app:graph="@navigation/in_game_navigation" />

</navigation>

If your app depends on library modules that have their own navigation graphs and you want to add it in your activity, you can use the <include> tag to include it.

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   android:id="@+id/in_game_nav_graph"
   app:startDestination="@id/in_game">

   <!-- Action back to destination which launched into this in_game_nav_graph-->
   <action android:id="@+id/action_pop_out_of_game"
                       app:popUpTo="@id/in_game_nav_graph"
                       app:popUpToInclusive="true" />
  
  ...
  
</navigation>

You don’t need to have actions inside <fragment> tags. If a destination in your app can be reached through more than one path, you can add the action directly under navigation, and outside of any fragment tags. These are called Global Actions.

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:android="http://schemas.android.com/apk/res/android"
    app:startDestination="@id/mainFragment">
    <fragment
        android:id="@+id/mainFragment"
        android:name="com.example.cashdog.cashdog.MainFragment"
        android:label="fragment_main"
        tools:layout="@layout/fragment_main" >
        <action
            android:id="@+id/action_mainFragment_to_sendMoneyGraph"
            app:destination="@id/sendMoneyGraph" />
        <action
            android:id="@+id/action_mainFragment_to_viewBalanceFragment"
            app:destination="@id/viewBalanceFragment" />
    </fragment>
    <fragment
        android:id="@+id/viewBalanceFragment"
        android:name="com.example.cashdog.cashdog.ViewBalanceFragment"
        android:label="fragment_view_balance"
        tools:layout="@layout/fragment_view_balance" />
    <navigation android:id="@+id/sendMoneyGraph" app:startDestination="@id/chooseRecipient">
        <fragment
            android:id="@+id/chooseRecipient"
            android:name="com.example.cashdog.cashdog.ChooseRecipient"
            android:label="fragment_choose_recipient"
            tools:layout="@layout/fragment_choose_recipient">
            <action
                android:id="@+id/action_chooseRecipient_to_chooseAmountFragment"
                app:destination="@id/chooseAmountFragment" />
        </fragment>
        <fragment
            android:id="@+id/chooseAmountFragment"
            android:name="com.example.cashdog.cashdog.ChooseAmountFragment"
            android:label="fragment_choose_amount"
            tools:layout="@layout/fragment_choose_amount" />
    </navigation>
</navigation>

You can nest navigation graphs within navigation graphs. Just set the destination of an action to the id of the nested navigation graph, or use the earlier mentioned <include> tag.

Conclusion (and Source Code)

And that’s everything I can gather about Fragments and Navigation in the Jetpack era.

Most of the code snippets you’ve seen were taken from Shopshop Shopping List, a project of mine I’m using to make all the episodes of HAT. Check it out on Github here.

This is episode two of HAT, you can check out the first episode here. There’s one more episode coming out in a couple weeks which is gonna be all about ViewStates and Testing single source of truth within MVVM.

And as always, happy coding ༼ つ ◕_◕ ༽つ

 

Subscribe to the Newsletter