Navigation 3的用法和Navigation 2大体相似,但增加了许多新功能,比如大屏模式适配等。由于Navigation 3刚推出,目前还处于alpha版阶段,API不够稳定,后期可能存在变动。下面以Navigation 2的用法进行讲解。
首先,想使用Navigation组件,要先添加必要的依赖库:
dependencies {
implementation "androidx.navigation:navigation-compose:2.9.3"
}
接下来修改MyApp()函数中的代码,如下:
const val SCREEN_A_ROUTE = "screen_a"
const val SCREEN_B_ROUTE = "screen_b"
const val SCREEN_C_ROUTE = "screen_c"
@Composable
fun MyApp(modifier: Modifier = Modifier) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = SCREEN_A_ROUTE,
modifier = modifier
) {
composable(route = SCREEN_A_ROUTE) {
ScreenA(onClick = {
navController.navigate(SCREEN_B_ROUTE)
})
}
composable(route = SCREEN_B_ROUTE) {
ScreenB(onClick = {
navController.navigate(SCREEN_C_ROUTE)
})
}
composable(route = SCREEN_C_ROUTE) {
ScreenC(onClick = {
navController.navigate(SCREEN_A_ROUTE)
})
}
}
}
这段代码不长,是简化后包含Navigation基本用法的最小代码示例。
使用Navigation,首先要创建NavController对象,可借助rememberNavController()函数创建。通常要将NavController对象放在整个Compose的最顶层结构中,这样它能管理整个App所有页面的导航与跳转,同时这种写法也最符合Compose提倡的状态提升原则。
接着,调用NavHost()函数,将刚才创建的NavController对象作为参数传入。NavHost()还要求传入startDestination参数,这个参数指定程序刚启动时,App应展示的首个页面。上述代码中,传入的字符串常量代表ScreenA页面。
这就是Navigation中的“route”概念,即路由。在Navigation 2中,页面的路由定义很简单,就是一个字符串,但要保证字符串唯一。而在Navigation 3中,路由的定义方式会更丰富,这部分我们之后的文章再讲。
最后,要在NavHost()函数的闭包中定义每个路由字符串与对应Compose页面的映射关系。比如,遇到路由A,就显示ScreenA页面;遇到路由B,就显示ScreenB页面。
Navigation的组件定义到这里就完成了,但别忘了处理页面跳转逻辑。点击按钮时,调用NavController的navigate()函数,传入对应页面的路由字符串就行。
上述代码的写法是在每个页面(ScreenA、ScreenB、ScreenC)中单独处理导航事件,所以它们都要在各自按钮的点击事件回调中调用NavController的navigate()函数。
如果想在一个统一的函数中专门处理这些页面的跳转事件,该怎么做?
首先,得知晓当前页面的路由。只有知道当前在哪个页面,才能确定要跳转到的下一个页面。
在Navigation库中,可借助NavDestination获取当前页面的路由,代码如下:
navController.currentDestination?.route
有了当前页面的路由值,就能按如下方式修改刚才的代码:
@Composable
fun MyApp(modifier: Modifier = Modifier) {
val navController = rememberNavController()
val onClick = {
when (navController.currentDestination?.route) {
SCREEN_A_ROUTE -> navController.navigate(SCREEN_B_ROUTE)
SCREEN_B_ROUTE -> navController.navigate(SCREEN_C_ROUTE)
SCREEN_C_ROUTE -> navController.navigate(SCREEN_A_ROUTE)
}
}
NavHost(
...
) {
composable(SCREEN_A_ROUTE) {
ScreenA(onClick = onClick)
}
composable(SCREEN_B_ROUTE) {
ScreenB(onClick = onClick)
}
composable(SCREEN_C_ROUTE) {
ScreenC(onClick = onClick)
}
}
}
这种写法也能实现和之前完全一样的页面跳转效果。
如果想在Composable函数中获取当前页面的路由值,而非在点击事件的回调中获取,就得使用带State版的NavDestination API。基本语法如下:
val currentBackStack by navController.currentBackStackEntryAsState()
currentBackStack?.destination?.route
这里拿到的当前页面路由是带状态的,能直接在Composable函数中使用。比如下面的代码:当访问页面C时,弹出一段Toast提示。
@Composable
fun MyApp(modifier: Modifier = Modifier) {
val navController = rememberNavController()
val currentBackStack by navController.currentBackStackEntryAsState()
if (currentBackStack?.destination?.route == SCREEN_C_ROUTE) {
Toast.makeText(LocalContext.current, "You are in Screen C", Toast.LENGTH_SHORT).show()
}
...
}
大家都知道,Activity有4种launchMode,除了standard,还有singleTop、singleTask和singleInstance。
使用Navigation导航,也能实现类似launchMode的效果。
比如,在singleTask模式下,按同样顺序进行页面跳转:ScreenA -> ScreenB -> ScreenC -> ScreenA。
从ScreenC跳转到ScreenA时,系统会发现当前返回栈中已有一个ScreenA,不会再创建新的ScreenA,而是将ScreenC和ScreenB从返回栈中依次弹出,让返回栈最上面的页面变成ScreenA。
这是在Activity中用singleTask模式导航实现的效果,下面我们在Compose中借助Navigation选项实现类似效果:
@Composable
fun MyApp(modifier: Modifier = Modifier) {
val navController = rememberNavController()
val onClick = {
when (navController.currentDestination?.route) {
SCREEN_A_ROUTE -> navController.navigate(SCREEN_B_ROUTE)
SCREEN_B_ROUTE -> navController.navigate(SCREEN_C_ROUTE)
SCREEN_C_ROUTE -> navController.navigate(SCREEN_A_ROUTE) {
popUpTo(SCREEN_A_ROUTE)
}
}
}
...
}
观察上述代码:从ScreenC跳转到ScreenA时,通过闭包增加了一个Navigation选项——popUpTo(SCREEN_A_ROUTE)。这是什么意思?
popUpTo()的作用是:调用navigate()函数跳转到ScreenA时,不是从当前的ScreenC直接跳转,而是先执行导航出栈操作,一直出栈到popUpTo()函数传入参数对应的页面,之后再跳转到ScreenA。
这个过程和刚才Activity中singleTask模式的导航效果类似。
我们已成功实现用Navigation进行页面跳转,但目前只能单纯跳转页面。如果想在页面跳转的同时携带参数,该怎么做?
这个需求在Activity中很常见,也很简单,因为Activity用Intent跳转,而Intent本身就能传递参数。相比之下,在Compose中用Navigation同时携带参数会稍显麻烦,但不用着急,现在我们就解决这个问题。
这里用最简化的例子演示Navigation参数传递功能:从页面A跳转到页面B时,携带一个字符串参数和一个整型参数。
首先,要让页面B能接收这个字符串参数和整型参数。 修改ScreenB()函数的代码,如下:
@Composable
fun ScreenB(
modifier: Modifier = Modifier,
name: String? = null,
age: Int? = null,
onClick: () -> Unit
) {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "Screen B", fontSize = 50.sp)
Button(onClick = onClick) {
Text(text = "Go to Next Screen", fontSize = 20.sp)
}
if (name != null) {
Text(text = "Name: $name", fontSize = 20.sp)
}
if (age != null) {
Text(text = "Age: $age", fontSize = 20.sp)
}
}
}
能看到,给ScreenB新增了name参数和age参数,这两个参数都可为空。如果参数不为空,就将参数内容显示在界面上。
ScreenB虽已支持接收额外参数,但还不够,还得在Navigation的路由定义层面让ScreenB支持参数传递。 接着修改NavHost中的代码,如下:
NavHost(
navController = navController,
startDestination = SCREEN_A_ROUTE,
modifier = modifier
) {
...
composable(
"${SCREEN_B_ROUTE}/{name}/{age}",
arguments = listOf(
navArgument("name") { type = NavType.StringType },
navArgument("age") { type = NavType.IntType }
)) { navBackStackEntry ->
val name = navBackStackEntry.arguments?.getString("name")
val age = navBackStackEntry.arguments?.getInt("age")
ScreenB(name = name, age = age, onClick = onClick)
}
...
}
这段代码是整个Navigation参数传递部分较复杂的部分,但因为用的是极简示例,理解起来相对容易。
首先看路由定义层面:之前为每个页面定义路由用的是一个字符串,现在还是一个字符串,但可通过斜杠(/)增加对参数的支持。上述代码中,就是在路由上增加了name和age两个参数。注意,参数一定要用{}包裹。
不过,在字符串中指定额外参数后,无法指定这些参数的数据类型。因此要给composable函数传递arguments参数,构建一个list集合,然后分别调用navArgument()给每个路由参数指定数据类型。
接下来,借助NavBackStackEntry获取传入的name和age参数内容,再传递给ScreenB就行。
路由定义完成后,最后一步:在执行页面跳转的地方传入name和age参数值。 修改页面A点击事件处的代码,如下:
@Composable
fun MyApp(modifier: Modifier = Modifier) {
val navController = rememberNavController()
val onClick = {
when (navController.currentDestination?.route) {
SCREEN_A_ROUTE -> {
val name = "Hello world"
val age = 100
navController.navigate("${SCREEN_B_ROUTE}/$name/$age")
}
}
}
...
}
这里从页面A跳转到页面B时,在navigate()函数中指定了name和age两个参数。
最后介绍Navigation对deep link的支持。deep link能直接打开App中的指定页面,不用先从App首页开始一层层点击进入目标页面,这个功能在Activity模式的页面导航中很常见。
在Compose中也能实现deep link功能,Navigation还专门为deep link功能加入了相应的API支持。学完Navigation传递参数后,再学deep link会很简单,因为它们的用法比较相似。
下面我们尝试让页面B能通过deep link链接直接打开。
首先,要让当前的Activity支持deep link,需先修改AndroidManifest.xml文件的配置,如下:
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.NavigationTest">
<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="myapp" android:host="screen_b" />
</intent-filter>
</activity>
这里给MainActivity增加了一个新的intent-filter,通过标签指定了myapp的scheme和screen_b的host。MainActivity能支持类似myapp://screen_b这种格式的deep link。
接下来的步骤很关键:在ScreenB的路由定义处给它增加deep link支持,代码如下:
composable(
"${SCREEN_B_ROUTE}/{name}/{age}",
arguments = listOf(
navArgument("name") { type = NavType.StringType },
navArgument("age") { type = NavType.IntType }
),
deepLinks = listOf(
navDeepLink { uriPattern = "myapp://${SCREEN_B_ROUTE}/{name}/{age}" }
)) { navBackStackEntry ->
val name = navBackStackEntry.arguments?.getString("name")
val age = navBackStackEntry.arguments?.getInt("age")
ScreenB(name = name, age = age, onClick = onClick)
}
能看到,composable函数可传递deepLinks参数,显然这是用于给当前页面增加deep link支持的。 然后调用navDeepLink函数给ScreenB创建deep link格式:
myapp://${SCREEN_B_ROUTE}/{name}/{age}
这样,用如下链接就能直接打开页面B,还能额外给页面B传递参数:
myapp://screen_b/Tom/35
测试方式有多种:可制作简单的测试网页,配置好上述超链接,点击后查看能否直接打开页面B;也能更简单些,直接用下面的adb命令测试:
adb shell am start -d "myapp://screen_b/Tom/35" -a android.intent.action.VIEW
