自适应界面的导航

导航是与应用界面进行交互以访问应用内容目的地的过程。Android 的导航原则提供了一些准则,可帮助您创建一致、直观的应用导航。

自适应界面可提供自适应内容目的地,并且通常会包含不同类型的导航元素来应对屏幕尺寸变化(例如,小显示屏上的底部导航栏、中等显示屏上的导航侧边栏,或大显示屏上持续存在的抽屉式导航栏),但自适应界面仍应遵循导航原则。

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

自适应界面导航

应用占用的显示屏窗口大小会影响人体工程学和易用性。您可以根据窗口大小类别确定适当的导航元素(例如导航栏、侧边栏或抽屉式导航栏),并将其放置在用户最容易访问的位置。根据 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-w840dp/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 = {
                BottomNavigation {
                    icons.forEach { item ->
                        BottomNavigationItem(
                            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
        }
    )
}

自适应内容目的地

在自适应界面中,每个内容目的地的布局都必须适应窗口大小的变化。应用可以调整布局间距、调整元素位置、添加或移除内容,或更改界面元素(包括导航元素)。(请参阅将界面迁移到自适应布局构建自适应布局。)

如果每个目的地都能妥善处理大小调整事件,那么更改就只会涉及到界面,不会影响应用状态的其余部分(包括导航)。

导航不应在窗口大小发生更改时才会发生。请勿仅为了适应不同的窗口大小而创建内容目的地。例如,不要针对可折叠设备的不同屏幕创建不同的内容目的地。

在窗口大小发生更改时进行导航会面临以下问题:

  • 在导航到新目的地之前,可能会短暂地显示旧目的地(针对上一个窗口大小)
  • 为保持可逆转性(例如,当设备折叠和展开时),您需要为每种窗口大小进行导航
  • 在目的地之间保留应用状态可能很困难,因为导航可能会在弹出返回堆栈时销毁状态

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

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

采用备用布局的内容目的地

作为自适应设计的一部分,单个导航目的地可以提供备用布局,具体取决于应用窗口大小。每个布局都会占据整个窗口,但会针对不同的窗口大小提供不同的布局。

列表详情视图就是一个很好的例子。对于小窗口,应用会为列表显示一个内容布局,并为详细信息显示一个内容布局。导航到列表详情视图目的地最初仅显示列表布局。选择列表项后,应用会显示详情布局来替换列表。选择返回控件后,将显示列表布局来替换详细信息。但是,如果窗口很大,列表和详情布局会并排显示。

视图

借助 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 提供。当用户从列表中选择一项时,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 示例。

一个导航图

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

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

嵌套导航宿主

您的应用可能包含具有自身内容目的地的内容目的地。例如,在列表详情视图中,项目详情窗格可能包含会导航至替换项目详情的内容的界面元素。

如需实现此类子导航,详情窗格可以是具有自己的导航图(用于指定从详情窗格访问的目的地)的嵌套导航宿主:

视图

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

NavHost(navController = navController, startDestination = "listDetailRoute") {
  composable("listDetailRoute") {
    ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,
                    selectedItemId = selectedItemId)
  }
  navigation(startDestination = "itemSubdetail1", route = "itemSubDetail") {
    composable("itemSubdetail1") { ItemSubdetail1(/*...*/) }
    composable("itemSubdetail2") { ItemSubdetail2(/*...*/) }
    composable("itemSubdetail3") { ItemSubdetail3(/*...*/) }
  }
  /*...*/
}

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

如需了解详情,请参阅嵌套导航图使用 Compose 进行导航

保留状态

如需提供自适应内容目的地,应用必须在设备旋转/折叠或调整应用窗口大小时保留其状态。默认情况下,诸如此类的配置更改会重新创建应用的 activity、fragment、视图层次结构和可组合项。建议通过 ViewModelrememberSaveable 保存界面状态,二者在配置更改后保持不变。(请参阅保存界面状态状态和 Jetpack Compose)。

大小更改应该可以逆转,例如,当用户旋转设备然后再旋转回设备时。

自适应布局可以根据不同窗口大小显示不同的内容片段;因此,自适应布局通常需要保存与内容相关的其他状态,即使该状态不适用于当前窗口大小也是如此。例如,某个布局只在窗口宽度较大时才会有空间显示一个额外的滚动微件。如果大小调整事件导致窗口宽度变得太小,该微件会隐藏。当应用调整到以前的大小后,滚动微件将再次变为可见,并且应恢复到原始滚动位置。

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,该 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 的作用域。

提升状态

在 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?,
) { /*...*/ }