阅读 1142
踩坑!Android Jetpack组件库之Navigation

踩坑!Android Jetpack组件库之Navigation

踩坑!Android Jetpack组件间库之Navigation

概述

Android JetpackNavigation组件可帮助我们实现页面间导航,无论是简单的按钮点击,还是标题栏和抽屉式导航栏等更为复杂的模式,该组件均可应对。导航组件通过遵循导航既定原则来确保一致且可预测的用户体验。

Navigation 组件旨在用于具有一个主 Activity 和多个 Fragment 目的地的应用。 主 Activity 与导航图相关联,且包含一个负责根据需要交换目的地的 NavHostFragment。具有以下优势:

  • 拥有可视化的页面导航图,便于看清页面之间的关系;
  • 支持NavDirectionsAction以及DeepLink多种导航方式;
  • 简单便捷的使用导航图或代码设置转场动画;
  • 页面间跳转时提供类型安全的参数传递;
  • NavigationUIToolbarNavigationViewBottomNavigationViewCollapsingToolbarLayoutDrawerLayout等控件提供了良好的支持。

注意:

  • 本文针对Navigation 组件当前发布的最新稳定版2.3.5进行讲解,需要 Android Studio 3.3 或更高版本,并且依赖于 Java 8 语言功能。
  • 根据迁移到 AndroidX 文档,您的 gradle.properties 文件中还必须具有 android.useAndroidX=true
  • 官方对Navigation的设计是不会对Fragment进行状态保存,需要开发者在ViewModel实现,所以无论是跳转到下一页面或者回退到上一页面,Fragment都会重新走生命周期方法,对此,本文也给出了解决方案,参考提供安全可靠的 Navigation 操作

基本使用

在Project的 build.gradle 文件添加以下依赖:

buildscript {
    repositories {
        google()
    }
    dependencies {
        def nav_version = "2.3.5"
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5"
    }
}
复制代码

在app的 build.gradle 文件添加以下依赖:

apply plugin: "androidx.navigation.safeargs"

dependencies {

  // Kotlin相关依赖
  implementation "androidx.navigation:navigation-fragment-ktx:2.3.5"
  implementation "androidx.navigation:navigation-ui-ktx:2.3.5"
  
}
复制代码

创建导航图:

  1. 在“Project”窗口中,右键点击 res 目录,然后依次选择 New > Android Resource File。此时系统会显示 New Resource File 对话框。
  2. 在 File name 字段中输入名称,例如“nav_graph”。
  3. 从 Resource type 下拉列表中选择 Navigation,然后点击 OK

当添加首个导航图时,Android Studio 会在 res 目录内创建一个 navigation 资源目录。该目录包含您的导航图资源文件(例如 nav_graph.xml)。

nav_graph1.png

添加nav_graph.xml后,Android Studio 会在 Navigation Editor 中打开该文件。在 Navigation Editor 中,您可以直观地修改导航图,或直接修改底层 XML。

下一步我们需要点击+号,去创建一个destination,点击Create new destination创建 HomeFragment

nav_graph2.png

destination是目的地,即你想要去的地方。可以是Fragment或者Activity,但最常见的是Fragment,Navigation组件的目的就是方便开发者在一个Activity中管理多个Fragment。

这时候就会生成一个Fragment,右上角默认为startDestinationNavHostFragment容器首先要展示的Fragment。如果发现页面不能预览,可以手动编辑XML文件在fragment节点添加页面对应的布局引用就可以了(例如 tools:layout="@layout/fragment_home")。

nav_graph3.png

点击右上角Split可以看到XML文件。这里的<navigation> 元素是导航图的根元素。当我们向导航图添加目的地和连接操作时,可以看到相应的 <destination> 和 <action> 元素在此处显示为子元素。如果您有嵌套视图,它们将显示为子 <navigation> 元素。

nav_graph4.png

向 Activity 添加 NavHost

导航宿主是 Navigation 组件的核心部分之一。导航宿主是一个空容器,用户在我们的应用中导航时,目的地会在该容器中交换进出。

