深入浅出 NavigationUI | MAD Skills

1,992 阅读7分钟

这是第二个关于导航 (Navigation) 的 MAD Skills 系列,如果您想回顾过去发布的内容,请参考下面链接查看:

今天为大家发布本系列文章中的第一篇。在本文中,我们将为大家讲解另外一个用例,即类似操作栏 (Action Bar)、底部标签栏或者抽屉型导航栏之类的 UI 组件如何在应用中实现导航功能。如果您更倾向于观看视频而非阅读文章,请查看 视频 内容。

概述

在之前的 导航系列文章中,Chet 开发了一个用于 跟踪甜甜圈的应用。知道什么是甜甜圈的最佳搭档吗?(难道是另一个甜甜圈?) 当然是咖啡!所以我准备增加一个追踪咖啡的功能。我需要在应用中增加一些页面,所以有必要使用抽屉式导航栏或者底部标签栏来辅助用户导航。但是我们该如何使用这些 UI 组件来集成导航功能呢?通过点击监听器手动触发导航动作吗?

不需要!无需任何监听器。NavigationUI 类通过匹配目标页面 id 与菜单 id 实现不同页面之间的导航功能。让我们深入探索一下它的内部机制吧。

添加咖啡追踪器

△ 工程结构

△ 工程结构

首先我将与甜甜圈相关的类文件拷贝了一份到新的包下,并且将它们重命名。这样的操作对于真正的应用来说也许不是最好的做法,但是在这里可以快速帮助我们添加咖啡跟踪功能到已有的应用中。如果您希望随着文章内容同步操作,可以获取 这里的代码,里面包含了全部针对 Donut Tracker 应用的修改,可以基于该代码了解 NavigationUI。

基于上面所做的修改,我更新了导航图,新增了从 coffeeFragment 到 coffeeDialogFragment 以及从 selectionFragment 到 donutFragment 相关的目的页面和操作。之后我会用到这些目的页面的 id ;)

△ 带有新的目的页面的导航图

△ 带有新的目的页面的导航图

更新导航图之后,我们可以开始将元素绑定起来,并且实现导航到 SelectionFragment。

选项菜单

应用的选项菜单现在尚未发挥作用。要启用它,需要在 onOptionsItemSelected() 函数中,为被选择的菜单项调用 onNavDestinationSelected() 函数,并传入 navController。只要目的页面的 idMenuItem 的 id 相匹配,该函数会导航到绑定在 MenuItem 上的目的页面。

override fun onOptionsItemSelected(item: MenuItem): Boolean {
   return item.onNavDestinationSelected(
       findNavController(R.id.nav_host_fragment)
   ) || super.onOptionsItemSelected(item)
}

现在导航控制器可以 "支配" 菜单项了,我将 MenuItemid 与之前所创建的目的页面的 id 进行了匹配。这样,导航组件就可以将 MenuItem 与目的页面进行关联。

<menu 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"
   tools:context="com.android.samples.donuttracker.MainActivity">
   <item
       android:id="@+id/selectionFragment"
       android:orderInCategory="100"
       android:title="@string/action_settings"
       app:showAsAction="never" />
</menu>

Toolbar

现在应用可以导航到 selectionFragment,但是标题仍然保持原样。当处于 selectionFragment 的时候,我们希望标题可以被更新并且显示返回按钮。

首先我需要添加一个 AppBarConfiguration 对象,NavigationUI 会使用该对象来管理应用左上角的导航按钮的行为。

appBarConfiguration = AppBarConfiguration(navController.graph)

该按钮会根据您的目的页面的层级改变自身的行为。比如,当您在最顶层的目的页面时,就不会显示回退按钮,因为没有更高层级的页面。

默认情况下,您应用的最初页面是唯一的最顶层目的页面,但是您也可以定义多个最顶层目的页面。比如,在我们的应用中,我可以将 donutList coffeeList 的目的页面都定义为最顶层的目的页面。

接下来,在 MainActivity 类中,获得 navControllertoolbar 的实例,并且验证 setSupportActionBar() 是否被调用。这里我还更新了传入函数的 toolbar 的引用。

val navHostFragment = supportFragmentManager.findFragmentById(
   R.id.nav_host_fragment
) as NavHostFragment
navController = navHostFragment.navController
val toolbar = binding.toolbar

要在默认的操作栏 (Action Bar) 中添加导航功能,我在这里使用了 setupActionBarWithNavController() 函数。该函数需要两个参数: navControllerappBarConfiguration

setSupportActionBar(toolbar)
setupActionBarWithNavController(navController, appBarConfiguration)

接下来,根据目前的目的页面,我覆写了 onSupportNavigationUp() 函数,然后在 nav_host_fragment 上调用 navigateUp() 并传入 appBarConfiguration 来支持回退导航或者显示菜单图标的功能。

override fun onSupportNavigateUp(): Boolean {
   return findNavController(R.id.nav_host_fragment).navigateUp(
       appBarConfiguration
   )
}

现在我可以导航到 selectionFragment,并且您可以看到标题已经更新,并且也显示了返回按钮,用户可以返回到之前的页面。

△ 标题更新了并且也显示了返回按钮

△ 标题更新了并且也显示了返回按钮

底部标签栏

目前为止还算顺利,但是应用还不能导航到 coffeeList Fragment。接下来我们将解决这个问题。

我们从添加底部标签栏入手。首先添加 bottom_nav_menu.xml 文件并且声明两个菜单元素。NavigationUI 依赖 MenuItemid,用它与导航图中目的页面的 id 进行匹配。我还为每个目的页面设置了图标和标题。

