使用 Compose 实现多屏幕导航

1. 准备工作

到目前为止,您创建的应用仅包含一个屏幕。不过,您使用的许多应用可能都有多个屏幕,您可以在这些屏幕之间导航。例如,Settings 应用中有多页内容分布在不同的界面上。

现代 Android 开发中,使用 Jetpack Navigation 组件创建多界面应用。借助 Navigation Compose 组件,您可以使用声明性方法在 Compose 中轻松构建多屏幕应用,就像构建界面一样。此 Codelab 介绍了 Navigation Compose 组件的基础知识、如何使应用栏具备自适应能力,以及如何使用 intent 将数据从您的应用发送到另一个应用,同时还将演示在日益复杂的应用中使用的最佳实践。

前提条件

  • 熟悉 Kotlin 语言,包括函数类型、lambda 和作用域函数
  • 熟悉 Compose 中的基本 RowColumn 布局

学习内容

  • 创建 NavHost 可组合项以定义应用中的路线和屏幕。
  • 使用 NavHostController 在屏幕之间导航。
  • 操控返回堆栈,以切换到之前的屏幕。
  • 使用 intent 与其他应用共享数据。
  • 自定义应用栏,包括标题和返回按钮。

构建内容

  • 您将在多屏幕应用中实现导航功能。

所需条件

  • 最新版本的 Android Studio
  • 互联网连接,用于下载起始代码

2. 下载起始代码

首先,请下载起始代码:

或者,您也可以克隆该代码的 GitHub 代码库:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-cupcake.git
$ cd basic-android-kotlin-compose-training-cupcake
$ git checkout starter

如果您想查看此 Codelab 的起始代码,请前往 GitHub 查看。

3. 应用演示

Cupcake 应用与您到目前为止所使用过的应用略有不同。该应用不是在单个屏幕上显示所有内容,而是采用四个单独的屏幕,并且用户可以在订购纸杯蛋糕时在各个屏幕之间切换。如果您运行应用,您将无法查看任何内容,也无法在这些屏幕之间进行导航,因为 navigation 组件尚未添加到应用代码中。不过,您仍可以检查每个屏幕的可组合项预览,并将它们与下方的最终应用屏幕配对。

Start Order 屏幕

第一个屏幕向用户显示三个按钮,这些按钮对应于要订购的纸杯蛋糕数量。

在代码中,这由 StartOrderScreen.kt 中的 StartOrderScreen 可组合项表示。

该屏幕包含一个列(包含图片和文字)以及三个用于订购不同数量的纸杯蛋糕的自定义按钮。自定义按钮由同样位于 StartOrderScreen.kt 中的 SelectQuantityButton 可组合项实现。

Choose Flavor 屏幕

选择数量后,应用会提示用户选择纸杯蛋糕的口味。应用使用单选按钮来显示不同的选项。用户可以从多种可选口味中选择一种口味。

可选口味列表以字符串资源 ID 列表的形式存储在 data.DataSource.kt 中。

Choose Pickup Date 屏幕

用户选择口味后,应用会向用户显示另一些单选按钮,用于选择自提日期。自提选项来自 OrderViewModel 中的 pickupOptions() 函数返回的列表。

Choose Flavor 屏幕和 Choose Pick-Date 屏幕均由 SelectOptionScreen.kt 中的相同可组合项 SelectOptionScreen 表示。为什么要使用相同的可组合项?因为这些屏幕的布局完全相同!唯一的区别在于数据,但您可以使用相同的可组合项来同时显示口味屏幕和自提日期屏幕。

Order Summary 屏幕

用户选择自提日期后,应用会显示 Order Summary 屏幕,用户可以在其中检查和完成订单。

此屏幕由 SummaryScreen.kt 中的 OrderSummaryScreen 可组合项实现。

布局包含一个 Column(包含订单的所有信息)、一个 Text 可组合项(用于显示小计),以及用于将订单发送到其他应用或取消订单并返回第一个屏幕的多个按钮。

如果用户选择将订单发送到其他应用,Cupcake 应用会显示 Android ShareSheet,其中显示了不同的分享选项。

a32e016a6ccbf427.png

应用的当前状态存储在 data.OrderUiState.kt 中。OrderUiState 数据类包含用于存储在每个屏幕中为用户提供的可用选项的属性。

应用的屏幕将显示在 CupcakeApp 可组合项中。不过,在起始项目中,应用仅显示第一个屏幕。目前还无法在应用的所有屏幕之间导航,不过别担心,本课程的目的就是实现这种导航!您将学习如何定义导航路线,设置用于在屏幕(也称为目标页面)之间导航的 NavHost 可组合项、执行 intent 以与共享屏幕等系统界面组件集成,并让应用栏能够响应导航更改。

