NavigationJetpack组件中的一个成员,主要作用是用于页面间的导航跳转。在很早之前谷歌就在推单ActivityFragment架构,而Navigation就是用于管理Fragment的跳转的,当时是使用xml来声明导航图进行导航的,再加上没有必要对项目进行太大的改造,就一直没有去学习其使用的方式。而随着Coompose的逐渐完善,谷歌也对Navigation进行了适应性开发,使其也能支持Compose中的页面跳转,因此Navigation就成了必须要学习的了。

引入Navigation

引入Navigation比较简单,直接添加依赖就行,注意要添加对应的Compose版本。截止到最新,已经是2.9.0版本了,使用该版本对CompileVersionAPG版本都有要求,引入时可以直接进行同步编译,然后根据错误提示升级对应的其他插件的版本。

1
2
3
4
5
dependencies {
...
val nav_version = "2.9.0"
implementation("androidx.navigation:navigation-compose:$nav_version")
}

简单使用

使用起来和其他导航组件是一样的,都是先定义导航图,然后根据key去进行跳转。这里导航图是通过NavHost进行创建的,跳转是通过NavController去实现的。因此,我们需要在可组合函数的最顶层中定义出这两个成员,然后将NavController传递给每一个界面,这些界面就通过它来进行跳转。

首先要定义不同的导航路径,该路径就是每个页面的路径,就类似于Key的作用,用来标识每个界面。可以使用一个具体的类名来标识,也可以使用一个字符串来标识。一般我们使用字符串进行标识,因为使用类的话,需要为每个界面单独创建一个类,太过于浪费资源了。

1
2
3
4
5
object Graph {
const val HOME = "home"
const val ME = "me"
const val DETAIL = "detail"
}

导航图通过NavHost创建,即将对应的路径与页面进行关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
// 创建的navController用于控制页面跳转,需要将其传递给每个页面
val navController = rememberNavController()
// 创建导航图,表明开始路径
NavHost(navController, startDestination = Graph.HOME) {
// 每个路径对应的页面
composable(route = Graph.HOME) {
HomeScreen(navController)
}
// 每个路径对应的页面
composable(route = Graph.ME) {
MeScreen(navController)
}
// 每个路径对应的页面
composable(route = Graph.DETAIL) {
DetailScreen(navController)
}
}
}
}
}

以上就是定义导航图的方式,用起来还是比较简单的,后续只需要关注这几个界面即可,并且由于startDestinationHome,因此当我们启动这个MainActivity时,显示的界面就是HomeScreen界面。如果我们要跳转,可以通过navigation方法进行跳转,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Composable
fun HomeScreen(navController: NavController) {
Text(
text = "首页",
modifier = Modifier
.fillMaxSize()
.padding(20.dp)
.clickable(true) {
// 点击时跳转到ME界面
navController.navigate(Graph.ME)
},
color = Color.Blue,
fontSize = 20.sp,
)
}

跳转通过navController.navigate跳到指定的界面,然后通过navController.navigateUp返回。当然,按下返回键实际上也相当于执行了navigateUp,会进行返回。

到这里,我们其实已经掌握了最简单的使用方式了,即定义导航图,然后进行跳转和返回。但实际中我们肯定不会这么简单的,因为会涉及到参数的传递、ViewModel的使用、界面跳转的动销等等,接下来我们继续看下详细的功能。

NavHost是用来创建导航图的,它本质上是个接口类,当然我们并不需要关注它的类型,我们直接使用对应的方法即可。在NavHost.kt中,提供了一个方法来创建导航图,与接口类的名称是一样的,方法名也是NavHost。我们需要关注的是它的方法参数。

  • navController 导航控制器,与当前NavHost关联的控制器,当前host中定义的界面就是通过该控制器进行跳转返回等操作。
  • startDestination起始的导航页,注意这里类型在不同的函数重载中可以为String也可以为KClass类型,具体要根据自己的定义导航的方式。
  • modifier页面参数修改器,该修改器影响的是路由的整个界面。
  • contentAlignment 注释说是AnimatedContent的对其方式,但是AnimatedContent自己已经有这个参数了,它为啥还要用你提供的呢?
  • route暂未发现有什么用,网上说是用于多个NavHost之间跳转,实测不行。
  • enterTransition进入界面的切换动画、exitTransition原界面的消失动画、popEnterTransition返回时进入界面的动画、popExitTransition返回时原界面的消失动画。如果不指定的话,popEnterenter的动画是一致的,popExitexit的动画是一致的。举个例子:A界面跳转到B界面,此时A界面执行exit动画,B界面执行enter动画;然后从B界面返回到A界面时,B界面执行popExit动画,A界面执行popEnter动画。
  • sizeTransform动画过程中的控制
  • builder创建对应的界面

以上就是创建NavHost的参数,我们重点关注的就是起始页以及四个动效,从而控制我们页面的跳转动画。在最后一个参数builder中,我们需要创建对应的界面,通常我们使用composable方法来声明一个普通界面,通过dialog声明一个对话框界面。