导航宿主必须派生于 NavHost。Navigation 组件的默认 NavHost 实现 (NavHostFragment) 负责处理 Fragment 目的地的交换。

Navigation 组件旨在用于具有一个主 Activity 和多个 Fragment 目的地的应用。主 Activity 与导航图相关联,且包含一个负责根据需要交换目的地的 NavHostFragment。在具有多个 Activity 目的地的应用中,每个 Activity 均拥有其自己的导航图。

接下来我们在MainAcitivty的XML文件添加 NavHostFragment,这里推荐使用的是androidx.fragment.app.FragmentContainerView而非fragment

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph" />
复制代码
  • android:name 属性指向 NavHost 实现的类名称。
  • app:navGraph 属性将 NavHostFragment 与导航图相关联。导航图会在此 NavHostFragment 中指定用户可以导航到的所有目的地。
  • app:defaultNavHost="true" 属性确保您的 NavHostFragment 会拦截系统返回按钮。请注意,只能有一个默认 NavHost。如果同一布局(例如,双窗格布局)中有多个宿主,请务必仅指定一个默认 NavHost

向导航图添加目的地

我们可以从现有的 Fragment 或 Activity 创建目的地。还可以使用 Navigation Editor 创建新目的地,或创建占位符以便稍后替换为 Fragment 或 Activity。

连接目的地

可以使用 Navigation Editor 将两个目的地连接起来,具体操作步骤如下:

  • 在 Design 标签页中,将鼠标悬停在目的地的右侧,该目的地为您希望用户从中导航出来的目的地。该目的地右侧上方会显示一个圆圈;
  • 点击希望用户导航到的目的地,并将光标拖动到该目的地的上方,然后松开。这两个目的地之间生成的线条表示操作。

点击右上角Code切换到XML视图可以看到:

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/homeFragment">

    <fragment
        android:id="@+id/homeFragment"
        android:name="com.soushin.tinmvvm.mvvm.ui.fragment.HomeFragment"
        android:label="HomeFragment"
        tools:layout="@layout/fragment_home">
        <action
            android:id="@+id/action_homeFragment_to_categoryFragment"
            app:destination="@id/categoryFragment" />
    </fragment>

    <fragment
        android:id="@+id/categoryFragment"
        android:name="com.soushin.tinmvvm.mvvm.ui.fragment.CategoryFragment"
        android:label="CategoryFragment"
        tools:layout="@layout/fragment_category"/>
</navigation>
复制代码

在导航图中,连接由 <action> 元素表示。连接至少应包含<action>的 ID 和用户应转到的目的地的 ID(destination指向的id)。

导航到目的地

导航到目的地是使用 NavController 完成的,它是一个在 NavHost 中管理应用导航的对象。每个 NavHost 均有自己的 NavController。您可以使用以下方法之一获取 NavController

Kotlin

Java

在目的地之间传递数据

启用 Safe Args 后,会为每个action的起点和终点生成类型安全的类和方法。具体操作如下:

  1. 在 Navigation Editor 中,点击接收参数的终点;
  2. 在 Attributes 面板中,点击 Add ( + );
  3. 在显示的 Add Argument Link 窗口中,输入参数名称、参数类型、参数是否可为 null,以及默认值(如果需要);
  4. 点击 Add。请注意,该参数现在会显示在 Attributes 面板的 Arguments 列表中;
  5. 您还可以看到该参数已添加到 XML 中。点击 Code 标签页以切换到 XML 视图,就会发现您的参数已添加到接收该参数的目的地。相关示例如下所示:
    <fragment
        android:id="@+id/categoryFragment"
        android:name="com.soushin.tinmvvm.mvvm.ui.fragment.CategoryFragment"
        android:label="CategoryFragment"
        tools:layout="@layout/fragment_category">
        <argument
            android:name="pageType"
            app:argType="integer" />
    </fragment>
复制代码

除了可以在XML文件中静态传值,还可以动态设置。我们在起点通过NavControllernavigate()导航,并且使用HomeFragmentDirections.actionHomeFragmentToCategoryFragment(99)进行传参,然后在终点通过CategoryFragmentArgs获取传过来的参数:

