构建响应式导航

导航是用户与应用界面的交互,以便访问内容目的地。Android 的导航原则提供了指导,可帮助您创建一致且直观的应用导航。

响应式/自适应界面提供响应式内容目的地,并且通常包含不同类型的导航元素,以响应显示屏大小的变化,例如在小显示屏上使用底部导航栏,在中等大小显示屏上使用导航轨道,或在大显示屏上使用持久性抽屉式导航栏,但响应式/自适应界面仍应符合导航原则。

Jetpack Navigation 组件实现了导航原则,并有助于开发具有响应式/自适应界面的应用。

图 1. 展开、中等和紧凑显示屏上的抽屉式导航栏、导航轨道和底部栏。

响应式界面导航

应用占据的显示窗口大小会影响人体工程学和可用性。窗口大小类别使您能够确定适当的导航元素(例如导航栏、导航轨道或抽屉式导航栏),并将其放置在用户最易于访问的位置。在 Material Design 布局指南中,导航元素在显示屏前缘占据持久空间,当应用宽度紧凑时,可以移动到底部边缘。您选择的导航元素很大程度上取决于应用窗口的大小以及元素必须容纳的项目数量。

窗口大小类别 项目较少 项目较多
紧凑宽度 底部导航栏 抽屉式导航栏(前缘或底部)
中等宽度 导航轨道 抽屉式导航栏(前缘)
展开宽度 导航轨道 持久性抽屉式导航栏(前缘)

布局资源文件可以按窗口大小类别断点限定,以便针对不同的显示尺寸使用不同的导航元素。

<!-- res/layout/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>

<!-- res/layout-w600dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigationrail.NavigationRailView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>

<!-- res/layout-w1240dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigation.NavigationView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>

响应式内容目的地

在响应式界面中,每个内容目标的布局会根据窗口大小的变化进行调整。您的应用可以调整布局间距、重新定位元素、添加或移除内容,或更改界面元素,包括导航元素。

当每个独立的目标处理大小调整事件时,更改仅限于界面本身。应用的其余状态(包括导航)不受影响。

导航不应作为窗口大小变化的副作用而发生。不要仅为了适应不同的窗口大小而创建内容目的地。例如,不要为可折叠设备的不同屏幕创建不同的内容目的地。

作为窗口大小变化的副作用导航到内容目的地会带来以下问题:

  • 在导航到新目的地之前,旧目的地(适用于先前的窗口大小)可能会短暂可见
  • 为了保持可逆性(例如,当设备折叠和展开时),每种窗口大小都需要导航
  • 在目的地之间维护应用状态可能很困难,因为导航会在弹出返回堆栈时销毁状态

此外,当窗口大小发生变化时,您的应用甚至可能不在前台。您的应用布局可能需要比前台应用更多的空间,当用户返回您的应用时,方向和窗口大小都可能已经改变。

如果您的应用需要基于窗口大小的唯一内容目的地,请考虑将相关目的地合并到一个包含备用自适应布局的单一目的地中。

具有备用布局的内容目的地

作为响应式/自适应设计的一部分,单个导航目的地可以根据应用窗口大小具有备用布局。每个布局占据整个窗口,但对于不同的窗口大小会呈现不同的布局(自适应设计)。

一个标准示例是列表-详情视图。对于紧凑的窗口大小,您的应用会显示一个用于列表的内容布局和一个用于详情的内容布局。导航到列表-详情视图目的地时,最初只会显示列表布局。选择列表项时,您的应用会显示详情布局,替换列表。选择返回控件时,会显示列表布局,替换详情。然而,对于展开的窗口大小,列表和详情布局会并排显示。

SlidingPaneLayout 使您能够创建一个单一的导航目的地,在大屏幕上并排显示两个内容窗格,但在传统手机等小屏幕上一次只显示一个窗格。

<!-- Single destination for list and detail. -->

<navigation ...>

    <!-- Fragment that implements SlidingPaneLayout. -->
    <fragment
        android:id="@+id/article_two_pane"
        android:name="com.example.app.ListDetailTwoPaneFragment" />

    <!-- Other destinations... -->
</navigation>

有关使用 SlidingPaneLayout 实现列表-详情布局的详细信息,请参阅创建双窗格布局

单一导航图

为了在任何设备或窗口大小上提供一致的用户体验,请使用单一导航图,其中每个内容目标的布局都是响应式的。

如果您为每个窗口大小类别使用不同的导航图,则每当应用从一个大小类别过渡到另一个大小时,您都必须确定用户在其他图中的当前目的地,构建返回堆栈,并协调图之间不同的状态信息。

嵌套导航宿主

您的应用可能包含一个具有其自身内容目的地的内容目的地。例如,在列表-详情布局中,项目详情窗格可能包含导航到替换项目详情的内容的界面元素。