NavHost中,我们还可以使用navigation将一组composable整合起来,这样做可以方便我们进行模块划分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
NavHost(...) {
// 单独的界面
composable(route = Graph.HOME) {
...
}
composable(route = Graph.ME) {
...
}

//组合起来的界面
navigation(
route = "Main",
// 内部的起始界面
startDestination = "pageA"
) {
composable(route = "pageA") {
...
}
composable(route = "pageB") {
...
}
}
}

如上代码,在NavHost除了普通的composable界面外,还使用navigation将一组界面包含在了一块,即嵌套界面。注意我们可以从外层的界面跳转到嵌套内部的界面,但是不能从内部的界面跳到外部的界面。

1
2
// 跳转到内部的界面,此时是pageA界面
navController.navigate("Main")

这种模式适合对某个特定功能封装的情况,即某个功能模块的界面都封装在一块。

composable

NavHost定义界面时,使用composable方法定义一个界面,使用dialog方法定义一个对话框界面,实际上我们甚至可以在composable中不去定义对应的界面,而是直接跳转到其他的Activity,当然我们一般不这样用。

1
2
3
4
5
6
7
8
9
10
11
12
public fun NavGraphBuilder.composable(
route: String,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
enterTransition:.. = null,
exitTransition:.. = null,
popEnterTransition:.. = enterTransition,
popExitTransition:.. = exitTransition,
sizeTransform:.. = null,
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit
) {
}

稍微简化下可以看到它的定义中,有我们前面了解的那四个动画以及一个sizeTransfrom,前面在NavHost中定义的动画属于全局动画,也就是它所有的界面都会应用这四个动画,但如果想要与众不同的话,则需要在composable中设置单属于自己这个界面的动画。

arguments

除了这五个参数外,我们需要关注的就是route,这是当前界面的路由地址,通过该route就可以跳转到这个界面。然后另一个参数就是arguments,正常我们跳转界面都会携带参数,在Activity中我们使用Intent传递参数,在这里肯定不能直接使用Intent了,而是使用route+arguments来传递参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
NavHost(...) {
// 定义一个界面,界面路由为Graph.ME常量字符串
composable(
route = "${Graph.ME}/{uid}/{uname}?sex={sex1}&age={age}",
arguments = listOf(
// uid的参数描述,注意名字和占位符保持一致
navArgument("uid") {
type = NavType.IntType
},
// 注意名字是占位符内的sex1,而不是前面的sex,当然如果保持一致也是可以的
navArgument("sex1") {
type = NavType.StringType
nullable = true
defaultValue = "未知"
}
)
) { entry->
// 获取到传递的参数
Log.d(TAG, entry.arguments?.getInt("uid"))
Log.d(TAG, entry.arguments?.getString("uname"))
// 注意!!查询的key不是sex,而是占位符sex1
Log.d(TAG, entry.arguments?.getString("sex1"))
//
MyScreen()
}
}

// 其他地方跳转到该界面
navController.navigate("${Graph.ME}/100/张三?sex=男&age=20")

可以看到在传递参数时,参数列表是已经定义在composable中的,主要集中在route中,表现形式类似于URL。其中可以将参数跟在主route后面,使用/的形式,然后参数使用占位符{}描述,如上面例子中的uiduname。还有一种形式的参数是通过?后面进行拼接的参数,如sex1age

定义完占位符后,需要在arguments数组中描述占位符的类型,可以通过navArgument方法快速定义。然后描述类型使用type表示类型,nullable表示参数是否可空,defaultValue表示默认值。

需要注意一点的是:对于路径占位符(即跟随在/后的占位符),不论是否可空,在跳转时都是不可省略的,因此不需要声明默认值defaultValue

1
2
3
4
5
6
// 例如有一个graph为pageA/name,并且在描述中将name设置为可空的
// 1.跳转必须带该参数
navController.navigate("pageA/张三")
// 2.参数即使为空也要添加
val nm = null
navController.navigate("pageA/$nm")

而对于 查询占位符(?后的占位符),如果将其设置为了可空的,则需要设置默认值,并且在跳转时参数可以省略不写。

1
2
3
4
5
6
7
8
// 例如有一个graph为pageB?name=${name},并且在描述中设置为可空的
// 1.正常跳转
navController.navigate("pageB?name=张三")
// 2.参数可以为空,查询到的也是null
val nm = null
navController.navigate("pageB?name=$nm")
// 3.直接忽略不写,查询到的是默认值
navController.navigate("pageB")

当参数名和占位符不一致时,查询需要查占位符,例如route=pageC?name={other},此时查询时需要以otherkey来获取参数值。

另外注意的就是占位符描述可以不加,即我在route中有定义占位符,但是我在arguments中不去声明这个占位符的类型等信息也是可以的。

deepLinks定义的是url类型的匹配符,即从网页端直接跳转到当前界面来。用法和原来的Activity一样,只是原来是从外部跳转到对应的Activity,而现在只有一个Activity了,就会跳到当前的Activity后,再由Navigation拦截并跳转到对应的界面而已。