可重复使用的可组合项

在适当的情况下,本课程中的示例应用可实现最佳实践。Cupcake 应用也不例外。在 ui.components 软件包中,您会看到一个名为 CommonUi.kt 的文件,其中包含一个 FormattedPriceLabel 可组合项。应用中的多个屏幕使用此可组合项来统一设置订单价格的格式。您无需重复定义具有相同格式和修饰符的相同 Text 可组合项,而是只需定义一次 FormattedPriceLabel,然后根据需要将其重复用于其他屏幕。

同样,口味屏幕和自提日期屏幕也使用可重复使用的 SelectOptionScreen 可组合项。此可组合项接受名为 options 且类型为 List<String> 的参数,该参数表示要显示的选项。这些选项显示在 Row 中,后者由一个 RadioButton 可组合项和一个包含各个字符串的 Text 可组合项组成。整个布局周围有一个 Column,还包含一个用于显示格式化价格的 Text 可组合项、一个 Cancel 按钮和一个 Next 按钮。

4. 定义路线并创建 NavHostController

导航组件的组成部分

Navigation 组件有三个主要部分:

  • NavController:负责在目标页面(即应用中的屏幕)之间导航。
  • NavGraph:用于映射要导航到的可组合项目标页面。
  • NavHost:此可组合项充当容器,用于显示 NavGraph 的当前目标页面。

在此 Codelab 中,您将重点关注 NavController 和 NavHost。在 NavHost 中,您将定义 Cupcake 应用的 NavGraph 的目标页面。

在应用中为目标页面定义路线

在 Compose 应用中,导航的一个基本概念就是路线。路线是与目标页面对应的字符串。这类似于网址的概念。就像不同网址映射到网站上的不同页面一样,路线是可映射至目标页面并作为其唯一标识符的字符串。目标页面通常是与用户看到的内容相对应的单个可组合项或一组可组合项。Cupcake 应用需要显示“Start Order”屏幕、“Flavor”屏幕、“Pickup Date”屏幕和“Order Summary”屏幕的目标页面。

应用中的屏幕数量有限,因此路线数量也是有限的。您可以使用枚举类来定义应用的路线。Kotlin 中的枚举类具有一个名称属性,该属性会返回具有属性名称的字符串。

首先,定义 Cupcake 应用的四个路线。

  • Start:从三个按钮之一选择纸杯蛋糕的数量。
  • Flavor:从选项列表中选择口味。
  • Pickup:从选项列表中选择自提日期。
  • Summary:检查所选内容,然后发送或取消订单。

添加一个枚举类来定义路线。

  1. CupcakeScreen.kt 中的 CupcakeAppBar 可组合项上方,添加一个名称为 CupcakeScreen 的枚举类。
enum class CupcakeScreen() {

}
  1. 在枚举类中添加四种情况:StartFlavorPickupSummary
enum class CupcakeScreen() {
    Start,
    Flavor,
    Pickup,
    Summary
}

为应用添加 NavHost

NavHost 是一个可组合项,用于根据给定路线来显示其他可组合项目标页面。例如,如果路线为 FlavorNavHost 会显示用于选择纸杯蛋糕口味的屏幕。如果路线为 Summary,则应用会显示摘要屏幕。

NavHost 的语法与任何其他可组合项的语法一样。

fae7688d6dd53de9.png

有两个参数值得注意:

  • navControllerNavHostController 类的实例。您可以使用此对象在屏幕之间导航,例如,通过调用 navigate() 方法导航到另一个目标页面。您可以通过从可组合函数调用 rememberNavController() 来获取 NavHostController
  • startDestination:此字符串路线用于定义应用首次显示 NavHost 时默认显示的目标页面。对于 Cupcake 应用,这应该是 Start 路线。

与其他可组合项一样,NavHost 也接受 modifier 参数。

您将向 CupcakeScreen.kt 中的 CupcakeApp 可组合项添加一个 NavHost。首先,您需要建立一个到导航控制器的引用。您现在添加的 NavHost 以及将在后续步骤中添加的 AppBar 中都可以使用该导航控制器。因此,您应在 CupcakeApp() 可组合项中声明该变量。

  1. 打开 CupcakeScreen.kt
  2. Scaffold 中的 uiState 变量下方,添加一个 NavHost 可组合项。
import androidx.navigation.compose.NavHost

Scaffold(
    ...
) { innerPadding ->
    val uiState by viewModel.uiState.collectAsState()

    NavHost()
}
  1. navController 参数传入 navController 变量,并为 startDestination 参数传入 CupcakeScreen.Start.name。传递之前传入到修饰符参数的 CupcakeApp() 中的修饰符。为最后一个参数传入空的尾随 lambda。