//起点设置参数 类型安全
Navigation.findNavController(v).navigate(HomeFragmentDirections.actionHomeFragmentToCategoryFragment(99))
//终点接收参数 类型安全
val args : CategoryFragmentArgs by navArgs<CategoryFragmentArgs>()
println(args.pageType)
复制代码

踩坑:当我们快速点击按钮会重复调用navigate()导致异常如下:

java.lang.IllegalArgumentException: Navigation action/destination com.soushin.tinmvvm:id/action_homeFragment_to_categoryFragment cannot be found from the current destination Destination(com.soushin.tinmvvm:id/categoryFragment) label=CategoryFragment class=com.soushin.tinmvvm.mvvm.ui.fragment.CategoryFragment
复制代码

这是因为第一次调用navigate()后当前页面已经换了,而我们的action是明确了起点和终点的,所以当第二次调用的时候起点不再是HomeFragment就会报上述异常,这在页面跳转时添加转场动画会显得非常明显(转场动画有duration),只需要在跳转前判断currentDestination是否是当前页面的destination即可。示例如下:

if (Navigation.findNavController(v).currentDestination?.id != R.id.homeFragment) return
复制代码

使用NavigationUI更新页面

Navigation 组件包含 NavigationUI 类。此类包含多种静态方法,可帮助您使用ToolbarNavigationViewBottomNavigationViewCollapsingToolbarLayoutDrawerLayout来管理导航。

顶部标题栏(Toolbar/ActionBar/CollapsingToolbarLayout)

nav_graph5.png

利用 NavigationUI 内置的方法,我们可以在用户浏览应用的过程中自动更新顶部标题栏中的内容。例如,NavigationUI 可使用导航图中的目的地标签及时更新顶部标题栏的标题。

<navigation>
    <fragment ...
              android:label="Page title">
      ...
    </fragment>
</navigation>
复制代码

还有个语法糖就是,如果我们按下面介绍的顶部标题栏实现方法使用 NavigationUI,可以在标签中使用 {argName} 格式,从而根据提供给相应目的地的参数来自动填充附加到目的地的标签。

NavigationUI 支持以下顶部标题栏类型:

AppBarConfiguration

NavigationUI 使用 AppBarConfiguration 对象管理在应用显示区域左上角的导航按钮的行为。导航按钮的行为会根据当前页面是否位于顶层目的地而变化。

顶层目的地是一组存在层次关系的目的地中的根级或最高级目的地。顶层目的地不会在顶部标题栏中显示“返回”按钮,因为不存在更高等级的目的地。默认情况下,应用的目的地是唯一的顶层目的地。

当用户位于顶层目的地时,如果目的地使用了 DrawerLayout,导航按钮会变为抽屉式导航栏图标。如果目的地没有使用 DrawerLayout,导航按钮处于隐藏状态。当用户位于任何其他目的地时,导航按钮会显示为返回按钮。在配置导航按钮时,如需将起始目的地用作唯一顶层目的地,请创建 AppBarConfiguration 对象并传入相应的导航图,如下所示:

val appBarConfiguration = AppBarConfiguration(navController.graph)
复制代码

在更多情况下,我们可能需要定义多个顶层目的地,而不是使用默认的目的地。这种情况的一种常见用例是 BottomNavigationView,在此场景中,同级屏幕可能彼此之间并不存在层次关系,并且可能各自有一组相关的目的地。对于这样的情况,您可以改为将一组目的地 ID 传递给构造函数,如下所示:

val appBarConfiguration = AppBarConfiguration(setOf(R.id.main, R.id.profile))
复制代码
创建Toolbar

如需使用 NavigationUI 创建工具栏,请先在主 activity 中定义该工具栏,如下所示:

<LinearLayout>
    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar" />
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        ... />
    ...
</LinearLayout>
复制代码

