使用 Compose 在屏幕之间导航

1. 开始之前

到目前为止,您所开发的应用都只有一个屏幕。但是,您使用的许多应用可能有多个屏幕,您可以在这些屏幕之间导航。例如,“设置”应用将大量内容分布在不同的屏幕上。

在现代 Android 开发中,多屏幕应用是使用 Jetpack Navigation 组件创建的。Navigation Compose 组件允许您使用声明式方法轻松地在 Compose 中构建多屏幕应用,就像构建用户界面一样。此代码实验室介绍了 Navigation Compose 组件的基本知识,如何使 AppBar 响应,以及如何使用 intent 将数据从您的应用发送到另一个应用,同时在一个越来越复杂的应用中演示最佳实践。

先决条件

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

您将学到什么

  • 创建一个 NavHost 可组合项以定义应用中的路由和屏幕。
  • 使用 NavHostController 在屏幕之间导航。
  • 操作后退栈以导航到以前的屏幕。
  • 使用 intent 与其他应用共享数据。
  • 自定义 AppBar,包括标题和后退按钮。

您将构建什么

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

您需要什么

  • 最新版本的 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

如果您想查看此代码实验室的初始代码,请在 GitHub 上查看。

3. 应用演练

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

开始订单屏幕

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

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

该屏幕包含一个单列,其中包含图像和文本,以及三个自定义按钮以订购不同数量的纸杯蛋糕。自定义按钮由 SelectQuantityButton 可组合项实现,该可组合项也位于 StartOrderScreen.kt 中。

选择口味屏幕

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

可能的口味列表存储为 data.DataSource.kt 中的字符串资源 ID 列表。

选择取货日期屏幕

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

选择口味屏幕和选择取货日期屏幕都由同一个可组合项 SelectOptionScreen.kt 中的 SelectOptionScreen 表示。为什么要使用同一个可组合项?这些屏幕的布局完全相同!唯一的区别是数据,但您可以使用同一个可组合项来显示口味和取货日期屏幕。

订单摘要屏幕

选择取货日期后,应用会显示订单摘要屏幕,用户可以在其中查看并完成订单。

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

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

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

13bde33712e135a4.png

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

应用的屏幕将显示在 CupcakeApp 可组合项中。但是,在初始项目中,应用仅显示第一个屏幕。目前无法浏览应用的所有屏幕,但不用担心,这就是您来这里的目的!您将学习如何定义导航路由,设置 NavHost 可组合项以在屏幕(也称为目标)之间导航,执行 intent 以与系统 UI 组件(如共享屏幕)集成,以及使 AppBar 响应导航更改。

可重复使用的可组合项

在适当的情况下,本课程中的示例应用旨在实施最佳实践。Cupcake 应用也不例外。在ui.components 包中,您会看到一个名为 CommonUi.kt 的文件,其中包含 FormattedPriceLabel 可组合项。应用中的多个屏幕使用此可组合项以一致的方式格式化订单价格。与其重复使用具有相同格式和修饰符的相同 Text 可组合项,不如定义一次 FormattedPriceLabel,然后根据需要在其他屏幕中重复使用它。

口味和取货日期屏幕使用 SelectOptionScreen 可组合项,该可组合项也可重复使用。此可组合项采用名为 options 的参数,其类型为 List<String>,表示要显示的选项。选项显示在 Row 中,包含一个 RadioButton 可组合项和一个包含每个字符串的 Text 可组合项。Column 包含整个布局,还包含一个 Text 可组合项以显示格式化的价格、一个“取消”按钮和一个“下一步”按钮。

4. 定义路由并创建 NavHostController

导航组件的组成部分

导航组件主要包含三个部分

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

在此代码实验室中,您将重点关注 NavController 和 NavHost。在 NavHost 中,您将为 Cupcake 应用的 NavGraph 定义目标。

为应用中的目标定义路由

Compose 应用中导航的基本概念之一是路由。路由是对应于目标的字符串。此概念类似于 URL 的概念。就像不同的 URL 映射到网站上的不同页面一样,路由是映射到目标并充当其唯一标识符的字符串。目标通常是单个可组合项或对应于用户所看到内容的可组合项组。Cupcake 应用需要开始订单屏幕、口味屏幕、取货日期屏幕和订单摘要屏幕的目标。

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

您将首先定义 Cupcake 应用的四个路由。

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

添加枚举类以定义路由。

  1. CupcakeScreen.kt 中,在 CupcakeAppBar 可组合项上方,添加一个名为 CupcakeScreen 的枚举类。