import androidx.compose.foundation.layout.padding

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {

}

NavHost 中处理路线

与其他可组合项一样,NavHost 接受函数类型作为其内容。

f67974b7fb3f0377.png

NavHost 的内容函数中,调用 composable() 函数。composable() 函数有两个必需参数。

  • route:与路线名称对应的字符串。这可以是任何唯一的字符串。您将使用 CupcakeScreen 枚举的常量的名称属性。
  • content:您可以在此处调用要为特定路线显示的可组合项。

您将针对这四个路线分别调用一次 composable() 函数。

  1. 调用 composable() 函数,为 route 传入 CupcakeScreen.Start.name
import androidx.navigation.compose.composable

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = CupcakeScreen.Start.name) {

    }
}
  1. 在尾随 lambda 中,调用 StartOrderScreen 可组合项,并为 quantityOptions 属性传入 quantityOptions。对于 Modifier.fillMaxSize().padding(dimensionResource(R.dimen.padding_medium)) 中的 modifier 卡券
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.res.dimensionResource
import com.example.cupcake.ui.StartOrderScreen
import com.example.cupcake.data.DataSource

NavHost(
    navController = navController,
    startDestination = CupcakeScreen.Start.name,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = CupcakeScreen.Start.name) {
        StartOrderScreen(
            quantityOptions = DataSource.quantityOptions,
            modifier = Modifier
                .fillMaxSize()
                .padding(dimensionResource(R.dimen.padding_medium))
        )
    }
}
  1. 在对 composable() 的第一次调用下方,再次调用 composable(),为 route 传入 CupcakeScreen.Flavor.name
composable(route = CupcakeScreen.Flavor.name) {

}
  1. 在尾随 lambda 中,获取对 LocalContext.current 的引用,并将其存储到名称为 context 的变量中。Context 是一个抽象类,其实现由 Android 系统提供。它允许访问应用专用资源和类,以及向上调用应用级别的操作(例如启动 activity 等)。您可以使用此变量从视图模型中的资源 ID 列表中获取字符串以显示口味列表。
import androidx.compose.ui.platform.LocalContext

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
}
  1. 调用 SelectOptionScreen 可组合项。
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(

    )
}
  1. 当用户选择口味时,Flavor 屏幕需要显示和更新小计。为 subtotal 参数传入 uiState.price
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price
    )
}
  1. Flavor 屏幕从应用的字符串资源中获取口味列表。使用 map() 函数并调用 context.resources.getString(id) 将资源 ID 列表转换为字符串列表。
import com.example.cupcake.ui.SelectOptionScreen

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        options = DataSource.flavors.map { id -> context.resources.getString(id) }
    )
}
  1. 对于 onSelectionChanged 参数,请传入一个对视图模型调用 setFlavor() 的 lambda 表达式,并传入 it(传到 onSelectionChanged() 中的实参)。对于 modifier 参数,传入 Modifier.fillMaxHeight().
import androidx.compose.foundation.layout.fillMaxHeight
import com.example.cupcake.data.DataSource.flavors

composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}

自提日期界面类似于口味界面。唯一的区别就是传入 SelectOptionScreen 可组合项的数据。

  1. 再次调用 composable() 函数,为 route 参数传入 CupcakeScreen.Pickup.name
composable(route = CupcakeScreen.Pickup.name) {

}
  1. 在尾随 lambda 中,调用 SelectOptionScreen 可组合项,并像之前一样为 subtotal 传入 uiState.price。为 options 参数传入 uiState.pickupOptions,并为 onSelectionChanged 参数传入对 viewModel 调用 setDate() 的 lambda 表达式。 对于 modifier 参数,传入 Modifier.fillMaxHeight().
SelectOptionScreen(
    subtotal = uiState.price,
    options = uiState.pickupOptions,
    onSelectionChanged = { viewModel.setDate(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. 再次调用 composable(),并为 route 传入 CupcakeScreen.Summary.name
composable(route = CupcakeScreen.Summary.name) {

}
  1. 在尾随 lambda 中,调用 OrderSummaryScreen() 可组合项,并为 orderUiState 参数传入 uiState 变量。 对于 modifier 参数,传入 Modifier.fillMaxHeight().
import com.example.cupcake.ui.OrderSummaryScreen

composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        modifier = Modifier.fillMaxHeight()
    )
}

以上就是设置 NavHost 的全部内容。在下一部分中,您将让应用更改路线,并在用户点按每个按钮时在不同屏幕之间导航。

5. 在多个路线之间导航

现在,您已经定义了路线并将其映射到 NavHost 中的可组合项,接下来可以在不同屏幕之间导航了。NavHostControllerrememberNavController() 调用中的 navController 属性)负责在多个路线之间导航。但请注意,此属性是在 CupcakeApp 可组合项中定义的。您需要从应用中的不同屏幕访问该属性。

