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

其他资源