enum class CupcakeScreen() {
    
}
  1. 向枚举类添加四个情况:StartFlavorPickupSummary
enum class CupcakeScreen() {
    Start,
    Flavor,
    Pickup,
    Summary
}

向应用添加 NavHost

NavHost 是一个可组合项,根据给定的路由显示其他可组合的目标。例如,如果路由是 Flavor,则 NavHost 将显示选择纸杯蛋糕口味的屏幕。如果路由是 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 参数,并将 CupcakeScreen.Start.name 传递给 startDestination 参数。将传递给 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 枚举常量的 name 属性。
  • content在这里,你可以调用一个可组合项,你想为给定的路由显示该可组合项。

你将为所有四个路由各调用一次 composable() 函数。

  1. 调用 composable() 函数,将 CupcakeScreen.Start.name 传递给 route
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,传递 Modifier.fillMaxSize().padding(dimensionResource(R.dimen.padding_medium))
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(),将 CupcakeScreen.Flavor.name 传递给 route
composable(route = CupcakeScreen.Flavor.name) {
    
}
  1. 在后置 lambda 中,获取对 LocalContext.current 的引用,并将其存储在一个名为 context 的变量中。Context 是一个抽象类,其实现由 Android 系统提供。它允许访问特定于应用程序的资源和类,以及用于应用程序级操作(如启动活动等)的上行调用。你可以使用此变量从视图模型中的资源 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. 口味屏幕需要在用户选择口味时显示和更新小计。将 uiState.price 传递给 subtotal 参数。
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price
    )
}
  1. 口味屏幕从应用程序的字符串资源中获取口味列表。使用 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 参数,传递一个 lambda 表达式,该表达式在视图模型上调用 setFlavor(),并将 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() 函数,将 CupcakeScreen.Pickup.name 传递给 route 参数。
composable(route = CupcakeScreen.Pickup.name) {
    
}
  1. 在后置 lambda 中,调用 SelectOptionScreen 可组合项,并将 uiState.price 传递给 subtotal(如前所述)。将 uiState.pickupOptions 传递给 options 参数,并将调用 viewModel 上的 setDate() 的 lambda 表达式传递给 onSelectionChanged 参数。对于 modifier 参数,传递 Modifier.fillMaxHeight().
SelectOptionScreen(
    subtotal = uiState.price,
    options = uiState.pickupOptions,
    onSelectionChanged = { viewModel.setDate(it) },
    modifier = Modifier.fillMaxHeight()
)
  1. 再调用一次 composable(),将 CupcakeScreen.Summary.name 传递给 route
composable(route = CupcakeScreen.Summary.name) {
    
}
  1. 在后置 lambda 中,调用 OrderSummaryScreen() 可组合项,将 uiState 变量传递给 orderUiState 参数。对于 modifier 参数,传递 Modifier.fillMaxHeight().
import com.example.cupcake.ui.OrderSummaryScreen

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

设置 NavHost 就完成了。在下一节中,你将使你的应用程序更改路由并在用户点击每个按钮时在屏幕之间导航。

5. 在路由之间导航

既然你已经定义了你的路由并在 NavHost 中将其映射到可组合项,那么现在该在屏幕之间导航了。NavHostController(来自调用 rememberNavController()navController 属性)负责在路由之间导航。但是,请注意,此属性是在 CupcakeApp 可组合项中定义的。你需要一种方法来从应用程序中的不同屏幕访问它。

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

虽然这种方法有效,但它并不是构建应用程序的理想方法。使用 NavHost 处理应用程序导航的一个好处是,导航逻辑**与单个 UI 分开保持**。此选项避免了将 navController 作为参数传递的一些主要缺点。

  • 导航逻辑集中在一个地方,这可以使你的代码更易于维护,并通过避免意外地赋予各个屏幕在你的应用程序中随意导航的权限来防止错误。
  • 在需要在不同表单因子(如纵向模式手机、折叠手机或大屏幕平板电脑)上工作的应用程序中,按钮可能会也可能不会触发导航,具体取决于应用程序的布局。各个屏幕应该是独立的,并且不需要了解应用程序中的其他屏幕。

相反,我们的方法是将函数类型传递给每个可组合项,以确定用户点击按钮时应该发生什么。这样,可组合项及其任何子可组合项都可以决定何时调用该函数。但是,导航逻辑不会暴露给应用程序中的各个屏幕。所有导航行为都在 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

每对中的每个项目都通过 first 属性或 second 属性访问。在 StartOrderScreen 可组合项的 quantityOptions 参数的情况下,第一个 Int 是要显示在每个按钮上的字符串的资源 ID。第二个 Int 是纸杯蛋糕的实际数量。

