响应式 UI 的导航

导航是与应用程序的 UI 交互以访问应用程序内容目标的过程。Android 的 导航原则 提供指南,帮助您创建一致且直观的应用导航。

响应式 UI 提供响应式内容目标,并且通常会包含不同类型的导航元素来响应显示大小的变化,例如,在小型显示屏上使用底部 导航栏,在中型显示屏上使用 导航栏,或在大型显示屏上使用持久 导航抽屉,但响应式 UI 仍应符合导航原则。

Jetpack 导航组件 实施了导航原则,可用于促进具有响应式 UI 的应用程序的开发。

图 1. 带有导航抽屉、栏和底部栏的扩展、中等和紧凑显示屏。

响应式 UI 导航

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

窗口大小类 少量项目 许多项目
紧凑宽度 底部导航栏 导航抽屉(前缘或底部)
中等宽度 导航导轨 导航抽屉(前缘)
扩展宽度 导航导轨 持久导航抽屉(前缘)

在基于视图的布局中,布局资源文件可以通过窗口大小类断点进行限定,以便为不同的显示尺寸使用不同的导航元素。 Jetpack Compose 可以使用由 窗口大小类 API 提供的断点以编程方式确定最适合应用程序窗口的导航元素。

视图

<!-- 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>

Compose

// This method should be run inside a Composable function.
val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass
// You can get the height of the current window by invoking heightSizeClass instead.

@Composable
fun MyApp(widthSizeClass: WindowWidthSizeClass) {
    // Select a navigation element based on window size.
    when (widthSizeClass) {
        WindowWidthSizeClass.Compact -> { CompactScreen() }
        WindowWidthSizeClass.Medium -> { MediumScreen() }
        WindowWidthSizeClass.Expanded -> { ExpandedScreen() }
    }
}

@Composable
fun CompactScreen() {
    Scaffold(bottomBar = {
                NavigationBar {
                    icons.forEach { item ->
                        NavigationBarItem(
                            selected = isSelected,
                            onClick = { ... },
                            icon = { ... })
                    }
                }
            }
        ) {
        // Other content
    }
}

@Composable
fun MediumScreen() {
    Row(modifier = Modifier.fillMaxSize()) {
        NavigationRail {
            icons.forEach { item ->
                NavigationRailItem(
                    selected = isSelected,
                    onClick = { ... },
                    icon = { ... })
            }
        }
        // Other content
    }
}

@Composable
fun ExpandedScreen() {
    PermanentNavigationDrawer(
        drawerContent = {
            icons.forEach { item ->
                NavigationDrawerItem(
                    icon = { ... },
                    label = { ... },
                    selected = isSelected,
                    onClick = { ... }
                )
            }
        },
        content = {
            // Other content
        }
    )
}

响应式内容目标

在响应式 UI 中,每个内容目标的布局必须适应窗口大小的变化。 您的应用程序可以调整布局间距,重新定位元素,添加或删除内容,或更改 UI 元素,包括导航元素。(参见 将您的 UI 迁移到响应式布局支持不同的屏幕尺寸。)

当每个单独的目标优雅地处理调整大小事件时,更改将被隔离到 UI。 应用程序的其余状态,包括导航,不受影响。

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

作为窗口大小变化的副作用进行导航有以下问题

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

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

如果您的应用程序需要根据窗口大小来创建唯一的内容目标,请考虑将相关的目标组合到一个包含备选布局的单个目标中。

具有备选布局的内容目标

作为响应式设计的组成部分,单个导航目标可以根据应用程序窗口的大小拥有备选布局。 每个布局都占据整个窗口,但不同的布局将针对不同的窗口大小呈现。

一个典型的例子是 列表-详情视图。 对于较小的窗口大小,您的应用程序将显示一个用于列表的内容布局和一个用于详情的内容布局。 导航到列表-详情视图目标最初只会显示列表布局。 当选择一个列表项时,您的应用程序会显示详情布局,替换列表。 当选择后退控件时,列表布局会显示,替换详情。 但是,对于扩展窗口大小,列表和详情布局并排显示。

视图

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 实现列表-详情布局的详细信息,请参见 创建双窗格布局

Compose

在 Compose 中,列表-详情视图可以通过将备选可组合项组合到单个 路由 中来实现,该路由使用窗口大小类来为每个大小类发出适当的可组合项。

路由是到内容目标的导航路径,通常是单个可组合项,但也可能是备选可组合项。 业务逻辑决定显示哪种备选可组合项。 可组合项会填充应用程序窗口,无论显示哪种备选项。

列表-详情视图包含三个可组合项,例如

/* Displays a list of items. */
@Composable
fun ListOfItems(
    onItemSelected: (String) -> Unit,
) { /*...*/ }

/* Displays the detail for an item. */
@Composable
fun ItemDetail(
    selectedItemId: String? = null,
) { /*...*/ }

/* Displays a list and the detail for an item side by side. */
@Composable
fun ListAndDetail(
    selectedItemId: String? = null,
    onItemSelected: (String) -> Unit,
) {
  Row {
    ListOfItems(onItemSelected = onItemSelected)
    ItemDetail(selectedItemId = selectedItemId)
  }
}

单个导航路由提供对列表-详情视图的访问

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      /*...*/
    )
  } else {
    // If the display size cannot accommodate both the list and the item detail,
    // show one of them based on the user's focus.
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(/*...*/)
    }
  }
}

ListDetailRoute(导航目标)决定发出哪三个可组合项:对于扩展窗口大小,发出 ListAndDetail;对于紧凑,根据是否已选择列表项发出 ListOfItemsItemDetail