1
2
3
4
5
6
7
8
9
composable(
...
deepLinks = listOf(
navDeepLink {
uriPattern = "test://mypage/{name}?age={age}"
}
)
) {
}

其中参数的传递仍然是以占位符的形式进行传递的,接下来就是在AndroidManifest中添加对应的filter

1
2
3
4
5
6
7
8
<activity
android:exported="true"
...
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="test" android:host="mypage" />
</intent-filter>

然后就可以响应外部的界面了,我们可以在其他html界面中嵌入<a href="test://mypage/wang?age=20">点击跳转</a>超链接,当点击超链接的时候就可以跳转到我们对应的界面了。或者测试用adb命令也是一样的:

1
adb shell am start -a android.intent.action.VIEW -d "test://mypage/wang?age=20" com.example.myapplication/.MainActivity

到这里NavHost相关的基本上都已经了解了,接下来我们继续看navController

NavController是用来控制跳转的,在创建NavHost的时候第一个参数就是它,后续需要把它传递到每个界面中,然后在对应的界面中通过它来跳转。同样的,我们也可以通过它来获取到Context以及对应的Activity

1
2
3
4
// 直接跳转
navController.navigate(Graph.ME)
// 返回
navController.navigateUp()

最简单的用法就是上面这种方法,注意在Navigation中也是存在任务栈的,和Activity的任务栈一样,当通过navigate()方法跳转时,会把跳转的界面入栈,此时栈内就有两个界面,一个是原来的界面,栈顶是新跳转的界面。如果要返回,可以通过navigateUp()方法出栈,或者直接按手机上的返回按钮,也是一样的逻辑。

也就是说,使用了Navigation后,每个composable界面和原来的Activity对应,界面的出栈入栈对应Activity的出栈入栈。而同样的,在每个composable获取的ViewModel也是一样会跟随界面的销毁而销毁。在composable界面中,通过方法viewModel()获取ViewModel实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Composable
fun PageA(navController: NavController) {
val viewmodel:PageViewModel = viewModel()
Button(onClick = {
// 点击按钮跳转到详情界面
navController.navigate(Graph.DETAIL)
}) {
Text(text = viewModel.buttonString)
}
}

@Composable
fun PageDetail(navController: NavController) {
val viewmodel:DetailViewModel = viewModel()
Button(onClick = {
// 点击按钮返回到上一级页面
navController.navigateUp()
}) {
Text(text = "详情界面")
}
}

如上述实例的两个界面,每个界面都有他们自己对应的ViewModel,当从PageA跳转到PageDetail的时候,PageA实际上还是在任务栈中没有销毁的,因此PageViewModel并不会销毁。而从PageDetail返回到PageA的时候,由于PageDetail界面被销毁了,因此它对应的DetailViewModel也会被销毁掉,和Activity的表现是一致的。

另外,我们可以使用popBackStack()来弹出多个界面。

1
2
3
4
// 弹出当前界面
navController.popBackStack()
// 弹出多个界面,直到目标界面
navController.popBackStack(Graph.ME, true)

对于带参数的popBackStack,第一个参数是目标界面,第二参数是否包含自己。例如当前的任务栈有由以下几个界面组成A->B->C->D,如果我们在D界面通过popBackStack(B, true),则会将DCB全部弹出,此时回到A界面,如果第二参数设置为false,表明不包含B,则只会弹出DC,此时回到B界面。

这是返回的操作退出任务栈,如果我们想在跳转的时候清除任务栈,例如登录成功后跳转到首页,此时就需要在跳转时就将登录界面弹出。

1
2
3
4
5
6
7
8
// 跳转到首页
navController.navigate(Graph.HOME) {
// 跳转前弹出界面到LOGIN界面
popUpTo(Graph.LOGIN) {
// 是否包含LOGIN界面
inclusive = true
}
}

由于我们可以轻易弹出界面,所以我们也可以来实现Activity的四种启动模式了。对于标准启动模式,我们什么都不需要做,默认就是标准启动模式。

对于singleTop模式,也是默认支持的,只需要我们在跳转时设置为singleTop即可。

1
2
3
navController.navigate(Graph.HOME) {
launchSingleTop = true
}

对于singleTask模式,我们需要手动弹出其他界面。

1
2
3
4
5
6
navController.navigate(Graph.ME) {
// 弹出Graph.ME上面的所有界面
popUpTo(Graph.ME) { inclusive = false }
// 设置为此次singleTop模式
launchSingleTop = true
}

但是对于singleInstance模式,似乎就无法完成了。

总结

至此,基本上已经了解了Navigation的使用方式了。实际上,在Compose中,Navigation是非常非常适合用来做路由的一个库,通过它我们可以以原来的Activity开发思想来进行设计,降低我们的学习曲线。关于Navigation的功能,总结下就是跳转返回、参数传递、任务栈管理。