在调用 onNextButtonClicked() 函数时,我们将传递所选对的 second 属性。

  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.ktSelectOptionScreen 可组合项的 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. onCancelButtonClicked 传递给取消按钮的 onClick 参数。
OutlinedButton(
    modifier = Modifier.weight(1f),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}
  1. onNextButtonClicked 传递给下一个按钮的 onClick 参数。
Button(
    modifier = Modifier.weight(1f),
    enabled = selectedValue.isNotEmpty(),
    onClick = onNextButtonClicked
) {
    Text(stringResource(R.string.next))
}

向 SummaryScreen 添加按钮处理程序

最后,为摘要屏幕上的“取消”和“发送”按钮添加按钮处理程序函数。

  1. SummaryScreen.kt 中的 OrderSummaryScreen 可组合项中,添加一个名为 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. onSendButtonClicked 传递给“发送”按钮的 onClick 参数。传递 newOrderorderSummary,这两个变量在 OrderSummaryScreen 中前面定义过。这些字符串包含用户可以与其他应用程序共享的实际数据。
Button(
    modifier = Modifier.fillMaxWidth(),
    onClick = { onSendButtonClicked(newOrder, orderSummary) }
) {
    Text(stringResource(R.string.send))
}
  1. onCancelButtonClicked 传递给“取消”按钮的 onClick 参数。
OutlinedButton(
    modifier = Modifier.fillMaxWidth(),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}

要导航到另一个路由,只需在 NavHostController 的实例上调用 navigate() 方法。

fc8aae3911a6a25d.png

navigate 方法接受一个参数:一个 String,对应于在 NavHost 中定义的路由。如果该路由与 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(),将 CupcakeScreen.Flavor.name 作为 route 传入。
onNextButtonClicked = {
    viewModel.setQuantity(it)
    navController.navigate(CupcakeScreen.Flavor.name)
}
  1. 对于口味屏幕上的 onNextButtonClicked 参数,只需传入一个调用 navigate() 的 lambda,并将 CupcakeScreen.Pickup.name 作为 route 传入。
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,您将在后面实现它。
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. 对于提货屏幕上的 onNextButtonClicked 参数,传入一个调用 navigate() 的 lambda,并将 CupcakeScreen.Summary.name 作为 route 传入。
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。添加 subjectsummary 参数,这些参数将传递到 onSendButtonClicked 中,您将很快实现它们。
composable(route = CupcakeScreen.Summary.name) {
    OrderSummaryScreen(
        orderUiState = uiState,
        onCancelButtonClicked = {},
        onSendButtonClicked = { subject: String, summary: String ->

        },
        modifier = Modifier.fillMaxHeight()
    )
}

您现在应该能够浏览应用程序的每个屏幕。请注意,通过调用 navigate(),不仅屏幕会发生变化,而且实际上它还会放置在返回堆栈的顶部。此外,当您按下系统后退按钮时,您可以导航回上一个屏幕。

应用程序将每个屏幕堆叠在上一个屏幕的顶部,后退按钮(bade5f3ecb71e4a2.png)可以移除它们。从底部 startDestination 到刚刚显示的最顶层屏幕的屏幕历史称为返回堆栈

返回到启动屏幕

与系统后退按钮不同,取消按钮不会返回到上一个屏幕。相反,它应该弹出(删除)返回堆栈中的所有屏幕并返回到起始屏幕。

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

2f382e5eb319b4b8.png

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

  • route表示要导航回的目标的路由的字符串。
  • inclusive一个布尔值,如果为 true,则还会弹出(删除)指定的路由。如果为 false,popBackStack() 将删除起始目标之上(但不包括起始目标)的所有目标,使其成为用户可见的最顶层屏幕。

当用户按下任何屏幕上的取消按钮时,应用程序会重置视图模型中的状态并调用 popBackStack()。您将首先实现一个执行此操作的方法,然后将其作为所有三个带有取消按钮的屏幕上的适当参数传入。

  1. CupcakeApp() 函数之后,定义一个名为 cancelOrderAndNavigateToStart() 的私有函数。
private fun cancelOrderAndNavigateToStart() {
}
  1. 添加两个参数:类型为 OrderViewModelviewModel 和类型为 NavHostControllernavController
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
}
  1. 在函数体中,在 viewModel 上调用 resetOrder()
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
}
  1. navController 上调用 popBackStack(),将 CupcakeScreen.Start.name 作为 route 传入,将 false 作为 inclusive 传入。