该路由包含在 NavHost 中,例如

NavHost(navController = navController, startDestination = "listDetailRoute") {
  composable("listDetailRoute") {
    ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,
                    selectedItemId = selectedItemId)
  }
  /*...*/
}

您可以通过检查应用程序的 WindowMetrics 来提供 isExpandedWindowSize 参数。

selectedItemId 参数可以由一个 ViewModel 提供,该 ViewModel 在所有窗口大小之间维护状态。 当用户从列表中选择一个项目时,selectedItemId 状态变量会被更新。

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

当项目详情可组合项占据整个应用程序窗口时,该路由还包括一个自定义 BackHandler

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }

  fun onItemBackPress() {
    viewModelState.update {
      it.copy(selectedItemId = null)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
    onItemBackPress: () -> Unit = { listDetailViewModel.onItemBackPress() },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
      BackHandler {
        onItemBackPress()
      }
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

将来自 ViewModel 的应用程序状态与窗口大小类信息相结合,使得选择适当的可组合项成为一个简单的逻辑问题。 通过维护 单向数据流,您的应用程序能够充分利用可用的显示空间,同时保留应用程序状态。

有关 Compose 中完整列表-详情视图实现的示例,请参见 GitHub 上的 JetNews 示例。

一个导航图

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

如果您对每个窗口大小类使用不同的导航图,那么每当应用程序从一个大小类过渡到另一个大小类时,您都必须确定用户在其他图中的当前目标,构建一个后退栈,并协调图之间不同的状态信息。

嵌套导航主机

您的应用程序可能包含一个内容目标,它本身具有内容目标。 例如,在列表-详情视图中,项目详情窗格可能包含导航到替换项目详情的内容的 UI 元素。

为了实现这种子导航,详情窗格可以是一个嵌套导航主机,它具有自己的导航图,该导航图指定从详情窗格访问的目标

视图

<!-- 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>

Compose

@Composable
fun ItemDetail(selectedItemId: String? = null) {
    val navController = rememberNavController()
    NavHost(navController, "itemSubdetail1") {
        composable("itemSubdetail1") { ItemSubdetail1(...) }
        composable("itemSubdetail2") { ItemSubdetail2(...) }
        composable("itemSubdetail3") { ItemSubdetail3(...) }
    }
}

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

有关更多信息,请参见 嵌套导航图使用 Compose 进行导航

保留状态

为了提供响应式内容目标,您的应用程序必须在设备旋转或折叠或应用程序窗口调整大小时保留其状态。 默认情况下,诸如此类的配置更改会重新创建应用程序的活动、片段、视图层次结构和可组合项。 保存 UI 状态的推荐方法是使用 ViewModelrememberSaveable,它们在配置更改中存活。(参见 保存 UI 状态状态和 Jetpack Compose。)

尺寸变化应该是可逆的——例如,当用户旋转设备然后将其旋转回来时。

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

ViewModel 作用域

迁移到导航组件 开发人员指南建议使用单活动架构,其中目标实现为片段,其数据模型使用 ViewModel 实现。

ViewModel 始终与生命周期绑定在一起,一旦该生命周期永久结束,ViewModel 就会被 清除 并且可以被丢弃。 与 ViewModel 绑定的生命周期(因此 ViewModel 的共享范围)取决于用于获取 ViewModel 的哪个属性委托。

在最简单的情况下,每个导航目标都是一个具有完全隔离的 UI 状态的单个片段;因此,每个片段都可以使用 viewModels() 属性委托来获取与该片段绑定的 ViewModel

为了在片段之间共享 UI 状态,通过在片段中调用 activityViewModels()ViewModel 的作用域绑定到活动(活动中对应的调用方式是 viewModels())。 这允许活动和任何附加到它的片段共享 ViewModel 实例。 但是,在单活动架构中,此 ViewModel 作用域实际上与应用程序的持续时间相同,因此即使没有片段使用 ViewModel,它也会保留在内存中。

假设您的导航图有一系列代表结账流程的片段目标,并且整个结账体验的当前状态位于片段之间共享的 ViewModel 中。 将 ViewModel 的作用域绑定到活动不仅范围太广,而且实际上暴露了另一个问题:如果用户完成一个订单的结账流程,然后再次完成另一个订单的结账流程,这两个订单将使用相同的结账 ViewModel 实例。 在第二个订单结账之前,您将不得不手动清除第一个订单的数据,任何错误都可能对用户造成重大损失。

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

范围 属性委托 可以与以下内容共享 ViewModel
片段 Fragment.viewModels() 仅限当前片段
活动 Activity.viewModels()

Fragment.activityViewModels()

活动及其所有附加的片段
导航图 Fragment.navGraphViewModels() 同一导航图中的所有片段

请注意,如果您使用的是 嵌套导航主机(见上文),则当使用 navGraphViewModels() 时,主机中的目的地无法与主机外部的目的地共享 ViewModel,因为这些图没有连接。在这种情况下,您可以改用活动的范围。

提升状态

在 Compose 中,您可以使用 状态提升 在窗口大小更改时保留状态。通过将可组合项的状态提升到组合树中更高位置,即使可组合项不再可见,也可以保留状态。

在上面 Compose 部分的 具有备用布局的内容目的地 中,我们将列表详细信息视图可组合项的状态提升到 ListDetailRoute,以便无论显示哪个可组合项,状态都能保留。

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) { /*...*/ }

其他资源