<menu xmlns:android="http://schemas.android.com/apk/res/android">
   <item
       android:id="@id/donutList"
       android:icon="@drawable/donut_with_sprinkles"
       android:title="@string/donut_name" />
   <item
       android:id="@id/coffeeList"
       android:icon="@drawable/coffee_cup"
       android:title="@string/coffee_name" />
</menu>

现在 MenuItem 已经就绪,我在 mainActivity 的布局中添加了 BottomNavigationView,并且将 bottom_nav_menu 设置为 BottomNavigationViewmenu 属性。

<com.google.android.material.bottomnavigation.BottomNavigationView
       android:id="@+id/bottom_nav_view"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       app:menu="@menu/bottom_nav_menu" />

要使底部标签栏发挥作用,这里调用 setupWithNavController() 函数将 navController 传入 BottomNavigationView

private fun setupBottomNavMenu(navController: NavController) {
   val bottomNav = findViewById<BottomNavigationView>(
       R.id.bottom_nav_view
   )
   bottomNav?.setupWithNavController(navController)
}

请注意我并没有从导航图中调用任何导航操作。实际上导航图中甚至没有前往 coffeeList Fragment 的路径。和之前对 ActionBar 所做的操作一样,BottomNavigationView 通过匹配 MenuItemid 和导航目的页面的 id 来自动响应导航操作。

抽屉式导航栏

虽然看上去不错,但是如果您设备的屏幕尺寸较大,那么底部标签栏恐怕无法提供最佳的用户体验。要解决这个问题,我会使用另外一个布局文件,它带有 w960dp 限定符,表明它适用于屏幕更大、更宽的设备。

这个布局文件与默认的 activity_main 布局相类似,其中已经包含了 ToolbarFragmentContainerView。我需要添加 NavigationView,并且将 nav_drawer_menu 设置为 NavigationViewmenu 属性。接下来,我将在 NavigationViewFragmentContainerView 之间添加分隔符。

<RelativeLayout
   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"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context="com.android.samples.donuttracker.MainActivity">
   <com.google.android.material.navigation.NavigationView
       android:id="@+id/nav_view"
       android:layout_width="wrap_content"
       android:layout_height="match_parent"
       android:layout_alignParentStart="true"
       app:elevation="0dp"
       app:menu="@menu/nav_drawer_menu" />
   <View
       android:layout_width="1dp"
       android:layout_height="match_parent"
       android:layout_toEndOf="@id/nav_view"
       android:background="?android:attr/listDivider" />
   <androidx.appcompat.widget.Toolbar
       android:id="@+id/toolbar"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:layout_alignParentTop="true"
       android:background="@color/colorPrimary"
       android:layout_toEndOf="@id/nav_view"
       android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar" />
   <androidx.fragment.app.FragmentContainerView
       android:id="@+id/nav_host_fragment"
       android:name="androidx.navigation.fragment.NavHostFragment"
       android:layout_width="match_parent"
       android:layout_height="match_parent"
       android:layout_below="@id/toolbar"
       app:defaultNavHost="true"
       android:layout_toEndOf="@id/nav_view"
       app:navGraph="@navigation/nav_graph" />
</RelativeLayout>

如此一来,在宽屏幕设备上,NavigationView 会代替 BottomNavigationView 显示在屏幕上。现在布局文件已经就绪,我再创建一个 nav_drawer_menu.xml,并且将 donutListcoffeeList 作为主要的分组添加为目的页面。对于 MenuItem,我添加了 selectionFragment 作为它的目的页面。

<menu xmlns:android="http://schemas.android.com/apk/res/android">
   <group android:id="@+id/primary">
       <item
           android:id="@id/donutList"
           android:icon="@drawable/donut_with_sprinkles"
           android:title="@string/donut_name" />
       <item
           android:id="@id/coffeeList"
           android:icon="@drawable/coffee_cup"
           android:title="@string/coffee_name" />
   </group>
   <item
       android:id="@+id/selectionFragment"
       android:title="@string/action_settings" />
</menu>

现在所有布局已经就绪,我们回到 MainActivity,设置抽屉式导航栏,使其能够与 NavigationController 协作。和之前针对 BottomNavigationView 所做的相类似,这里创建一个新的方法,并且调用 setupWithNavController() 函数将 navController 传入 NavigationView。为了使代码保持整洁、各个元素之间更加清晰,我们会在新的方法中实现相关操作,并且在 onCreate() 中调用该方法。

private fun setupNavigationMenu(navController: NavController){
   val sideNavView = findViewById<NavigationView>(R.id.nav_view)
   sideNavView?.setupWithNavController(navController)
}

现在当我在屏幕较宽的设备上运行应用时,可以看到抽屉式导航栏已经设置了 MenuItem,并且在导航图中,MenuItem 和目的页面的 id 是相匹配的。

△ 在屏幕较宽的设备上运行 Donut Tracker

△ 在屏幕较宽的设备上运行 Donut Tracker

请注意,当我切换页面的时候返回按钮会自动显示在左上角。如果您想这么做,还可以修改 AppBarConfiguration 来将 CoffeeList 添加为最顶层的目的页面。

小结

本次分享的内容就是这些了。Donut Tracker 应用并不需要底部标签栏或者抽屉式导航栏,但是添加了新的功能和目的页面后,NavigationUI 可以很大程度上帮助我们处理应用中的导航功能。

我们无需进行多余的操作,仅需添加 UI 组件,并且匹配 MenuItem 的 id 和目的页面的 id。您可以查阅 完整代码,并且通过 main 与 starter 分支的 比较,观察代码的变化。