使用 Compose 在不同屏幕之间导航

1. 开始之前

到目前为止,您一直在使用的应用都只包含一个屏幕。然而,您使用的许多应用可能都有多个屏幕,您可以在其中导航。例如,“设置”应用的内容就分布在多个不同的屏幕上。

在现代 Android 开发中,多屏幕应用是使用 Jetpack Navigation 组件创建的。Navigation Compose 组件允许您使用声明式方法轻松地在 Compose 中构建多屏幕应用,就像构建用户界面一样。本 Codelab 介绍 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

如果您想查看此 Codelab 的启动代码,请在 GitHub 上查看。

3. 应用概览

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

开始订购屏幕

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

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

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

选择口味屏幕

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

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

选择取货日期屏幕

选择口味后,应用会向用户显示另一系列单选按钮,用于选择取货日期。取货选项来自 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

Navigation 组件的组成部分

Navigation 组件主要包含以下三个部分:

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

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

定义应用中目标的路线

Compose 应用中导航的基本概念之一是路线(route)。路线是对应于目标的字符串。这个概念类似于 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 路线。

与G其他可组合项一样,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()modifier 传入 `modifier` 参数。为最后一个参数传入一个空的尾随 lambda 表达式。
import androidx.compose.foundation.layout.padding

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

}

在您的 NavHost 中处理路线

与G其他可组合项一样,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 系统提供。它允许访问应用程序特定的资源和类,以及用于应用程序级操作(例如启动 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. 口味屏幕需要在用户选择口味时显示并更新小计。将 uiState.price 传入 subtotal 参数。
composable(route = CupcakeScreen.Flavor.name) {
    val context = LocalContext.current
    SelectOptionScreen(
        subtotal = uiState.price
    )
}
  1. 口味屏幕从应用的字符串资源中获取口味列表。使用 map() 函数并将每个口味的 context.resources.getString(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 参数,并为 onSelectionChanged 参数传入一个 lambda 表达式,该表达式在 viewModel 上调用 setDate()。对于 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 参数下方,modifier 参数上方,添加一个名为 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() 函数时,我们将传入所选对的第二个属性。

  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 可组合项现在需要 onSendButtonClicked 和 onCancelButtonClicked 的值。找到 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 方法接受一个参数:一个对应于在您的 NavHost 中定义的路线的 String。如果路线与 NavHost 中对 composable() 的某个调用匹配,应用就会导航到该屏幕。

当用户按下“开始”、“口味”和“取货”屏幕上的按钮时,您将传入调用 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 组件——它显示分享选项。

此 UI 组件并非 Cupcake 应用的一部分。事实上,它由 Android 操作系统提供。系统 UI(例如分享屏幕)不会通过您的 navController 调用。相反,您会使用一种称为 Intent 的东西。

Intent 是请求系统执行某种操作的请求,通常是呈现一个新的 Activity。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. 调用 contextstartActivity() 方法,传入通过该 Intent 创建的 Activity。

我们将逐步指导您如何创建分享操作 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 对象一次,因此您可以使用 apply() 函数(您在之前的 Codelab 中学过)使接下来的几行代码更简洁。

  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. 调用 contextstartActivity() 方法。
context.startActivity(
    
)
  1. 在传入 startActivity() 的 lambda 表达式中,通过调用类方法 createChooser() 从 Intent 创建一个 activity。将 intent 作为第一个参数传入,并将 new_cupcake_order 字符串资源作为第二个参数传入。
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. 运行您的应用并在屏幕之间导航。

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

13bde33712e135a4.png

7. 使应用栏响应导航

尽管您的应用功能正常,并且可以在每个屏幕之间导航,但与本 Codelab 开头截图中的应用相比,仍缺少一些内容。应用栏不会自动响应导航。当应用导航到新路线时,标题不会更新,也不会在适当的时候在标题之前显示“向上”按钮。

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

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

enum class CupcakeScreen(@StringRes val title: Int) {
    Start,
    Flavor,
    Pickup,
    Summary
}
  1. 为每个枚举值添加一个资源值,对应于每个屏幕的标题文本。对于“开始”屏幕使用 app_name,对于“口味”屏幕使用 choose_flavor,对于“取货”屏幕使用 choose_pickup_date,对于“摘要”屏幕使用 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 传入 TopAppBartitle 参数的 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 中处理了它们,并使用了函数类型参数将导航逻辑与单个屏幕分离。您还学习了如何使用 Intent 将数据发送到另一个应用,以及如何自定义应用栏以响应导航。在接下来的单元中,您将继续使用这些技能来开发其他几个日益复杂的多屏幕应用。

了解更多