很简单,对吧?只需将 navController 作为参数传递给每个可组合项即可。

尽管这种方法很有效,但这并不是构建应用的理想方式。使用 NavHost 处理应用导航的一项优势就是,导航逻辑将各个界面相隔离。此方法可避免将 navController 作为参数传递时的一些主要缺点。

  • 导航逻辑会保存在一个集中位置,这样可以避免意外允许各个屏幕在应用中随意导航,从而让您的代码更易于维护并预防 bug。
  • 在需要处理不同外形规格(例如竖屏模式手机、可折叠手机或大屏平板电脑)的应用中,按钮可能会触发导航,也可能不会触发导航,具体取决于应用的布局。各个屏幕应保持独立,无需了解应用中其他屏幕的信息。

而我们的方法是为每个可组合项传入一个函数类型,以便确定在用户点击该按钮时应当发生什么。这样,可组合项及其任何子可组合项就可以确定何时调用函数。不过,导航逻辑不会向应用中的各个屏幕公开。所有导航行为都在 NavHost 中处理。

StartOrderScreen 添加按钮处理程序

首先添加一个函数类型参数。当在第一个屏幕上点按某个数量按钮时,系统会调用该函数类型参数。此函数会传入 StartOrderScreen 可组合项,并负责更新视图模型以及导航到下一个屏幕。

  1. 打开 StartOrderScreen.kt
  2. quantityOptions 参数下方以及修饰符参数之前,添加一个名称为 onNextButtonClicked 且类型为 () -> Unit 的参数。
