Skip to content

Navigation

Albert Pinto edited this page Jul 16, 2021 · 2 revisions

As discussed previously, the application only contains a single activity, and all different screens are fragments. To simplify the navigation between them, it has been used the Navigation Component. This library simplifies navigation between fragment or activities in the application by automatically generating all the needed code to replace the fragments according to an XML file called the navigation graph. In this section, we will describe how it is used in the project. Before starting, however, if you do not know the fundamentals of this library, we recommend you to have a look at Navigation Component documentation

Navigation graphs

In the application, there are two navigation graphs:

  • nav_graph: It contains the interaction with top-level fragments, being MainFragment the only one in this case.
  • main_fragment_nav_graph: It contains the interactions with the fragments displayed in the BottomNavigationView.

Since the BottomNavigationView and the ToolBar are synchronized with the main_fragment_nav_graph, we are indeed navigating when we click on an icon with the navigation component. The top-level fragments in the graph are displayed in the bottom bar, and when navigating to other fragments, a back arrow is displayed in the ToolBar.

Navigating between fragments (Deprecated)

This section has been deprecated. Please refer to Navigating with NavManager

Instead of using the default methods for navigating between fragments in BaseFragment, we have defined the navigate() and navigateUp() method. On the one hand, the navigate() method navigates between two fragments given a navDirections.

val action = RecipesFragmentDirections
    .actionRecipesFragmentToRecipeDetail(state.recipe.name, state.recipe)
navigate(action)

On the other hand, the navigateUp() method navigates to the previous fragment in the back stack.

There are two differences between those methods and the approaches detailed in Navigation Component:

  • NavController: since there are two nav graphs, we need to specify which one we are shown the destination fragment. The navigate() method accepts as a parameter the NavController we have to use when navigating, being the findNavController() the default one. When navigating to the fragments from the BottomNavigationView, we should use the NavController from the inner NavHostFragment.
  • BaseViewModel: when navigating to another fragment, since the system might store it in the back stack, we need to remove the current state of the BaseViewModel by setting it to ScreenState.Nothing.

Navigating with NavManager

To navigate using the NavController, we needed to load a special state from the ViewModel to its Fragment, which will perform navigation. To test this functionality, we could verify that the state was loaded correctly. We will use the SearchViewModel to exemplify this situation, using the method that loads the state to display a recipe detail.

fun onShowRecipeDetail(recipe: Recipe) {
    loadState(SearchState.ShowRecipeDetail(recipe))
}

We have introduced the NavManager, which performs navigation between destinations from ViewModel. This class has to be injected in the constructor and is a singleton; thus, there will only be a single instance.

class SearchViewModel @Inject constructor(
    private val searchRecipes: SearchRecipes,
    private val navManager: NavManager,
    // ...
) : BaseViewModel() {
    // ...
}

Methods to navigate

The NavManager class contains two methods to navigate within the application.

abstract fun navigate(@IdRes navHostFragment: Int, action: NavDirections)
abstract fun navigateUp(@IdRes navHostFragment: Int)

On the one hand, the navigate() method performs navigation within the specified NavHostFragment. On the other hand, the navigateUp() perform navigation up in the nav graph.

We have developed a set of extension functions to NavManager to use these methods in the projects.

fun NavManager.navigate(action: NavDirections)
fun NavManager.navigateUp()
fun NavManager.navigateMainFragment(action: NavDirections)
fun NavManager.navigateUpMainFragment()

The first two methods will navigate using the application nav graph, whereas the others will use the nav graph from MainFragment.

NavDirections

To use NavManager, we need to obtain the destination to which we are navigating. Each ViewModel that needs to navigate between fragments should implement a navigation class composed of an interface and its implementation that will be stored within the navigation package of each feature. There will be a method for every navigation of the fragment associated with the ViewModel, which will return an instance of NavDirections. This class has to be injected into the constructor, and its provider must be specified in NavigationProviders.

interface SearchNavigation {
    fun navigateToRecipeDetail(recipe: Recipe): NavDirections
}
class SearchNavigationImpl : SearchNavigation {
    override fun navigateToRecipeDetail(recipe: Recipe) =
        SearchFragmentDirections.actionSearchFragmentToRecipeDetail(recipe.name, recipe)
}
@Module
@InstallIn(SingletonComponent::class)
class NavigationProviders {

    @Provides
    fun provideSearchNavigation(): SearchNavigation = SearchNavigationImpl()
}
class SearchViewModel @Inject constructor(
    private val searchRecipes: SearchRecipes,
    private val navManager: NavManager,
    private val searchNavigation: SearchNavigation,
    // ...
) : BaseViewModel() {
    // ...
}

Performing navigation

Finally, we might perform navigation to RecipeDetailFragment from SearchViewModel using NavManager and SearchNavigation.

fun onShowRecipeDetail(recipe: Recipe) {
    val action = searchNavigation.navigateToRecipeDetail(recipe)
    navManager.navigateMainFragment(action)
}

Testing navigation

To test the navigation, we need to create a mock for NavManager, navigation, and NavDirections.

@MockK
private lateinit var navManager: NavManager

@MockK
private lateinit var searchNavigation: SearchNavigation

@MockK
private lateinit var navDirections: NavDirections

@Before
fun setUp() {
    navManager = mockk()
    every { navManager.navigate(any(), any()) } returns Unit

    navDirections = mockk()
    searchNavigation = mockk()
    every { searchNavigation.navigateToRecipeDetail(any()) } returns navDirections

    // ...
}

After declaring the mocks, we might write a test about navigation.

@Test
fun `when loading recipe then we navigate to RecipeDetail`() {
    val recipe = recipes.first()
    viewModel.onShowRecipeDetail(recipe)

    verify {
        searchNavigation.navigateToRecipeDetail(recipe)
        navManager.navigate(any(), navDirections)
    }
}

Adding a bottom navigation

When adding a fragment to the BottomNavigationView, we have to add main_fragment_nav_graph.xml as a top-level destination. Then we need to add it into bottom_navigation_menu.xml, with a label and an icon. The id of the menu item has to be the same as the fragment it refers to.

Then we have to add it into the AppBarConfiguration, located in MainFragment.

val appBarConfiguration = AppBarConfiguration(
    topLevelDestinationIds = setOf(
        R.id.recipesFragment,
        R.id.searchFragment,
        R.id.favoriteFragment,
        R.id.settingsFragment
    )
)

If you want to add a custom name to the top bar, then you have to pass it as an argument to the fragment and refer it into the main_fragment_nav_graph.xml.

<fragment
    android:id="@+id/recipeDetail"
    android:name="org.easyrecipe.features.recipedetail.RecipeDetailFragment"
    android:label="{recipeName}"
    tools:layout="@layout/fragment_recipe_detail">
    <argument
        android:name="recipeName"
        app:argType="string" />
    <argument
        android:name="recipe"
        app:argType="org.easyrecipe.model.Recipe" />
    <action
        android:id="@+id/action_recipeDetail_to_createRecipeFragment"
        app:destination="@id/createRecipeFragment" />
</fragment>

Clone this wiki locally