使用 Compose 在屏幕之间导航

1. 开始之前

到目前为止,您所使用的应用程序只有一个屏幕。但是,您使用的许多应用程序可能有多个屏幕,您可以通过这些屏幕进行导航。例如,“设置”应用程序的许多内容页面分布在不同的屏幕上。

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

先决条件

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

您将学习到

  • 创建一个NavHost 可组合项以定义应用程序中的路由和屏幕。
  • 使用NavHostController在屏幕之间导航。
  • 操作返回堆栈以导航到之前的屏幕。
  • 使用意图与另一个应用程序共享数据。
  • 自定义 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可组合项表示。

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

选择口味屏幕

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

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

选择取货日期屏幕

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

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

订单汇总屏幕

选择取货日期后,应用程序将显示“订单汇总”屏幕,用户可以在其中查看和完成订单。

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

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

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

13bde33712e135a4.png

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

应用程序的屏幕将在CupcakeApp可组合项中显示。但是,在起始项目中,应用程序仅显示第一个屏幕。目前无法浏览应用程序的所有屏幕,但不用担心,这就是您来这里的原因!您将学习如何定义导航路由,设置 NavHost 可组合项以在屏幕(也称为目标)之间导航,执行意图以与系统 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 的当前目标的可组合项。

在本 Codelab 中,您将重点关注 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.ktCupcakeApp可组合项中添加一个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。为modifier参数传入传递给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()函数,为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,传入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(),为route传入CupcakeScreen.Flavor.name
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. 当用户选择口味时,口味屏幕需要显示和更新小计。为subtotal参数传入uiState.price
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()函数,为route参数传入CupcakeScreen.Pickup.name
composable(route = CupcakeScreen.Pickup.name) {
    
}
  1. 在尾随lambda中,调用SelectOptionScreen可组合项,并像以前一样为subtotal传入uiState.price。为options参数传入uiState.pickupOptions,并为onSelectionChanged参数传入一个lambda表达式,该表达式在viewModel上调用setDate()。对于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中定义了路由并将它们映射到可组合项,那么现在该在屏幕之间导航了。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

一对中的每个项目都可以通过第一个属性或第二个属性访问。对于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. 为取消按钮的onClick参数传入onCancelButtonClicked
OutlinedButton(
    modifier = Modifier.weight(1f),
    onClick = onCancelButtonClicked
) {
    Text(stringResource(R.string.cancel))
}
  1. 为下一个按钮的onClick参数传入onNextButtonClicked
Button(
    modifier = Modifier.weight(1f),
    enabled = selectedValue.isNotEmpty(),
    onClick = onNextButtonClicked
) {
    Text(stringResource(R.string.next))
}

向SummaryScreen添加按钮处理程序

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

  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,向 onSendButtonClicked 传递一个带有两个 String 参数的空 lambda 表达式,向 onCancelButtonClicked 参数传递一个空 lambda 表达式。
@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 中定义的路由。如果该路由与 NavHostcomposable() 调用的任何一个匹配,则应用将导航到该屏幕。

当用户按下 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(),为 route 传入 CupcakeScreen.Flavor.name
onNextButtonClicked = {
    viewModel.setQuantity(it)
    navController.navigate(CupcakeScreen.Flavor.name)
}
  1. 对于口味屏幕上的 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 表达式,您将在后面实现它。
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 表达式,为 route 传入 CupcakeScreen.Pickup.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 表达式。添加 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(),为 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. 运行您的应用并测试按下任何屏幕上的**取消**按钮是否会将用户导航回第一个屏幕。

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(),为 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 创建一个活动。为第一个参数传入 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注解添加一个名为titleInt类型参数。
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可组合项添加一个名为currentScreenCupcakeScreen类型参数。
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中处理它们,并使用函数类型参数将导航逻辑与各个屏幕分开。您还学习了如何使用意图将数据发送到另一个应用程序,以及如何根据导航自定义应用栏。在接下来的单元中,您将继续使用这些技能,因为您将处理其他几个复杂度不断提高的多屏应用程序。

了解更多