为了实现这种子导航,将详情窗格设为一个嵌套导航宿主,该宿主具有自己的导航图,用于指定从详情窗格访问的目的地。

<!-- layout/two_pane_fragment.xml -->

<androidx.slidingpanelayout.widget.SlidingPaneLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/sliding_pane_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list_pane"
        android:layout_width="280dp"
        android:layout_height="match_parent"
        android:layout_gravity="start"/>

    <!-- Detail pane is a nested navigation host. Its graph is not connected
         to the main graph that contains the two_pane_fragment destination. -->
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/detail_pane"
        android:layout_width="300dp"
        android:layout_weight="1"
        android:layout_height="match_parent"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/detail_pane_nav_graph" />
</androidx.slidingpanelayout.widget.SlidingPaneLayout>

这与嵌套导航图不同,因为嵌套 NavHost 的导航图未连接到主导航图;也就是说,您无法直接从一个图中的目的地导航到另一个图中的目的地。

如需了解详情,请参阅嵌套导航图

保存状态

为了提供响应式内容目的地,您的应用必须在设备旋转或折叠或应用窗口调整大小时保留其状态。默认情况下,此类配置更改会重新创建应用 Activity、Fragment 和视图层次结构。保存界面状态的推荐方法是使用 ViewModel,它可以在配置更改后存活。(请参阅保存界面状态)。

大小更改应该是可逆的,例如用户旋转设备然后将其转回时。

响应式/自适应布局可以在不同的窗口大小下显示不同的内容;因此,响应式布局通常需要保存与内容相关的额外状态,即使该状态不适用于当前窗口大小。例如,布局可能仅在窗口宽度较大时有空间显示额外的滚动微件。如果调整大小事件导致窗口宽度变得太小,微件将被隐藏。当应用调整回其先前尺寸时,滚动微件会再次可见,并且应该恢复原始滚动位置。

ViewModel 作用域

针对 迁移到 Navigation 组件的开发者指南规定了一种单 Activity 架构,其中目的地实现为 Fragment,其数据模型使用 ViewModel 实现。

ViewModel 的作用域始终与其生命周期相关联,当该生命周期永久结束时,ViewModel 将被清除并可以被丢弃。ViewModel 的作用域所关联的生命周期(以及因此 ViewModel 的共享范围)取决于用于获取 ViewModel 的属性委托。

在最简单的情况下,每个导航目的地都是一个具有完全独立界面状态的单个 Fragment;因此,每个 Fragment 都可以使用 viewModels() 属性委托来获取作用域限定为该 Fragment 的 ViewModel

若要在 Fragment 之间共享界面状态,可在 Fragment 中调用 activityViewModels()ViewModel 的作用域限定到 Activity(对于 Activity,等效项只是 viewModels())。这使得 Activity 及其附加到的任何 Fragment 都可以共享 ViewModel 实例。然而,在单 Activity 架构中,此 ViewModel 作用域的有效持续时间与应用一样长,因此即使没有 Fragment 使用它,ViewModel 也会保留在内存中。

假设您的导航图有一系列表示结账流程的 Fragment 目的地,并且整个结账体验的当前状态位于一个在 Fragment 之间共享的 ViewModel 中。将 ViewModel 的作用域限定到 Activity 不仅范围太广,而且实际上还暴露了另一个问题:如果用户完成了一个订单的结账流程,然后又完成了第二个订单的结账流程,这两个订单都会使用同一个结账 ViewModel 实例。在处理第二个订单结账之前,您必须手动清除第一个订单的数据。任何错误都可能给用户带来高昂的成本。

而是将 ViewModel 的作用域限定到当前 NavController 中的导航图。创建一个嵌套导航图来封装属于结账流程的目的地。然后在这些 Fragment 目的地中的每一个中使用 navGraphViewModels() 属性委托,并传入导航图的 ID 来获取共享 ViewModel。这确保了用户退出结账流程并且嵌套导航图超出作用域后,相应的 ViewModel 实例将被丢弃,并且不会用于下一次结账。

作用域 属性委托 可与以下对象共享 ViewModel
Fragment Fragment.viewModels() 仅 Fragment
Activity Activity.viewModels()Fragment.activityViewModels() Activity 及其附加到的所有 Fragment
导航图 Fragment.navGraphViewModels() 同一导航图中的所有 Fragment

请注意,如果您使用的是嵌套导航宿主(请参阅嵌套导航宿主部分),则在使用 navGraphViewModels() 时,该宿主中的目的地无法与宿主外部的目的地共享 ViewModel 实例,因为这些图未连接。在这种情况下,您可以使用 Activity 作用域代替。

其他资源