接下来,从主 activity 的 onCreate() 方法中调用 setupWithNavController(),如下所示:

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

    ...

    val navController = findNavController(R.id.nav_host_fragment)
    val appBarConfiguration = AppBarConfiguration(navController.graph)
    findViewById<Toolbar>(R.id.toolbar)
        .setupWithNavController(navController, appBarConfiguration)
}
复制代码

注意:使用 Toolbar 时,Navigation 组件会自动处理导航按钮的点击事件,因此我们不需要重写 onSupportNavigateUp()

如需将导航按钮配置为在所有目的地都显示为返回按钮,那么在构建 AppBarConfiguration 时,请为顶层目的地传递一组空白目的地 ID。例如,如果我们有第二个 activity,其应在所有目的地的 Toolbar 中显示返回按钮,这样做可能就很有用。这样一来,当返回堆栈上没有其他目的地时,用户便可导航回父 activity。您可以使用 setFallbackOnNavigateUpListener() 控制在 navigateUp() 不另行执行任何操作时的回退行为,如以下示例所示:

override fun onCreate(savedInstanceState: Bundle?) {
    ...

    val navHostFragment =
        supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
    val navController = navHostFragment.navController
    val appBarConfiguration = AppBarConfiguration(
        topLevelDestinationIds = setOf(),
        fallbackOnNavigateUpListener = ::onSupportNavigateUp
    )
    findViewById<Toolbar>(R.id.toolbar)
        .setupWithNavController(navController, appBarConfiguration)
}
复制代码
使用CollapsingToolbarLayout

如需在工具栏中添加 CollapsingToolbarLayout,请先在 activity 中定义工具栏和周围布局,如下所示:

<LinearLayout>
    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="@dimen/tall_toolbar_height">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/collapsing_toolbar_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:contentScrim="?attr/colorPrimary"
            app:expandedTitleGravity="top"
            app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">

            <androidx.appcompat.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin"/>
        </com.google.android.material.appbar.CollapsingToolbarLayout>
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        ... />
    ...
</LinearLayout>
复制代码

接着,通过主 activity 的 onCreate 方法调用 setupWithNavController(),如下所示:

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

    ...

    val layout = findViewById<CollapsingToolbarLayout>(R.id.collapsing_toolbar_layout)
    val toolbar = findViewById<Toolbar>(R.id.toolbar)
    val navHostFragment =
        supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
    val navController = navHostFragment.navController
    val appBarConfiguration = AppBarConfiguration(navController.graph)
    layout.setupWithNavController(toolbar, navController, appBarConfiguration)
}
复制代码
使用ActionBar

如需向默认操作栏添加导航支持,请通过主 activity 的 onCreate() 方法调用 setupActionBarWithNavController(),如下所示。请注意,您需要在 onCreate() 之外声明 AppBarConfiguration,因为您在替换 onSupportNavigateUp() 时也使用该方法:

private lateinit var appBarConfiguration: AppBarConfiguration

...

override fun onCreate(savedInstanceState: Bundle?) {
    ...

    val navHostFragment =
        supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
    val navController = navHostFragment.navController
    appBarConfiguration = AppBarConfiguration(navController.graph)
    setupActionBarWithNavController(navController, appBarConfiguration)
}
复制代码

接着,替换 onSupportNavigateUp() 以处理向上导航:

override fun onSupportNavigateUp(): Boolean {
    val navController = findNavController(R.id.nav_host_fragment)
    return navController.navigateUp(appBarConfiguration)
            || super.onSupportNavigateUp()
}
复制代码
支持标题栏的不同样式

如果对于应用中的每个目的地,标题栏的布局都类似,那么向 activity 添加顶部标题栏的效果很好。但是,如果顶部标题栏在不同目的地之间有很大变化,请考虑从 activity 中移除顶部标题栏,并改为在每个目的地fragment 中进行定义。

例如,一个目的地可能使用标准的 Toolbar,而另一个目的地则使用 AppBarLayout 创建带有标签页的更复杂的标题栏。

如需使用 NavigationUI 在目的地 fragment 中实现此示例,首先请在每个 fragment 布局中定义标题栏,从使用标准工具栏的目的地 fragment 开始:

<LinearLayout>
    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar"
        ... />
    ...