private fun cancelOrderAndNavigateToStart(
    viewModel: OrderViewModel,
    navController: NavHostController
) {
    viewModel.resetOrder()
    navController.popBackStack(CupcakeScreen.Start.name, inclusive = false)
}
  1. CupcakeApp() 可组合项中,将 cancelOrderAndNavigateToStart 作为两个 SelectOptionScreen 可组合项和 OrderSummaryScreen 可组合项的 onCancelButtonClicked 参数传入。
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. 运行您的应用程序并测试按下任何屏幕上的取消按钮是否会将用户导航回第一个屏幕。

6. 导航到另一个应用程序

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

此 UI 部分不属于 Cupcake 应用程序。实际上,它是 Android 操作系统提供的。系统 UI(例如共享屏幕)不会由您的 navController 调用。相反,您使用的是称为Intent的东西。

Intent 是对系统执行某些操作(通常是呈现新活动)的请求。有许多不同的 Intent,建议您参考文档以获取完整列表。但是,我们对名为 ACTION_SEND 的 Intent 感兴趣。您可以为此 Intent 提供一些数据(例如字符串),并为该数据呈现适当的共享操作。

设置 Intent 的基本流程如下

  1. 创建一个 Intent 对象并指定 Intent,例如 ACTION_SEND
  2. 指定与 Intent 一起发送的其他数据的类型。对于简单的文本,您可以使用 "text/plain",尽管还有其他类型可用,例如 "image/*""video/*"
  3. 通过调用 putExtra() 方法将任何其他数据(例如要共享的文本或图像)传递给 Intent。此 Intent 将接收两个额外参数:EXTRA_SUBJECTEXTRA_TEXT
  4. 调用上下文的 startActivity() 方法,传入从 Intent 创建的活动。

我们将引导您完成如何创建共享操作 Intent 的过程,但对于其他类型的 Intent,过程相同。对于未来的项目,建议您根据需要参考文档以了解特定类型的数据和必要的额外参数。

完成以下步骤以创建将纸杯蛋糕订单发送到另一个应用程序的 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(),将 subject 作为 EXTRA_SUBJECT 传入。
val intent = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_SUBJECT, subject)
}
  1. 调用 putExtra(),将 summary 作为 EXTRA_TEXT 传入。
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 创建一个活动。将 intent 作为第一个参数传入,并将 new_cupcake_order 字符串资源作为第二个参数传入。
context.startActivity(
    Intent.createChooser(
        intent,
        context.getString(R.string.new_cupcake_order)
    )
)
  1. CupcakeApp 可组合项中,在对 composable() 的调用(用于 CucpakeScreen.Summary.name)中,获取对 context 对象的引用,以便您可以将其传递给 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. 运行您的应用程序并浏览屏幕。

当您点击将订单发送到另一个应用程序时,您应该在底部工作表上看到共享操作(例如消息蓝牙),以及您作为额外参数提供的主题和摘要。

13bde33712e135a4.png

7. 使应用栏响应导航

即使您的应用程序可以正常运行并且可以导航到和离开每个屏幕,但在本 codelab 开头的屏幕截图中仍然缺少某些内容。应用栏不会自动响应导航。当应用程序导航到新路由时,标题不会更新,并且在适当情况下也不会在标题之前显示向上按钮

启动代码包含一个用于管理名为CupcakeAppBarAppBar的可组合函数。现在您已在应用中实现了导航,您可以使用来自返回栈的信息来显示正确的标题,并在适当的情况下显示“向上”按钮。CupcakeAppBar可组合函数应该知道当前屏幕,以便标题能够相应地更新。

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

enum class CupcakeScreen(@StringRes val title: Int) {
    Start,
    Flavor,
    Pickup,
    Summary
}
  1. 为每个枚举情况添加一个资源值,对应于每个屏幕的标题文本。对于Start屏幕使用app_name,对于Flavor屏幕使用choose_flavor,对于Pickup屏幕使用choose_pickup_date,对于Summary屏幕使用order_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. CupcakeAppBar可组合函数添加一个名为currentScreen、类型为CupcakeScreen的参数。
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变量下方,使用val创建一个名为currentScreen的变量,使其等于调用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中处理它们,并使用函数类型参数将导航逻辑与各个屏幕分离。您还学习了如何使用意图将数据发送到另一个应用程序,以及如何根据导航自定义应用栏。在接下来的单元中,您将继续使用这些技能,因为您将在几个其他复杂程度不断提高的多屏应用程序上工作。

了解更多