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" />
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
<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" />
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.
Update: I have a new article on Hilt MVVM. Read that instead of the Dagger MVVM episode.
So that’s cool, but is that all that’s different about Fragments in this new Jetpack era?
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 the classic <fragment>
but don’t do this. FragmentContainerView
gives you the functionality of fragment
and more.
<?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>
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)
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) } }
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) } } }
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
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.
private val navController: NavController = mockk(relaxed = true)
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
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.
@Test fun givenNoShoppingList_whenGoToList_thenNavigateToListFragmentWithNoShoppingListId() { mainNavigator.goToList() val slot = slot<HomeFragmentDirections.ActionHomeFragmentToListFragment>() verify { navController.navigate(capture(slot)) } assertThrows<NullPointerException> { slot.captured.shoppingListId } }
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
<!-- 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>
<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>
<?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>
<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
This is episode two of HAT, you can check out the first episode
And as always, happy coding ༼ つ ◕_◕ ༽つ