@Composable
fun StartOrderScreen(
    quantityOptions: List<Pair<Int, Int>>,
    onNextButtonClicked: () -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. 现在,StartOrderScreen 可组合项需要 onNextButtonClicked 的值,请找到 StartOrderPreview 并将空的 lambda 正文传递给 onNextButtonClicked 参数。
@Preview
@Composable
fun StartOrderPreview() {
    CupcakeTheme {
        StartOrderScreen(
            quantityOptions = DataSource.quantityOptions,
            onNextButtonClicked = {},
            modifier = Modifier
                .fillMaxSize()
                .padding(dimensionResource(R.dimen.padding_medium))
        )
    }
}

每个按钮对应不同数量的纸杯蛋糕。您需要此信息,以便为 onNextButtonClicked 传入的函数会相应地更新视图模型。

  1. 修改 onNextButtonClicked 参数的类型以接受 Int 参数。
onNextButtonClicked: (Int) -> Unit,

如需在调用 onNextButtonClicked() 时传入 Int,请查看 quantityOptions 参数的类型。

类型为 List<Pair<Int, Int>>Pair<Int, Int> 列表。您可能不熟悉 Pair 类型,但顾名思义,该类型就是一对值。Pair 接受两个通用类型参数。在本例中,两者均为 Int 类型。

8326701a77706258.png

Pair 对中的每一项均由第一个属性或第二个属性访问。对于 StartOrderScreen 可组合项的 quantityOptions 参数,第一个 Int 是每个按钮上显示的字符串的资源 ID。第二个 Int 是纸杯蛋糕的实际数量。

当调用 onNextButtonClicked() 函数时,我们会传递所选 Pair 对的第二个属性。

  1. SelectQuantityButtononClick 参数查找空 lambda 表达式。
quantityOptions.forEach { item ->
    SelectQuantityButton(
        labelResourceId = item.first,
        onClick = {}
    )
}
  1. 在 lambda 表达式中,调用 onNextButtonClicked,并传入 item.second(纸杯蛋糕的数量)。
quantityOptions.forEach { item ->
    SelectQuantityButton(
        labelResourceId = item.first,
        onClick = { onNextButtonClicked(item.second) }
    )
}

为 SelectOptionScreen 添加按钮处理程序

  1. SelectOptionScreen.kt 中的 SelectOptionScreen 可组合项的 onSelectionChanged 参数下,添加一个名为 onCancelButtonClicked、类型为 () -> Unit 且默认值为 {} 的参数。
@Composable
fun SelectOptionScreen(
    subtotal: String,
    options: List<String>,
    onSelectionChanged: (String) -> Unit = {},
    onCancelButtonClicked: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. onCancelButtonClicked 参数下,添加另一个类型为 () -> Unit、名为 onNextButtonClicked 且默认值为 {} 的参数。
@Composable
fun SelectOptionScreen(
    subtotal: String,
    options: List<String>,
    onSelectionChanged: (String) -> Unit = {},
    onCancelButtonClicked: () -> Unit = {},
    onNextButtonClicked: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. 为 Cancel 按钮的 onClick 参数传入 onCancelButtonClicked
OutlinedButton(
    modifier = Modifier.weight(1f),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}
  1. 为 Next 按钮的 onClick 参数传入 onNextButtonClicked
Button(
    modifier = Modifier.weight(1f),
    enabled = selectedValue.isNotEmpty(),
    onClick = onNextButtonClicked
) {
    Text(stringResource(R.string.next))
}

为 SummaryScreen 添加按钮处理程序

最后,为 Summary 屏幕上的 CancelSend 按钮添加按钮处理程序函数。

  1. SummaryScreen.ktOrderSummaryScreen 可组合项中,添加一个名称为 onCancelButtonClicked 且类型为 () -> Unit 的参数。
@Composable
fun OrderSummaryScreen(
    orderUiState: OrderUiState,
    onCancelButtonClicked: () -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. 添加另一个类型为 (String, String) -> Unit 的参数,并将其命名为 onSendButtonClicked
@Composable
fun OrderSummaryScreen(
    orderUiState: OrderUiState,
    onCancelButtonClicked: () -> Unit,
    onSendButtonClicked: (String, String) -> Unit,
    modifier: Modifier = Modifier
){
    ...
}
  1. OrderSummaryScreen 可组合项现在需要 onSendButtonClickedonCancelButtonClicked 的值。找到 OrderSummaryPreview,将包含两个 String 参数的空 lambda 正文传递给 onSendButtonClicked,然后将一个空 lambda 正文传递给 onCancelButtonClicked 参数。
@Preview
@Composable
fun OrderSummaryPreview() {
   CupcakeTheme {
       OrderSummaryScreen(
           orderUiState = OrderUiState(0, "Test", "Test", "$300.00"),
           onSendButtonClicked = { subject: String, summary: String -> },
           onCancelButtonClicked = {},
           modifier = Modifier.fillMaxHeight()
       )
   }
}
  1. Send 按钮的 onClick 参数传递 onSendButtonClicked。传入 newOrderorderSummary,这是之前在 OrderSummaryScreen 中定义的两个变量。这些字符串由用户可以与其他应用共享的实际数据组成。
Button(
    modifier = Modifier.fillMaxWidth(),
    onClick = { onSendButtonClicked(newOrder, orderSummary) }
) {
    Text(stringResource(R.string.send))
}
  1. Cancel 按钮的 onClick 参数传递 onCancelButtonClicked
OutlinedButton(
    modifier = Modifier.fillMaxWidth(),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}

如需导航到其他路线,只需在 NavHostController 实例上调用 navigate() 方法即可。

fc8aae3911a6a25d.png

navigation 方法仅接受一个参数:与 NavHost 中定义的路线相对应的 String。如果路线与 NavHost 中的 composable() 任一调用匹配,应用便会转到该屏幕。

您将传入在用户按下 StartFlavorPickup 屏幕上的按钮时调用 navigate() 的函数。

  1. CupcakeScreen.kt 中,找到起始屏幕的 composable() 调用。为 onNextButtonClicked 参数传入 lambda 表达式。
StartOrderScreen(
    quantityOptions = DataSource.quantityOptions,
    onNextButtonClicked = {
    }
)

还记得传入此函数用于表示纸杯蛋糕数量的 Int 属性吗?在导航到下一个屏幕之前,您应当更新视图模型,以便应用显示正确的小计。

  1. viewModel 调用 setQuantity,并传入 it
onNextButtonClicked = {
    viewModel.setQuantity(it)
}
  1. navController 调用 navigate(),并传入 routeCupcakeScreen.Flavor.name
onNextButtonClicked = {
    viewModel.setQuantity(it)
    navController.navigate(CupcakeScreen.Flavor.name)
}
  1. 对于 Flavor 屏幕上的 onNextButtonClicked 参数,只需传入调用 navigate() 的 lambda,并为 route 传入 CupcakeScreen.Pickup.name
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
  1. onCancelButtonClicked 传入一个空的 lambda,您接下来要实现该 lambda。
SelectOptionScreen(
    subtotal = uiState.price,
    onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
    onCancelButtonClicked = {},
    options = DataSource.flavors.map { id -> context.resources.getString(id) },
    onSelectionChanged = { viewModel.setFlavor(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. 对于 Pickup 屏幕上的 onNextButtonClicked 参数,请传入调用 navigate() 的 lambda,并为 route 传入 CupcakeScreen.Summary.name
composable(route = CupcakeScreen.Pickup.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
        options = uiState.pickupOptions,
        onSelectionChanged = { viewModel.setDate(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
  1. 同样,为 onCancelButtonClicked() 传入一个空的 lambda。
SelectOptionScreen(
    subtotal = uiState.price,
    onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
    onCancelButtonClicked = {},
    options = uiState.pickupOptions,
    onSelectionChanged = { viewModel.setDate(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. 对于 OrderSummaryScreen,请为 onCancelButtonClickedonSendButtonClicked 传入空的 lambda。为传入到 onSendButtonClicked 中的 subjectsummary 添加参数,您很快就将实现这些参数。
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {},
        onSendButtonClicked = { subject: String, summary: String ->

        },
        modifier = Modifier.fillMaxHeight()
    )
}

您现在应当能够在应用的各个屏幕之间导航。请注意,通过调用 navigate(),屏幕不仅会发生变化,而且会实际放置在返回堆栈之上。此外,当您点按系统返回按钮时,即可返回到上一个界面。

应用会将每个界面堆叠在上一个界面上,而返回按钮 (bade5f3ecb71e4a2.png) 可以移除这些界面。从底部 startDestination 到刚才显示的最顶部的屏幕的历史记录称为返回堆栈。

跳转至起始屏幕

与系统返回按钮不同,Cancel 按钮不会返回上一个屏幕。而是跳转移除返回堆栈中的所有屏幕,并返回起始屏幕。

您可以通过调用 popBackStack() 方法来实现此目的。

2f382e5eb319b4b8.png

popBackStack() 方法有两个必需参数。

  • route:此字符串表示您希望返回到的目标页面的路线。
  • inclusive:这是一个布尔值,如果为 true,还会移除指定路线。如果为 false,popBackStack() 将移除起始目标页面之上的所有目标页面,但不包含该起始目标页面,并仅留下该起始目标页面作为最顶层的屏幕显示给用户。

当用户在任何屏幕上点按 Cancel 按钮时,应用会重置视图模型中的状态并调用 popBackStack()。首先,您将实现一个方法来执行此操作,然后为包含 Cancel 按钮的所有三个屏幕的相应参数传入该方法。

  1. CupcakeApp() 函数后面,定义一个名称为 cancelOrderAndNavigateToStart() 的私有函数。
private fun cancelOrderAndNavigateToStart() {
}
  1. 添加两个参数:OrderViewModel 类型的 viewModelNavHostController 类型的 navController
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
}
  1. 在函数主体中,对 viewModel 调用 resetOrder()
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
}
  1. navController 调用 popBackStack(),为 route 传入 CupcakeScreen.Start.name 并为 inclusive 传入 false
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
    navController.popBackStack(CupcakeScreen.Start.name, inclusive = false)
}
  1. CupcakeApp() 可组合项中,为两个 SelectOptionScreen 可组合项和 OrderSummaryScreen 可组合项的 onCancelButtonClicked 参数传入 cancelOrderAndNavigateToStart
composable(route = CupcakeScreen.Start.name) {
    StartOrderScreen(
        quantityOptions = DataSource.quantityOptions,
        onNextButtonClicked = {
            viewModel.setQuantity(it)
            navController.navigate(CupcakeScreen.Flavor.name)
        },
        modifier = Modifier
            .fillMaxSize()
            .padding(dimensionResource(R.dimen.padding_medium))
    )
}
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Pickup.name) },
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        options = DataSource.flavors.map { id -> context.resources.getString(id) },
        onSelectionChanged = { viewModel.setFlavor(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
composable(route = CupcakeScreen.Pickup.name) {
    SelectOptionScreen(
        subtotal = uiState.price,
        onNextButtonClicked = { navController.navigate(CupcakeScreen.Summary.name) },
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        options = uiState.pickupOptions,
        onSelectionChanged = { viewModel.setDate(it) },
        modifier = Modifier.fillMaxHeight()
    )
}
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {
            cancelOrderAndNavigateToStart(viewModel, navController)
        },
        onSendButtonClicked = { subject: String, summary: String ->

        },
        modifier = Modifier.fillMaxHeight()
   )
}
  1. 运行应用,并测试点按任何屏幕上的 Cancel 按钮是否会返回第一个屏幕。

6. 导航到其他应用

到目前为止,您已经学习了如何导航到应用中的不同屏幕,以及如何返回主屏幕。在 Cupcake 应用中实现导航只剩最后一步了。在订单摘要屏幕上,用户可以将其订单发送到其他应用。此选项会打开一个 ShareSheet(覆盖屏幕底部部分的界面组件),其中会显示分享选项。

此部分界面不属于 Cupcake 应用。事实上,它是由 Android 操作系统提供的。您的 navController 不会调用系统界面(例如分享屏幕)。作为替代方案,您可以使用 intent

intent 将请求系统执行某项操作,通常用于呈现新的 activity。有许多不同的 intent,建议您参阅相关文档,查看完整列表。不过,我们感兴趣的是 ACTION_SEND。您可以向此 intent 提供某些数据(例如字符串),并为这些数据提供适当的分享操作。

设置 intent 的基本过程如下:

  1. 创建一个 intent 对象并指定 intent,例如 ACTION_SEND
  2. 指定随 intent 一同发送的其他数据类型。对于简单的一段文本,您可以使用 "text/plain",但也可以使用其他类型,例如 "image/*""video/*"
  3. 通过调用 putExtra() 方法,向 intent 传递任何其他数据,例如要分享的文本或图片。此 intent 将接受两个 extra:EXTRA_SUBJECTEXTRA_TEXT
  4. 调用上下文的 startActivity() 方法,并传入从 intent 创建的 activity。

我们将向您介绍如何创建分享操作 intent,但对于其他类型的 intent,流程是相同的。在后续项目中,建议您参阅特定数据类型和所需 extra 的相关文档。

如需创建 intent 以将纸杯蛋糕订单发送给其他应用,请完成以下步骤:

  1. CupcakeScreen.kt 中的 CupcakeApp 可组合项下方,创建一个名称为 shareOrder() 的私有函数。
private fun shareOrder()
  1. 添加一个名称为 context 且类型为 Context 的参数。
import android.content.Context

private fun shareOrder(context: Context) {
}
  1. 添加两个 String 参数:subjectsummary。这些字符串将显示在分享操作工作表中。
private fun shareOrder(context: Context, subject: String, summary: String) {
}
  1. 在函数主体中,创建一个名称为 intent 的 intent,并将 Intent.ACTION_SEND 作为参数传递。
import android.content.Intent

val intent = Intent(Intent.ACTION_SEND)

由于您只需配置此 Intent 对象一次,因此,您可以使用在之前的 Codelab 中学到的 apply() 函数,让接下来的几行代码更加简洁。

  1. 对新创建的 intent 调用 apply() 并传入 lambda 表达式。
val intent = Intent(Intent.ACTION_SEND).apply {

}
  1. 在 lambda 主体中,将类型设置为 "text/plain"。由于您是在传递到 apply() 的函数中执行此操作,因此无需引用对象的标识符 intent
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
}
  1. 调用 putExtra(),并传入 EXTRA_SUBJECT 的 subject。
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_SUBJECT, subject)
}
  1. 调用 putExtra(),并传入 EXTRA_TEXT 的 summary。
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_SUBJECT, subject)
    putExtra(Intent.EXTRA_TEXT, summary)
}
  1. 调用上下文的 startActivity() 方法。
context.startActivity(

)
  1. 在传入 startActivity() 的 lambda 中,通过调用类方法 createChooser() 从 intent 中创建一个 activity。为第一个参数和 new_cupcake_order 字符串资源传递 intent。
context.startActivity(
    Intent.createChooser(
        intent,
        context.getString(R.string.new_cupcake_order)
    )
)
  1. CupcakeApp 可组合项的 CucpakeScreen.Summary.namecomposable() 调用中,获取对上下文对象的引用,以便将其传递给 shareOrder() 函数。
composable(route = CupcakeScreen.Summary.name) {
    val context = LocalContext.current

    ...
}
  1. onSendButtonClicked() 的 lambda 主体中,调用 shareOrder(),并传入 contextsubjectsummary 作为参数。
onSendButtonClicked = { subject: String, summary: String ->
    shareOrder(context, subject = subject, summary = summary)
}
  1. 运行应用并在各个屏幕间导航。

点击 Send Order to Other App 时,您应当会看到底部动作条上的分享操作(例如 MessagingBluetooth)以及您以 extra 形式提供的主题和摘要。

a32e016a6ccbf427.png

7. 让应用栏响应导航

尽管您的应用可以正常运行,并且可以在各个屏幕之间导航,但在此 Codelab 最开始的屏幕截图中仍缺少一些内容。应用栏无法自动响应导航。当应用导航至新路线时,不会更新标题,也不会适时在标题前显示向上按钮

起始代码包含一个可组合项,用于管理名称为 CupcakeAppBarAppBar。现在,您已经在应用中实现了导航,接下来可以使用返回堆栈中的信息来显示正确的标题,并适时显示向上按钮。CupcakeAppBar 可组合项应了解当前屏幕,以便标题进行相应更新。

  1. CupcakeScreen.kt 中的 CupcakeScreen 枚举中,使用 @StringRes 注解添加类型为 Int 且名为 title 的参数。
import androidx.annotation.StringRes

enum class CupcakeScreen(@StringRes val title: Int) {
    Start,
    Flavor,
    Pickup,
    Summary
}
  1. 为每个枚举用例添加资源值,与各屏幕的标题文本相对应。将 app_name 用于 Start 屏幕,choose_flavor 用于 Flavor 屏幕,choose_pickup_date 用于 Pickup 屏幕,order_summary 用于 Summary 屏幕。
enum class CupcakeScreen(@StringRes val title: Int) {
    Start(title = R.string.app_name),
    Flavor(title = R.string.choose_flavor),
    Pickup(title = R.string.choose_pickup_date),
    Summary(title = R.string.order_summary)
}
  1. 将名为 currentScreen 且类型为 CupcakeScreen 的参数添加到 CupcakeAppBar 可组合函数中。
fun CupcakeAppBar(
    currentScreen: CupcakeScreen,
    canNavigateBack: Boolean,
    navigateUp: () -> Unit = {},
    modifier: Modifier = Modifier
)
  1. CupcakeAppBar 内,将硬编码应用名称替换为当前屏幕的标题,具体方法为将 currentScreen.title 传递给对 TopAppBar 标题参数的 stringResource() 的调用。
TopAppBar(
    title = { Text(stringResource(currentScreen.title)) },
    modifier = modifier,
    navigationIcon = {
        if (canNavigateBack) {
            IconButton(onClick = navigateUp) {
                Icon(
                    imageVector = Icons.Filled.ArrowBack,
                    contentDescription = stringResource(R.string.back_button)
                )
            }
        }
    }
)

仅当返回堆栈上有可组合项时才应显示向上按钮。如果应用在返回堆栈上没有任何屏幕(显示 StartOrderScreen),则不应显示向上按钮。如需检查这一点,您需要建立对返回堆栈的引用。

  1. CupcakeApp 可组合项中的 navController 变量下方,创建一个名称为 backStackEntry 的变量,并使用 by 委托调用 navControllercurrentBackStackEntryAsState() 方法。
import androidx.navigation.compose.currentBackStackEntryAsState

@Composable
fun CupcakeApp(
    viewModel: OrderViewModel = viewModel(),
    navController: NavHostController = rememberNavController()
){

    val backStackEntry by navController.currentBackStackEntryAsState()

    ...
}
  1. 将当前屏幕的标题转换为 CupcakeScreen 的值。在 backStackEntry 变量下方,使用名为 currentScreenval 创建一个变量,并将其设为等于 CupcakeScreenvalueOf() 类函数调用的结果,然后传入 backStackEntry 的目标页面的路线。使用 elvis 运算符提供 CupcakeScreen.Start.name 的默认值。
val currentScreen = CupcakeScreen.valueOf(
    backStackEntry?.destination?.route ?: CupcakeScreen.Start.name
)
  1. currentScreen 变量传递给 CupcakeAppBar 可组合项的同名参数。
CupcakeAppBar(
    currentScreen = currentScreen,
    canNavigateBack = false,
    navigateUp = {}
)

只要返回堆栈中的当前屏幕后面还有屏幕,系统就会显示向上按钮。您可以使用布尔表达式来确定是否应显示向上按钮:

  1. 对于 canNavigateBack 参数,请传入一个布尔表达式,用于检查 navControllerpreviousBackStackEntry 属性是否不等于 null。
canNavigateBack = navController.previousBackStackEntry != null,
  1. 如需实际返回上一个屏幕,请调用 navControllernavigateUp() 方法。
navigateUp = { navController.navigateUp() }
  1. 运行应用。

请注意,AppBar 标题现已更新以反映当前屏幕。当您导航到 StartOrderScreen 以外的界面时,其中应当会显示向上按钮,点按该按钮可返回到上一个界面。

3fd023516061f522.gif

8. 获取解决方案代码

如需下载完成后的 Codelab 代码,您可以使用以下 Git 命令:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-cupcake.git
$ cd basic-android-kotlin-compose-training-cupcake
$ git checkout navigation

或者,您也可以下载 ZIP 文件形式的代码库,将其解压缩并在 Android Studio 中打开。

如果您想查看此 Codelab 的解决方案代码,请前往 GitHub 查看。

9. 总结

祝贺您!您已经使用 Jetpack Navigation 组件从简单的单屏幕应用转变为复杂的多屏幕应用,支持在多个屏幕之间切换。您定义了路线,在 NavHost 中处理了路线,并使用函数类型参数将导航逻辑与各个屏幕相隔离。您还学习了如何使用 intent 将数据发送到其他应用,以及如何自定义应用栏以响应导航。在接下来的几个单元中,您将学习开发其他复杂程度不断增加的多屏幕应用,并继续运用这些技能。

了解详情