</LinearLayout>
复制代码

接下来,定义使用带有标签页的应用栏的目的地 fragment:

<LinearLayout>
    <com.google.android.material.appbar.AppBarLayout
        ... />

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            ... />

        <com.google.android.material.tabs.TabLayout
            ... />

    </com.google.android.material.appbar.AppBarLayout>
    ...
</LinearLayout>
复制代码

这两个 fragment 的导航配置逻辑相同,不过您应该在每个 fragment 的 onViewCreated() 方法中调用 setupWithNavController(),而不是通过 activity 对它们进行初始化:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    val navController = findNavController()
    val appBarConfiguration = AppBarConfiguration(navController.graph)

    view.findViewById<Toolbar>(R.id.toolbar)
            .setupWithNavController(navController, appBarConfiguration)
}
复制代码

注意:设置 fragment 过渡时,如果将顶部应用栏放入目的地 fragment 布局中,会导致在 fragment 切换期间标题栏与布局的一起转场。

使用DrawerLayout

DrawerLayout是显示应用主导航菜单的界面。当用户触摸应用栏中的抽屉式导航栏图标或用户从屏幕的左边缘滑动手指时,就会显示DrawerLayout。

抽屉式导航栏图标会显示在使用 DrawerLayout 的所有顶层目的地上。

如需添加抽屉式导航栏,请先声明 DrawerLayout 为根视图。在 DrawerLayout 内,为主界面内容以及包含抽屉式导航栏内容的其他视图添加布局。

例如,以下布局使用含有两个子视图的 DrawerLayout:包含主内容的 NavHostFragment 和适用于抽屉式导航栏内容的 NavigationView

<?xml version="1.0" encoding="utf-8"?>
<!-- Use DrawerLayout as root container for activity -->
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/drawer_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">

    <!-- Layout to contain contents of main body of screen (drawer will slide over this) -->
    <androidx.fragment.app.FragmentContainerView
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:id="@+id/nav_host_fragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph" />

    <!-- Container for contents of drawer - use NavigationView to make configuration easier -->
    <com.google.android.material.navigation.NavigationView
        android:id="@+id/nav_view"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        android:fitsSystemWindows="true" />

</androidx.drawerlayout.widget.DrawerLayout>
复制代码

接下来,将 DrawerLayout 传递给 AppBarConfiguration,以将其连接到导航图,如以下示例所示:

注意:当使用 NavigationUI 时,顶部标题栏帮助程序会随着当前目的地的更改在抽屉式导航栏图标和返回图标之间自动转换。无需使用 ActionBarDrawerToggle

接着,在我们的主 activity 类中,通过主 activity 的 onCreate() 方法调用 setupWithNavController(),如下所示:

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

    ...

    val navHostFragment =
        supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
    val navController = navHostFragment.navController
    findViewById<NavigationView>(R.id.nav_view)
        .setupWithNavController(navController)
}
复制代码

使用BottomBarNavigation

NavigationUI 也可以处理底部导航。当用户选择某个菜单项时,NavController 会调用 onNavDestinationSelected() 并自动更新底部导航栏中的所选项目。

bottom-navigation.png

如需在应用中创建底部导航栏,请先在主 activity 中定义底部导航栏,如下所示:

<LinearLayout>
    ...
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        ... />
    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/bottom_nav"
        app:menu="@menu/menu_bottom_nav" />
</LinearLayout>
复制代码

接着,在您的主 activity 类中,通过主 activity 的 onCreate() 方法调用 setupWithNavController(),如下所示:

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

    ...

    val navHostFragment =
        supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
    val navController = navHostFragment.navController
    findViewById<BottomNavigationView>(R.id.bottom_nav)
        .setupWithNavController(navController)
}
复制代码

相关资料:

Android Jetpack Navigation官方文档

FragmentPagerAdapter、FragmentStatePagerAdapter和FragmentStateAdapter的区别你知道嘛

Android Jetpack最佳实践!结合MVVM快速开发,助力开发者深入理解Jetpack!

文章分类
Android
文章标签