Jetpack Compose(第九趴)——Jetpack
Navigation
是一个Jetpack库,用于在应用中从一个目的地导航到另一个目的地。Navigation库还提供了一个专用工件,用于使用Jetpack Compose实现一致而惯用的导航方式。
你将会学到
- 将Jetpack Navigation与Jetpack Compose配合使用的基础知识
- 在可组合项之间导航
- 将自定义标签页栏可组合项集成到导航层级结构中
- 使用实参进行导航
- 使用深层链接进行导航
- 测试导航
一、设置
如需自行学习,请克隆此 Codelab 的起始代码(main
分支)。
$ git clone https://github.com/googlecodelabs/android-compose-codelabs.git
或者,您也可以下载两个 ZIP 文件:
现在,您已下载相应代码,请在 Android Studio 中打开 NavigationCodelab 项目文件夹。现已准备就绪,可以开始开发项目了。
二、Rally应用概览
首先,您应当熟悉Rally应用及其代码库。运行应用并稍加探索。
Rally有三个主屏幕作为可组合项:
OverviewScreen
- 所有财务交易和提醒的概览AccountsScreen
- 有关现有账户的数据分析BillsScreen
- 预定支出
在屏幕的最顶部,Rally使用自定义标签页栏可组合项(RallyTabRow) 在这三个屏幕之间进行导航。点按每个图标应该会展开当前所选内容,您也会转到其对应的屏幕:
在导航到这些可组合屏幕时,您还可以将它们视为导航目的地,因为我们希望在特定时间到达各个目的地。这些目的地是在RallyDestinations.kt
文件中预定义的。
在该文件中,您会找到所有三个定义为对象的主要目的地(Override
,Accounts
和Bills
),以及一个日后要添加到应用的SingleAccount
。每个对象都从RallyDestination
接口扩展,并且包含有关每个目的地的必要信息,以便进行导航:
- 顶栏的
icon
- 字符串
route
(这对Compose Navigation而言是必须的,可作为指向相应目的地的路径) screen
,表示相应目的地的整个可组合项。
运行应用时,您会发现自己实际上可以在当前使用顶栏的目的地之间导航。然而,应用实际上并未使用Compose Navigation,但它当前使用的导航机制依赖手动切换一些可组合项和触发重组来显示新内容。
三、迁移到Compose Navigation
迁移到Jetpack Compose所涉及的基本步骤如下:
- 添加最新的
Compose Navigation依赖项
- 设置
NavController
- 添加
NavHost
并创建导航图 - 准备路线以在不同的应用目的地之间导航
- 将当前导航机制替换为Compose Navigation
3.1、添加Navigation依赖项
打开应用的build文件(位于app/build.gradle
)。在“dependencies”区段中,添加navigation-compose
依赖项。
dependencies {
implementation "androidx.navigation:navigation-compose:{latest_version}"
}
您可以点击此处找到最新版 Navigation Compose。
现在,同步项目,然后您就可以开始使用 Compose 中的 Navigation 了
3.2、设置NavController
使用Compose中的Navigation时,NavController
是核心组件。它可跟踪返回堆栈可组合条目、使堆栈向前移动、支持对返回堆栈执行操作,以及在不同目的地状态之间导航。由于NavController
是导航的核心,因此在设置Compose Navigation时必须先创建它。
NavController
是通过调用rememberNavController()
函数获取的。这将创建并记住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(
// ...
) {
// ...
}
}
}
3.3、Compose Navigation中的路线
如前所述,Rally应用有三个主要目的地,以及一个日后要添加的额外目的地(SingleAccount
)。这些目的地在RallyDestinations.kt
中定义。我们已经提到,每个目的地都有定义的icon
、route
和screen
:
接下来,将这些目的地添加到导航图中,其中Overview
作为应用启动时的其实目的地。
使用Compose中的Navigation时,导航图中的每个可组合目的地都与一个路线相关联。路线用字符串表示,用于定义指向可组合项的路径,并指引您的navController
到达正确的位置。您可以将其视为指向特定目的地的隐式深层链接。每个目的地都必须有一条唯一的路线。
为此,我们会使用每个RallyDestination
对象的route
属性。例如,Overview.route
是将您转到Overview
屏幕可组合项的录像。
3.4、使用导航图调用NavHost可组合项
下一步是添加NavHost
并创建导航图。
Navigation的3个主要部分是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
}
}
3.5、向NacGraph添加目的地
现在,您可以定义导航图以及NavController
可导航到的目的地。如上所述,builder
形参要求使用函数,因此Navigation Compose提供了NavGraphBuilder.composable
扩展函数,以便轻松将各个可组合目的地添加到导航图中,并定义必要的导航信息。
第一个目的地是Overview
,因此您需要通过composable
扩展函数添加它,并为其设置唯一字符串route
。此操作只会将目的地添加到导航图中,因此您还需要定义导航到此目的地时要显示的实际界面。此外,还可通过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
作为其实目的地,以及其对应的界面。
我们之前提到过自定义顶部标签页栏可组合项RallyTabRow
,该可组合项之前处理了屏幕之间的手动导航。此时,它尚未与新导航相关联,因此你可以验证一下点击标签页是否会更改所显示屏幕可组合项的目的地。接下来,我们来解决这个问题!
四、将RallyTabRow与导航集成
在此步骤中,您需要将RallyTabRow
与navController
和导航图链接起来,以便其导航到正确的目的地。
为此,您需要使用新的navController
,为RallyTabRow
的onTabSelected
回调定义正确的导航操作。此回调定义了选择特定标签页图标时发生的情况,并通过navController.navigate(route)
执行导航操作。
遵循此指南,在RallyActivity
中找到RallyTabRow
金额组合项及其回调形参onTabSelected
。
由于我们希望点按标签页后导航到特定目的地,因此您还需要知道所选择的确切标签页图标。幸运的是,onTabSelected:(RallyDestination) -> Unit
形参已提供这些信息。您将使用这些信息和RallyDestination
路线来引导navController
并在用户选择标签页时调用navController.navgate(newScreen.route)
:
@Composable
fun RallyApp() {
RallyTheme {
var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
val navController = rememberNavController()
Scaffold(
topBar = {
RallyTabRow(
allScreen = rallyTabRowScreens,
// Pass the callack like this,
// defining the navigation action when a tab is selected:
onTabSelected = { newScreen ->
navController.navigate(newScreen.route)
},
currentScreen = currentScreen,
)
}
)
}
}
如果现在运行应用,可以验证点按RallyTabRow
中的各个标签页是否确实会导航到正确的可组合目的地。不过,您目前可能已经注意到以下两个问题:
- 重按同一个标签页会启动同一目的地的多个副本
- 标签页的界面与所显示的正确目的地不符,也就是说,无法正常展开和收起所选标签页:
下面我们来解决这两个问题!
4.1、启动目的地的单个副本
为了解决第一个问题,并确保返回堆栈顶部最多只有给定目的地的一个副本, 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(newScteen.route)
调用替换为navigateSingleTopTo(...)
。重新运行应用,然后验证一下:在顶栏中多次点击器图标时,现在是否只会获得单一目的地的一个副本:
@Composable
fun RallyApp() {
RallyTheme {
var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
val navController = rememberNavController()
Scaffold(
topBar = {
RallyTabRow(
allScreen = rallyTabRowScreens,
onTabSelected = { newScreen ->
navController.navigationTopTo(newScreen.route)
},
currentScreen = currentScreen,
)
}
)
}
}
4.2、控制导航选项和返回堆栈状态
除了launchSingleTop
之外,您还可以使用NavOptionsBuilder
中的其他标志进一步控制和自定义导航行为。由于RallyTabRow
的作用类似于BottomNavigation
,因此您还应想一想,在导航和离开目的地时是否要保存并恢复目的地状态。例如,如果要滚动到“Overview”屏幕底部,然后导航到“Accounts”屏幕,接着返回,那么您是否要保持滚动位置?是否要重按RallyTabRow
中的同一目的地,以重新加载屏幕状态?这些都是有效问题,因根据您自己的应用设计要求来确定。
我们将介绍同一navigateSingleTopTo
扩展函数中可供您使用的一些其他选项:
launchSingleTop = true
-如上所述,这可确保返回堆栈顶部最多只有给定目的地的一个副本- 在Rally应用中,这意味着,多次重按同意标签页不会启动同一目的地的多个副本
popUpTo(startDestination) { saveState = true }
-弹出到导航图的起始目的地,以免在您选择标签页时在返回堆栈上积累大量目的地- 在Rally中,这意味着,在任何目的地按下返回箭头都会将整个返回堆栈弹出到“Overview”屏幕
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(
this@navigateSingleTopTo.graph.findStartDestination().id
) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
4.3、解决标签页界面问题
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()
val currentDestination = currentBackStack?.destination
}
}
currentBackStack?.destination
会返回NavDestination
,如需重新正确更新currentScreen
,您需要想法设法将返回值NavDestination
与Rally的三个主要屏幕可组合项之一进行匹配。您必须确定当前显示的目的地,以便随后将这些信息传递给RallyTabRow
。如前所述,每个目的地都有一条唯一的路线,因此我们可以使用此String路线作为类似ID,以进行严格的对比并找到唯一匹配。
如需更新currentScreen
,您需要迭代rallyTabRowScreen
列表,以找到匹配路线,然后返回对应的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
,因此您可以运行应用,并验证标签页栏界面现在是否已相应更新。
五、将RallyDestination中提取屏幕可组合项
到目前为止,为简单起见,我们一直使用RallyDestination
接口中的screen
属性以及通过该属性扩展的屏幕对象,将可组合界面添加到NavHost(RallyActivity.kt)
中:
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界面相关的信息分离。
打开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
}
object Overview: RallyDestination {
override val icon = Icons.Filled.PieChart
override val route = "overview"
}
再次运行应用,并验证一切是否像先前一样正常运行。现在,您已经完成此步骤,接下来可以在可组合屏幕中设置点击事件了。
5.1、对OverviewScreen启用点击
目前,OverviewScreen
中的所有点击事件都已被忽略。也就是说,“Accounts”和“Bills”的子部分“SEE ALL”按钮可供点击,但实际上不会跳转到任何位置。此步骤的目的是为了这些点击事件启用导航。
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
实例。此外,改为传递回调还可让您快速更改点击事件!