1. 简介
上次更新 2022-07-25
您需要什么
- 最新版 Android Studio
- Kotlin 和 尾随 lambda 表达式 的知识
- 对导航及其术语(如返回栈)的基本了解
- 对 Compose 的基本了解
- 建议在此之前学习 Jetpack Compose 基础代码实验室
- 对 Compose 中状态管理的基本了解
- 建议在此之前学习 Jetpack Compose 中的状态代码实验室
使用 Compose 进行导航
导航 是一个 Jetpack 库,它允许在应用内从一个目标导航到另一个目标。导航库还提供了一个特定工件,以实现与 Jetpack Compose 一致且惯用的导航。此工件(navigation-compose
)是本代码实验室的重点。
您将做什么
您将使用 Rally Material 学习 作为本代码实验室的基础,以实现 Jetpack Navigation 组件并在可组合 Rally 屏幕之间启用导航。
您将学到什么
- 使用 Jetpack Compose 进行 Jetpack Navigation 的基础知识
- 在可组合项之间导航
- 将自定义标签栏可组合项集成到您的导航层次结构中
- 使用参数导航
- 使用深层链接导航
- 测试导航
2. 设置
要继续学习,请克隆代码实验室的起点(main
分支)。
$ git clone https://github.com/android/codelab-android-compose.git
或者,您可以下载两个 zip 文件
现在您已下载代码,请在 Android Studio 中打开 **NavigationCodelab** 项目文件夹。现在您可以开始学习了。
3. Rally 应用概述
第一步,您应该熟悉 Rally 应用及其代码库。运行应用并探索一下。
Rally 有三个主要屏幕作为可组合项
- **
OverviewScreen
** — 所有财务交易和警报的概述 - **
AccountsScreen
** — 对现有账户的见解 - **
BillsScreen
** — 预计支出
在屏幕的最顶部,Rally 使用 **自定义标签栏可组合项(**RallyTabRow
)** 在这三个屏幕之间导航。点击每个图标都会展开当前选择并带您到相应的屏幕
导航到这些可组合屏幕时,您也可以将它们视为导航目标,因为我们希望在特定点到达每个目标。这些目标在 RallyDestinations.kt
文件中预定义。
在里面,您会发现所有三个主要目标都定义为对象(Overview、Accounts
和 Bills
),以及一个 SingleAccount
,它稍后将添加到应用中。每个对象都扩展自 RallyDestination
接口,并包含每个目标在导航目的方面所需的信息
- 顶部栏的
icon
- String
route
(对于 Compose 导航,它是到达该目标的路径) - 表示此目标的整个可组合项的
screen
运行应用时,您会注意到您实际上可以使用顶部栏在目标之间导航。但是,应用实际上并没有使用 Compose 导航,而是其当前的导航机制依赖于手动切换可组合项并触发 重新组合 以显示新内容。因此,本代码实验室的目标是成功迁移和实现 Compose 导航。
4. 迁移到 Compose 导航
迁移到 Jetpack Compose 的基本步骤如下所示
- 添加最新的 Compose 导航依赖项
- 设置
NavController
- 添加
NavHost
并创建导航图 - 准备在不同应用目标之间导航的路由
- 用 Compose 导航替换当前的导航机制
让我们逐一更详细地介绍这些步骤。
添加导航依赖项
打开应用的构建文件,位于 app/build.gradle
。在依赖项部分,添加 navigation-compose
依赖项。
dependencies {
implementation "androidx.navigation:navigation-compose:{latest_version}"
// ...
}
您可以在 此处 找到 navigation-compose 的最新版本。
现在,同步项目,您就可以开始在 Compose 中使用导航了。
设置 NavController
在 Compose 中使用导航时,NavController
是核心组件。它跟踪返回栈可组合项条目、向前移动栈、启用返回栈操作并在目标状态之间导航。由于 NavController
对于导航至关重要,因此必须将其作为设置 Compose 导航的第一步创建。
通过调用 rememberNavController()
函数可以获取 NavController
。这将创建一个并 记住 一个 NavController
,该控制器可在配置更改(使用 rememberSaveable
)后继续存在。
您应该始终在可组合项层次结构的顶层创建和放置 NavController
,通常在 App
可组合项内。然后,所有需要引用 NavController
的可组合项都可以访问它。这遵循 状态提升 的原则,并确保 NavController
是在可组合屏幕之间导航和维护返回栈的主要真相来源。
打开 RallyActivity.kt
。通过在 RallyApp
内使用 rememberNavController()
获取 NavController
,因为它是最顶层可组合项,也是整个应用的入口点
import androidx.navigation.compose.rememberNavController
// ...
@Composable
fun RallyApp() {
RallyTheme {
var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
val navController = rememberNavController()
Scaffold(
// ...
) {
// ...
}
}
Compose 导航中的路由
如前所述,Rally 应用有三个主要目标,以及一个稍后要添加的目标(SingleAccount
)。这些目标在 RallyDestinations.kt
中定义。我们提到每个目标都有一个定义的 icon
、route
和 screen
下一步是将这些目标添加到您的导航图中,并将 Overview
作为应用启动时的起始目标。
在 Compose 中使用导航时,导航图中的每个可组合目标都与一个 路由 相关联。路由表示为字符串,定义可组合项的路径并指导您的 navController
到达正确的位置。您可以将其视为指向特定目标的隐式深层链接。**每个目标都必须具有唯一的路由**。
为此,我们将使用每个 RallyDestination
对象的 route
属性。例如,Overview.route
是将带您到 Overview
屏幕可组合项的路由。
使用导航图调用 NavHost 可组合项
下一步是添加 NavHost
并创建导航图。
导航的三个主要部分是 NavController
、NavGraph
和 NavHost
。NavController
始终与单个 NavHost
可组合项相关联。NavHost
充当容器,负责显示图的当前目标。在可组合项之间导航时,NavHost
的内容会自动 重新组合。它还将 NavController
与导航图(NavGraph
)关联,该导航图绘制了可组合目标之间的导航路径。它本质上是可获取目标的集合。
返回 RallyActivity.kt
中的 RallyApp
可组合项。替换 Scaffold
内包含当前屏幕内容以手动切换屏幕的 Box
可组合项,替换为一个新的 NavHost
,您可以按照以下代码示例创建它。
传入我们在上一步中创建的 navController
以将其连接到此 NavHost
。如前所述,每个 NavController
必须与单个 NavHost
相关联。
NavHost
还需要一个 startDestination
路由来了解在应用启动时显示哪个目标,因此将其设置为 Overview.route
。此外,传递一个 Modifier
以接受外部 Scaffold
填充并将其应用于 NavHost
。
最后一个参数 builder: NavGraphBuilder.() -> Unit
负责定义和构建导航图。它使用来自 Navigation Kotlin DSL 的 lambda 语法,因此它可以作为尾随 lambda 传递到函数体中并从括号中提取出来。
import androidx.navigation.compose.NavHost
...
Scaffold(...) { innerPadding ->
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
// builder parameter will be defined here as the graph
}
}
向 NavGraph 添加目标
现在,您可以定义导航图以及 NavController
可以导航到的目标。如前所述,builder
参数期望一个函数,因此 Navigation Compose 提供了 NavGraphBuilder.composable
扩展函数,以便轻松地将各个可组合的目标添加到导航图中并定义必要的导航信息。
第一个目标将是 Overview
,因此您需要通过 composable
扩展函数添加它并设置其唯一的字符串 route
。这只是将目标添加到您的导航图中,因此您还需要定义在导航到此目标时要显示的实际 UI。这也将通过 composable
函数体内的尾随 lambda 完成,这是 Compose 中经常使用的模式
import androidx.navigation.compose.composable
// ...
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Overview.route) {
Overview.screen()
}
}
遵循此模式,我们将所有三个主屏幕可组合项添加为三个目标
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Overview.route) {
Overview.screen()
}
composable(route = Accounts.route) {
Accounts.screen()
}
composable(route = Bills.route) {
Bills.screen()
}
}
现在运行应用程序 - 您将看到 Overview
作为起始目标及其相应的 UI 显示。
我们之前提到了一个自定义顶部选项卡栏,RallyTabRow
可组合项,它以前处理屏幕之间的手动导航。此时,它尚未与新的导航连接,因此您可以验证点击选项卡不会更改显示的屏幕可组合项的目标。我们接下来修复它!
5. 集成 RallyTabRow 和导航
在此步骤中,您将 RallyTabRow
与 navController
和导航图连接起来,以使其能够导航到正确的位置。
为此,您需要使用新的 navController
为 RallyTabRow
的 onTabSelected
回调定义正确的导航操作。此回调定义了选择特定选项卡图标时应该发生什么,并通过 navController.navigate(route)
.
执行导航操作。
遵循此指南,在 RallyActivity
中,找到 RallyTabRow
可组合项及其回调参数 onTabSelected
。
由于我们希望选项卡在点击时导航到特定目标,因此您还需要知道选择了哪个确切的选项卡图标。幸运的是,onTabSelected: (RallyDestination) -> Unit
参数已经提供了这一点。您将使用该信息和 RallyDestination
路由来指导您的 navController
,并在选择选项卡时调用 navController.navigate(newScreen.route)
@Composable
fun RallyApp() {
RallyTheme {
var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
val navController = rememberNavController()
Scaffold(
topBar = {
RallyTabRow(
allScreens = rallyTabRowScreens,
// Pass the callback like this,
// defining the navigation action when a tab is selected:
onTabSelected = { newScreen ->
navController.navigate(newScreen.route)
},
currentScreen = currentScreen,
)
}
如果您现在运行应用程序,您可以验证点击 RallyTabRow
中的各个选项卡确实会导航到正确可组合的目标。但是,您可能已经注意到目前存在两个问题
- 连续重新点击同一个选项卡会启动同一目标的多个副本
- 选项卡的 UI 与显示的正确目标不匹配 - 也就是说,所选选项卡的展开和折叠无法按预期工作
让我们修复这两个问题!
启动目标的单个副本
为了解决第一个问题并确保在回退栈顶部最多只有一个给定目标的副本,Compose Navigation API 提供了一个 launchSingleTop
标志,您可以将其传递给您的 navController.navigate()
操作,如下所示
navController.navigate(route) { launchSingleTop = true }
由于您希望在整个应用程序中都具有此行为,因此对于每个目标,而不是将此标志复制粘贴到所有 .navigate(...)
调用中,您可以将其提取到 RallyActivity
底部的帮助程序扩展中
import androidx.navigation.NavHostController
// ...
fun NavHostController.navigateSingleTopTo(route: String) =
this.navigate(route) { launchSingleTop = true }
现在您可以将 navController.navigate(newScreen.route)
调用替换为 .navigateSingleTopTo(...)
。重新运行应用程序并验证当您多次点击顶栏中其图标时,现在只会获得单个目标的一个副本
@Composable
fun RallyApp() {
RallyTheme {
var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
val navController = rememberNavController()
Scaffold(
topBar = {
RallyTabRow(
allScreens = rallyTabRowScreens,
onTabSelected = { newScreen ->
navController
.navigateSingleTopTo(newScreen.route)
},
currentScreen = currentScreen,
)
}
控制导航选项和回退栈状态
除了 launchSingleTop
之外,您还可以从 NavOptionsBuilder
中使用其他标志来进一步控制和自定义您的导航行为。由于我们的 RallyTabRow
的作用类似于 BottomNavigation
,因此您还应该考虑在导航到和离开目标时是否要保存和恢复目标状态。例如,如果您滚动到“概述”的底部,然后导航到“帐户”并返回,您是否希望保留滚动位置?您是否希望重新点击 RallyTabRow
中的同一目标以重新加载屏幕状态,或者不希望重新加载?这些都是有效的问题,应根据您自己的应用程序设计的需求来确定。
我们将介绍一些您可以在同一 navigateSingleTopTo
扩展函数中使用的其他选项
launchSingleTop = true
- 如前所述,这确保在回退栈顶部最多只有一个给定目标的副本- 在 Rally 应用程序中,这意味着多次重新点击同一个选项卡不会启动同一目标的多个副本
popUpTo(startDestination) { saveState = true }
- 弹出到图的起始目标,以避免在您选择选项卡时在回退栈上累积大量目标- 在 Rally 中,这意味着从任何目标按下后退箭头将弹出整个回退栈到“概述”
restoreState = true
- 确定此导航操作是否应恢复之前由PopUpToBuilder.saveState
或popUpToSaveState
属性保存的任何状态。请注意,如果之前没有使用正在导航到的目标 ID 保存任何状态,则此操作无效- 在 Rally 中,这意味着重新点击同一个选项卡将保留屏幕上的先前数据和用户状态,而无需再次重新加载
您可以将所有这些选项逐一添加到代码中,在添加每个选项后运行应用程序并验证添加每个标志后的确切行为。这样,您就可以在实践中了解每个标志如何更改导航和回退栈状态
import androidx.navigation.NavHostController
import androidx.navigation.NavGraph.Companion.findStartDestination
// ...
fun NavHostController.navigateSingleTopTo(route: String) =
this.navigate(route) {
popUpTo(
[email protected]().id
) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
修复选项卡 UI
在代码实验室的开始时,在仍然使用手动导航机制时,RallyTabRow
使用 currentScreen
变量来确定是展开还是折叠每个选项卡。
但是,在您进行更改后,currentScreen
将不再更新。这就是为什么 RallyTabRow
内所选选项卡的展开和折叠不再起作用的原因。
要使用 Compose Navigation 重新启用此行为,您需要在每个点都知道显示的当前目标是什么,或者用导航术语来说,当前回退栈条目的顶部是什么,然后在每次更改时更新您的 RallyTabRow
。
要以 State
的形式从回退栈中获取有关当前目标的实时更新,您可以使用 navController.currentBackStackEntryAsState()
,然后获取其当前 destination:
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...
@Composable
fun RallyApp() {
RallyTheme {
val navController = rememberNavController()
val currentBackStack by navController.currentBackStackEntryAsState()
// Fetch your currentDestination:
val currentDestination = currentBackStack?.destination
// ...
}
}
currentBackStack?.destination
返回 NavDestination
.
为了正确更新 currentScreen
,您需要找到一种方法将返回的 NavDestination
与 Rally 的三个主要屏幕可组合项之一进行匹配。您必须确定当前显示的是哪一个,以便您可以将此信息传递给 RallyTabRow.
如前所述,每个目标都有一个唯一的路由,因此我们可以使用此字符串路由作为某种 ID 来进行验证比较并找到唯一的匹配项。
要更新 currentScreen
,您需要遍历 rallyTabRowScreens
列表以查找匹配的路由,然后返回相应的 RallyDestination
。Kotlin 为此提供了一个方便的 .find()
函数
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...
@Composable
fun RallyApp() {
RallyTheme {
val navController = rememberNavController()
val currentBackStack by navController.currentBackStackEntryAsState()
val currentDestination = currentBackStack?.destination
// Change the variable to this and use Overview as a backup screen if this returns null
val currentScreen = rallyTabRowScreens.find { it.route == currentDestination?.route } ?: Overview
// ...
}
}
由于 currentScreen
已经被传递给 RallyTabRow
,因此您可以运行应用程序并验证选项卡栏 UI 现在正在相应地更新。
6. 从 RallyDestinations 中提取屏幕可组合项
到目前为止,为简单起见,我们一直使用 RallyDestination
接口及其扩展的屏幕对象的 screen
属性,在 NavHost (RallyActivity.kt
) 中添加可组合的 UI
import com.example.compose.rally.ui.overview.OverviewScreen
// ...
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Overview.route) {
Overview.screen()
}
// ...
}
但是,此代码实验室中的后续步骤(例如点击事件)需要将其他信息直接传递给您的可组合屏幕。在生产环境中,肯定会有更多需要传递的数据。
实现这一目标更正确、更简洁的方法是在 NavHost
导航图中直接添加可组合项,并从 RallyDestination
中提取它们。之后,RallyDestination
和屏幕对象将只保存与导航相关的信息,例如 icon
和 route
,并且与任何 Compose UI 相关内容解耦。
打开 RallyDestinations.kt
。将每个屏幕的可组合项从 RallyDestination
对象的 screen
参数中提取到 NavHost
中相应的 composable
函数中,替换之前的 .screen()
调用,如下所示:
import com.example.compose.rally.ui.accounts.AccountsScreen
import com.example.compose.rally.ui.bills.BillsScreen
import com.example.compose.rally.ui.overview.OverviewScreen
// ...
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
composable(route = Overview.route) {
OverviewScreen()
}
composable(route = Accounts.route) {
AccountsScreen()
}
composable(route = Bills.route) {
BillsScreen()
}
}
此时,您可以安全地从 RallyDestination
及其对象中删除 screen
参数。
interface RallyDestination {
val icon: ImageVector
val route: String
}
/**
* Rally app navigation destinations
*/
object Overview : RallyDestination {
override val icon = Icons.Filled.PieChart
override val route = "overview"
}
// ...
再次运行应用,并验证所有内容是否仍按之前的方式工作。现在您已完成此步骤,您将能够在可组合屏幕内设置点击事件。
启用 OverviewScreen 上的点击
目前,OverviewScreen
中的任何点击事件都会被忽略。这意味着“帐户”和“账单”子部分的“查看全部”按钮是可点击的,但实际上不会带您到任何地方。此步骤的目标是为这些点击事件启用导航。
OverviewScreen
可组合项可以接受多个函数作为回调以设置为点击事件,在本例中,这些回调应该是导航操作,将您带到 AccountsScreen
或 BillsScreen
。让我们将这些导航回调传递给 onClickSeeAllAccounts
和 onClickSeeAllBills
以导航到相关的目的地。
打开 RallyActivity.kt
,在 NavHost
中找到 OverviewScreen
,并将 navController.navigateSingleTopTo(...)
传递给两个导航回调,并使用相应的路由。
OverviewScreen(
onClickSeeAllAccounts = {
navController.navigateSingleTopTo(Accounts.route)
},
onClickSeeAllBills = {
navController.navigateSingleTopTo(Bills.route)
}
)
navController
现在将拥有足够的信息,例如确切目的地的路由,
以便在点击按钮时导航到正确的目的地。如果您查看 OverviewScreen
的实现,您会看到这些回调已设置为相应的 onClick
参数:
@Composable
fun OverviewScreen(...) {
// ...
AccountsCard(
onClickSeeAll = onClickSeeAllAccounts,
onAccountClick = onAccountClick
)
// ...
BillsCard(
onClickSeeAll = onClickSeeAllBills
)
}
如前所述,将 navController
保留在导航层次结构的顶层,并提升到 App
可组合项的级别(而不是直接将其传递到例如 OverviewScreen)
使得单独预览、重用和测试 OverviewScreen
可组合项变得容易,而无需依赖实际或模拟的 navController
实例。传递回调还可以快速更改您的点击事件!
7. 使用参数导航到 SingleAccountScreen
让我们为 Accounts
和 Overview
屏幕添加一些新功能!目前,这些屏幕显示了若干不同类型帐户的列表 - “支票账户”、“住房储蓄”等。
但是,点击这些帐户类型没有任何作用(目前!)。让我们解决这个问题!当我们点击每种帐户类型时,我们希望显示一个包含完整帐户详细信息的新屏幕。为此,我们需要向 navController
提供有关我们正在点击的具体帐户类型的附加信息。这可以通过 参数 来实现。
参数是一个非常强大的工具,它通过将一个或多个参数传递给路由来使导航路由变得动态。它能够根据提供的不同参数显示不同的信息。
在 RallyApp
中,通过向现有的 NavHost
添加一个新的 composable
函数,向图中添加一个新的目的地 SingleAccountScreen
,它将处理显示这些单个帐户:
import com.example.compose.rally.ui.accounts.SingleAccountScreen
// ...
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = Modifier.padding(innerPadding)
) {
...
composable(route = SingleAccount.route) {
SingleAccountScreen()
}
}
设置 SingleAccountScreen 着陆目的地
当您到达 SingleAccountScreen
时,此目的地需要其他信息才能知道在打开时应显示哪个确切的帐户类型。我们可以使用参数来传递此类信息。您需要指定其路由还要求一个参数 {account_type}
。如果您查看 RallyDestination
及其 SingleAccount
对象,您会注意到此参数已为您定义为 accountTypeArg
字符串以供使用。
要在导航时将参数与路由一起传递,您需要将它们组合在一起,遵循以下模式:"route/{argument}"
。在您的情况下,它将如下所示:"${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
。请记住,$ 符号用于转义变量。
import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...
composable(
route =
"${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
) {
SingleAccountScreen()
}
这将确保在触发导航到 SingleAccountScreen
的操作时,也必须传递 accountTypeArg
参数,否则导航将不成功。可以将其视为需要由想要导航到 SingleAccountScreen
的其他目的地的签名或契约。
第二步是使此 composable
意识到它应该接受参数。您可以通过定义其 arguments
参数来实现。您可以根据需要定义任意数量的参数,因为 composable
函数默认情况下接受参数列表。在您的情况下,您只需要添加一个名为 accountTypeArg
的参数,并通过将其指定为 String
类型来添加一些额外的安全性。如果您没有显式设置类型,则将从该参数的默认值推断出类型。
import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...
composable(
route =
"${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
arguments = listOf(
navArgument(SingleAccount.accountTypeArg) { type = NavType.StringType }
)
) {
SingleAccountScreen()
}
这将完美运行,您可以选择保持代码不变。但是,由于我们所有特定于目的地的信息都在 RallyDestinations.kt
及其对象中,让我们继续使用相同的方法(就像我们在上面为 Overview
、Accounts
和 Bills
所做的那样),并将此参数列表移动到 SingleAccount
中:
object SingleAccount : RallyDestination {
// ...
override val route = "single_account"
const val accountTypeArg = "account_type"
val arguments = listOf(
navArgument(accountTypeArg) { type = NavType.StringType }
)
}
现在,将以前的参数替换为 SingleAccount.arguments
,然后返回到 NavHost 中相应的 composable
。这也确保我们保持 NavHost
的整洁和可读性。
composable(
route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
arguments = SingleAccount.arguments
) {
SingleAccountScreen()
}
现在您已为 SingleAccountScreen
定义了完整的路由和参数,下一步是确保此 accountTypeArg
被进一步传递到 SingleAccountScreen
可组合项,以便它知道应该正确显示哪个帐户类型。如果您查看 SingleAccountScreen
的实现,您会看到它已经设置好了,并等待接受 accountType
参数。
fun SingleAccountScreen(
accountType: String? = UserData.accounts.first().name
) {
// ...
}
概括一下,到目前为止
- 我们确保定义了路由以请求参数,作为对其前序目的地的信号。
- 我们确保
composable
知道它需要接受参数。
我们的最后一步是实际**检索传递的参数**值。
在 Compose Navigation 中,每个 NavHost
可组合函数都可以访问当前的 NavBackStackEntry
- 一个类,它保存有关回退栈中条目的当前路由和传递参数的信息。您可以使用它从 navBackStackEntry
获取所需的 arguments
列表,然后搜索并检索您需要的精确参数,将其进一步传递到您的可组合屏幕。
在本例中,您将从 navBackStackEntry
中请求 accountTypeArg
。然后,您需要将其进一步传递到 SingleAccountScreen
的 accountType
参数。
您还可以为参数提供一个默认值作为占位符,以防它未提供,并通过覆盖此边缘情况使您的代码更加安全。
您的代码现在应该如下所示
NavHost(...) {
// ...
composable(
route =
"${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
arguments = SingleAccount.arguments
) { navBackStackEntry ->
// Retrieve the passed argument
val accountType =
navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)
// Pass accountType to SingleAccountScreen
SingleAccountScreen(accountType)
}
}
现在,当您导航到 SingleAccountScreen
时,它拥有显示正确帐户类型所需的信息。如果您查看 SingleAccountScreen
的实现,您会发现它已经完成了将传递的 accountType
与 UserData
源进行匹配以获取相应帐户详细信息的操作。
让我们再次执行一项小的优化任务,并将 "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
路由也移动到 RallyDestinations.kt
及其 SingleAccount
对象中:
object SingleAccount : RallyDestination {
// ...
override val route = "single_account"
const val accountTypeArg = "account_type"
val routeWithArgs = "${route}/{${accountTypeArg}}"
val arguments = listOf(
navArgument(accountTypeArg) { type = NavType.StringType }
)
}
同样,在相应的 NavHost composable
中替换它:
// ...
composable(
route = SingleAccount.routeWithArgs,
arguments = SingleAccount.arguments
) {...}
设置 Accounts 和 Overview 的起始目的地
现在您已定义了 SingleAccountScreen
路由及其成功导航到 SingleAccountScreen
所需和接受的参数,您需要确保从上一个目的地(即您来自的任何目的地)传递相同的 accountTypeArg
参数。
如您所见,这有两个方面 - 提供并传递参数的起始目的地和接受该参数并使用它显示正确信息的着陆目的地。**两者都需要明确定义。**
例如,当您位于 Accounts
目的地并点击“支票账户”帐户类型时,Accounts 目的地需要将“支票账户”字符串作为参数传递,附加到“single_account”字符串路由,以成功打开相应的 SingleAccountScreen
。其字符串路由将如下所示:"single_account/Checking"
在使用 navController.navigateSingleTopTo(...),
时,您将使用此完全相同的路由和传递的参数,如下所示
navController.navigateSingleTopTo("${SingleAccount.route}/$accountType")
将此导航操作回调传递给OverviewScreen
和AccountsScreen
的onAccountClick
参数。请注意,这些参数预定义为:onAccountClick: (String) -> Unit
,其中String作为输入。这意味着,当用户在Overview
和Account
中点击特定账户类型时,该账户类型字符串将可供您使用,并且可以轻松地作为导航参数传递。
OverviewScreen(
// ...
onAccountClick = { accountType ->
navController
.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}
)
// ...
AccountsScreen(
// ...
onAccountClick = { accountType ->
navController
.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}
)
为了保持代码的可读性,您可以将此导航操作提取到一个私有的辅助函数(扩展函数)中。
import androidx.navigation.NavHostController
// ...
OverviewScreen(
// ...
onAccountClick = { accountType ->
navController.navigateToSingleAccount(accountType)
}
)
// ...
AccountsScreen(
// ...
onAccountClick = { accountType ->
navController.navigateToSingleAccount(accountType)
}
)
// ...
private fun NavHostController.navigateToSingleAccount(accountType: String) {
this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}
在您此时运行应用程序时,您可以点击每个账户类型,并将其带到相应的SingleAccountScreen
,显示给定账户的数据。
8. 启用深层链接支持
除了添加参数之外,您还可以添加深层链接,以将特定的 URL、操作和/或 MIME 类型与可组合项关联。在 Android 中,深层链接是指直接将您带到应用内特定目标的链接。Navigation Compose 支持隐式深层链接。当调用隐式深层链接时(例如,当用户点击链接时),Android 随后可以将您的应用打开到相应的目标。
在本节中,您将为导航到SingleAccountScreen
可组合项(以及相应的账户类型)添加一个新的深层链接,并启用此深层链接以供外部应用使用。为了刷新您的记忆,此可组合项的路由是"single_account/{account_type}"
,这也就是您也将用于深层链接的内容,并进行一些细微的与深层链接相关的更改。
由于默认情况下不会将深层链接公开给外部应用,因此您还必须将<intent-filter>
元素添加到应用的manifest.xml
文件中,这将是您的第一步。
首先将深层链接添加到应用的AndroidManifest.xml
中。您需要在<activity>
内通过<intent-filter>
创建一个新的意图过滤器,其操作为VIEW
,类别为BROWSABLE
和DEFAULT
。
然后在过滤器内部,您需要data
标签来添加**scheme
**(rally
- 应用名称)和**host
**(single_account
- 可组合项的路由),以定义您的精确深层链接。这将为您提供rally://single_account
作为深层链接 URL。
请注意,您不需要在AndroidManifest
中声明account_type
参数。这将在稍后在NavHost
可组合函数内部追加。
<activity
android:name=".RallyActivity"
android:windowSoftInputMode="adjustResize"
android:label="@string/app_name"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="rally" android:host="single_account" />
</intent-filter>
</activity>
触发并验证深层链接
现在,您可以从RallyActivity
内部对传入的意图做出反应。
可组合项SingleAccountScreen
已经接受参数,但现在它还需要接受新创建的深层链接,以便在触发其深层链接时启动此目标。
在SingleAccountScreen
的可组合函数内部,添加另一个参数deepLinks
。与arguments
类似,它也接受navDeepLink
列表,因为您可以定义多个指向相同目标的深层链接。传递uriPattern
,使其与清单中intent-filter
中定义的匹配 - rally://singleaccount
,但这次您还将追加其accountTypeArg
参数。
import androidx.navigation.navDeepLink
// ...
composable(
route = SingleAccount.routeWithArgs,
// ...
deepLinks = listOf(navDeepLink {
uriPattern = "rally://${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
})
)
您知道接下来该做什么,对吧?将此列表移动到RallyDestinations SingleAccount:
中。
object SingleAccount : RallyDestination {
// ...
val arguments = listOf(
navArgument(accountTypeArg) { type = NavType.StringType }
)
val deepLinks = listOf(
navDeepLink { uriPattern = "rally://$route/{$accountTypeArg}"}
)
}
同样,在相应的NavHost
可组合项中替换它。
// ...
composable(
route = SingleAccount.routeWithArgs,
arguments = SingleAccount.arguments,
deepLinks = SingleAccount.deepLinks
) {...}
使用 adb 测试深层链接
现在您的应用和SingleAccountScreen
已准备好处理深层链接。为了测试它是否按预期工作,请在连接的模拟器或设备上全新安装 Rally,打开命令行并执行以下命令,以模拟深层链接启动。
adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW
这将直接带您进入“Checking”账户,但您也可以验证它是否对所有其他账户类型都正常工作。
9. 将 NavHost 提取到 RallyNavHost 中
现在您的NavHost
已完成。但是,为了使其可测试,并使您的RallyActivity
更简洁,您可以从RallyApp
可组合项中提取当前的NavHost
及其辅助函数(如navigateToSingleAccount
),将其放到自己的可组合函数中,并将其命名为RallyNavHost
。
RallyApp
是唯一一个应直接与navController
一起工作的可组合项。如前所述,每个嵌套的可组合屏幕都应该只获取导航回调,而不是navController
本身。
因此,新的RallyNavHost
将接受navController
和modifier
作为来自RallyApp
的参数。
@Composable
fun RallyNavHost(
navController: NavHostController,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = Overview.route,
modifier = modifier
) {
composable(route = Overview.route) {
OverviewScreen(
onClickSeeAllAccounts = {
navController.navigateSingleTopTo(Accounts.route)
},
onClickSeeAllBills = {
navController.navigateSingleTopTo(Bills.route)
},
onAccountClick = { accountType ->
navController.navigateToSingleAccount(accountType)
}
)
}
composable(route = Accounts.route) {
AccountsScreen(
onAccountClick = { accountType ->
navController.navigateToSingleAccount(accountType)
}
)
}
composable(route = Bills.route) {
BillsScreen()
}
composable(
route = SingleAccount.routeWithArgs,
arguments = SingleAccount.arguments,
deepLinks = SingleAccount.deepLinks
) { navBackStackEntry ->
val accountType =
navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)
SingleAccountScreen(accountType)
}
}
}
fun NavHostController.navigateSingleTopTo(route: String) =
this.navigate(route) { launchSingleTop = true }
private fun NavHostController.navigateToSingleAccount(accountType: String) {
this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}
现在将新的RallyNavHost
添加到您的RallyApp
中,并重新运行应用以验证一切是否如之前一样工作。
fun RallyApp() {
RallyTheme {
...
Scaffold(
...
) { innerPadding ->
RallyNavHost(
navController = navController,
modifier = Modifier.padding(innerPadding)
)
}
}
}
10. 测试 Compose Navigation
从本 Codelab 的开始,您就确保不将navController
直接传递到任何可组合项(除了高级应用)中,而是将导航回调作为参数传递。这允许您的所有可组合项单独进行测试,因为它们不需要在测试中使用navController
的实例。
您应该始终测试应用中整个 Compose Navigation 机制是否按预期工作,方法是测试RallyNavHost
和传递给可组合项的导航操作。这些将是本节的主要目标。要单独测试各个可组合函数,请务必查看Jetpack Compose 中的测试 Codelab。
要开始测试,我们首先需要添加必要的测试依赖项,因此请返回到应用的构建文件,该文件位于app/build.gradle
中。在测试依赖项部分,添加navigation-testing
依赖项。
dependencies {
// ...
androidTestImplementation "androidx.navigation:navigation-testing:$rootProject.composeNavigationVersion"
// ...
}
准备 NavigationTest 类
您的RallyNavHost
可以与Activity
本身隔离进行测试。
由于此测试仍将在 Android 设备上运行,因此您需要创建测试目录/app/src/androidTest/java/com/example/compose/rally
,然后创建一个新的测试文件测试类,并将其命名为NavigationTest
。
第一步,要使用 Compose 测试 API 以及使用 Compose 测试和控制可组合项和应用,请添加一个Compose 测试规则。
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
}
编写您的第一个测试
创建一个公共的rallyNavHost
测试函数,并用@Test
进行注释。在此函数中,您首先需要设置要测试的 Compose 内容。使用composeTestRule
的setContent
执行此操作。它将可组合项作为参数传递给函数体,并允许您在测试环境中编写 Compose 代码并添加可组合项,就像在常规的生产环境应用中一样。
在setContent
内部,您可以设置当前的测试对象RallyNavHost
,并向其传递一个新的navController
实例。Navigation 测试工件提供了一个方便的TestNavHostController
供使用。因此,让我们添加此步骤。
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import org.junit.Assert.fail
import org.junit.Test
// ...
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
lateinit var navController: TestNavHostController
@Test
fun rallyNavHost() {
composeTestRule.setContent {
// Creates a TestNavHostController
navController =
TestNavHostController(LocalContext.current)
// Sets a ComposeNavigator to the navController so it can navigate through composables
navController.navigatorProvider.addNavigator(
ComposeNavigator()
)
RallyNavHost(navController = navController)
}
fail()
}
}
如果您复制了上面的代码,则fail()
调用将确保您的测试失败,直到进行实际断言。它用作提醒您完成测试的实现。
要验证是否显示了正确的屏幕可组合项,您可以使用其contentDescription
并断言它是否显示。在本 Codelab 中,之前已为“Accounts”和“Overview”目标设置了contentDescription
,因此您现在可以将其用于测试验证。
作为第一个验证,您应该检查在第一次初始化RallyNavHost
时是否显示“Overview”屏幕作为第一个目标。您还应该重命名测试以反映这一点 - 将其命名为rallyNavHost_verifyOverviewStartDestination
。通过以下操作替换fail()
调用。
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
// ...
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
lateinit var navController: TestNavHostController
@Test
fun rallyNavHost_verifyOverviewStartDestination() {
composeTestRule.setContent {
navController =
TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(
ComposeNavigator()
)
RallyNavHost(navController = navController)
}
composeTestRule
.onNodeWithContentDescription("Overview Screen")
.assertIsDisplayed()
}
}
再次运行测试,并验证它是否通过。
由于您需要以相同的方式设置RallyNavHost
以用于每个即将进行的测试,因此您可以将其初始化提取到一个用@Before
注释的函数中,以避免不必要的重复并使您的测试更简洁。
import org.junit.Before
// ...
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
lateinit var navController: TestNavHostController
@Before
fun setupRallyNavHost() {
composeTestRule.setContent {
navController =
TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(
ComposeNavigator()
)
RallyNavHost(navController = navController)
}
}
@Test
fun rallyNavHost_verifyOverviewStartDestination() {
composeTestRule
.onNodeWithContentDescription("Overview Screen")
.assertIsDisplayed()
}
}
在测试中导航
您可以通过多种方式测试您的导航实现,方法是对 UI 元素执行点击,然后验证显示的目标或将预期路由与当前路由进行比较。
通过 UI 点击和屏幕 contentDescription 进行测试
由于您希望测试您具体应用的实现,因此 UI 点击是首选。下一段文本可以验证,在“Overview”屏幕中,点击“Accounts”部分中的“SEE ALL”按钮将带您到“Accounts”目标。
您将再次使用OverviewScreenCard
可组合项中为此特定按钮设置的contentDescription
,通过performClick()
模拟对其进行点击,并验证是否随后显示“Accounts”目标。
import androidx.compose.ui.test.performClick
// ...
@Test
fun rallyNavHost_clickAllAccount_navigatesToAccounts() {
composeTestRule
.onNodeWithContentDescription("All Accounts")
.performClick()
composeTestRule
.onNodeWithContentDescription("Accounts Screen")
.assertIsDisplayed()
}
您可以遵循此模式来测试应用中所有剩余的点击导航操作。
通过 UI 点击和路由比较进行测试
您还可以使用navController
通过将当前字符串路由与预期路由进行比较来检查您的断言。为此,请执行对 UI 的点击,与上一节相同,然后使用navController.currentBackStackEntry?.destination?.route
将当前路由与您期望的路由进行比较。
还有一步是确保您首先滚动到“Overview”屏幕上的“Bills”部分,否则测试将失败,因为它将无法找到具有contentDescription
“All Bills”的节点。
import androidx.compose.ui.test.performScrollTo
import org.junit.Assert.assertEquals
// ...
@Test
fun rallyNavHost_clickAllBills_navigateToBills() {
composeTestRule.onNodeWithContentDescription("All Bills")
.performScrollTo()
.performClick()
val route = navController.currentBackStackEntry?.destination?.route
assertEquals(route, "bills")
}
遵循这些模式,您可以通过涵盖任何其他导航路线、目的地和点击操作来完成测试类。现在运行整套测试以验证它们都已通过。
11. 恭喜
恭喜您已成功完成此 Codelab!您可以在此处找到解决方案代码,并将其与您的代码进行比较。
您已将 Jetpack Compose 导航添加到 Rally 应用,并且现在熟悉其关键概念。您学习了如何设置可组合目的地的导航图、定义导航路线和操作、通过参数将其他信息传递到路线、设置深层链接以及测试您的导航。
有关更多主题和信息,例如底部导航栏集成、多模块导航和嵌套图,您可以查看Now in Android GitHub 存储库并了解它是如何在其中实现的。
下一步是什么?
查看以下资料以继续您的Jetpack Compose 学习路径
有关 Jetpack Navigation 的更多信息