ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Compose Navigation实战:两层NavHost、转场动画与深链传参完整指南

Compose Navigation实战:两层NavHost、转场动画与深链传参完整指南 网上的Compose Navigation示例很多但绝大多数都停在“点按钮跳下一页”的最小演示。真正把它放进业务项目里麻烦立刻变多底部导航和全屏详情页怎么共用一套路由动画切换时旧页面还在组合状态会不会丢深链带参数进来之后返回键会不会多跳一层我前后踩了小一周这次直接把一套“带动画完整功能”的项目源码拆开讲覆盖启动页、底部导航、详情页跳转、深链传参、权限联动和全套转场动画代码可以直接往新项目里搬。1. 动手前的关键一步把路由表设计成业务页面的样子1.1 功能清单与页面字段写导航代码之前我习惯先把整张路由表画出来。示例项目模拟的页面结构和真实App几乎一样路由页面参数说明splash启动页无图片淡入加缩放随后进入主界面main主容器无负责承载底部导航栏和三个标签页home首页标签无列表页可点击进入详情orders订单标签无订单列表profile我的标签无包含权限申请入口detail?itemId{itemId}详情页itemId从首页跳入支持深链permission权限页无演示运行时权限与导航联动这里有一个很重的设计决定我没有把底部三个标签平铺在最外层NavHost里而是让main成为独立容器再由它嵌套一个内部NavHost。外层NavHost负责全屏路由启动页、主容器、详情、权限内层NavHost只管理标签切换。如果你把三个标签直接放在外层NavHost就会出现两个典型问题进入详情页时底部栏还挂在那标签切换后滚动位置容易丢。拆成两层之后详情页天然就是全屏底部栏只在主容器里存在每个标签的返回栈也由内部NavHost自己管理互不污染。1.2 依赖版本与选择理由完整路由表确定后先把依赖固定下来。推荐使用当前较新的组合动画API和导航API的配合会比较稳定// app/build.gradle.kts dependencies { val composeBom platform(androidx.compose:compose-bom:2024.12.01) implementation(composeBom) implementation(androidx.navigation:navigation-compose:2.8.5) implementation(androidx.activity:activity-compose:1.10.1) implementation(androidx.compose.material3:material3) implementation(androidx.compose.animation:animation) }navigation-compose:2.8.5支持在NavHost里直接写全局enterTransition、exitTransition、popEnterTransition和popExitTransition不需要再自己包一层AnimatedContent。compose-bom用来统一Compose相关库版本避免动画库和基础库版本不匹配导致编译期找不到新API。老项目如果还在用2.4之前的导航版本建议尽快升上来因为旧版本只能通过composable块内的AnimatedVisibility做变通写起来绕且容易出怪问题。Build之后记得打开android:enableOnBackInvokedCallbacktrue不用那是另一个话题。这里只需要在MainActivity里保证使用enableEdgeToEdge或默认主题即可动画不依赖这个设置。2. NavHost的全局转场先跑通一套默认动画再逐页覆盖2.1 全局过渡参数的正确姿势NavHost本身支持在四个方向统一设置转场动画。我的示例里把全局动画设定为“淡入加轻微右移”这是日常列表跳详情最不出错的一套组合NavHost( navController rootNavController, startDestination splash, modifier Modifier.fillMaxSize(), enterTransition { fadeIn(animationSpec tween(320, easing FastOutSlowInEasing)) slideInHorizontally( initialOffsetX { width - width / 5 }, animationSpec tween(320, easing FastOutSlowInEasing) ) }, exitTransition { fadeOut(animationSpec tween(300)) }, popEnterTransition { fadeIn(animationSpec tween(300)) }, popExitTransition { fadeOut(animationSpec tween(300)) slideOutHorizontally( targetOffsetX { width - width / 5 }, animationSpec tween(300) ) } ) { // 各路由 registração }这里有个参数容易看混slideInHorizontally的lambda接收的是当前目标区域的宽度返回的是新页面从哪个位置开始滑动。我写成width - width / 5表示新页面从右侧五分之一的位置滑进比直接{ it }从屏幕外滑入柔和很多。如果你希望做“从右侧完全滑入”的手势效果再把width / 5换成width即可。另外要注意popExitTransition里的slideOutHorizontally方向。页面返回时当前页面应该让出位置所以目标偏移量是width / 5还是-width / 5取决于你要“右退”还是“左退”。常见的返回动效是当前页向右滑动退出所以我用正数如果你做的是iOS风格左滑返回改成负数并且把popEnterTransition里的新页面偏移量设成负数让上一页从左侧回来。括号里的方向不匹配返回时会出现两页往同一个方向跑视觉上很怪。2.2 单页覆盖启动页就适合用缩放代替滑动全局动画不可能适配每个页面。启动页通常不是从左往右推入而是Logo放大淡入所以我单独给splash路由写了覆盖动画composable( route splash, enterTransition { fadeIn(animationSpec tween(700)) scaleIn( initialScale 0.8f, animationSpec tween(700, easing FastOutSlowInEasing) ) }, exitTransition { fadeOut(animationSpec tween(400)) } ) { SplashScreen(onFinish { ... }) }scaleIn配合FastOutSlowInEasing会比均匀速率的缩放更细腻。如果你希望Logo弹出来后有轻微回弹感可以在spring动画上再包一层但动画时长整体控制在每秒内更符合启动页的节奏别把转场拖到1.5秒以上。2.3 push与pop的方向最容易出错的地方很多同学在写详情页转场时把exitTransition和popExitTransition写反最终表现就是进详情时旧页面和新页面一起向右挪返回时又一起向左挪。我通常这样备注enterTransition新页面入栈时新页面的进入动画。exitTransition新页面入栈时旧页面的退出动画。popEnterTransition返回时被压在下面的上一页重新出现的动画。popExitTransition返回时当前详情页离场动画。想让详情页像从右侧推进来、返回时向右退出代码是这样composable( route detail?itemId{itemId}, enterTransition { fadeIn(tween(400)) slideInHorizontally(initialOffsetX { width - width }, tween(400)) }, exitTransition { fadeOut(tween(300)) slideOutHorizontally(targetOffsetX { width - -width }, tween(300)) }, popEnterTransition { fadeIn(tween(300)) slideInHorizontally(initialOffsetX { width - -width }, tween(300)) }, popExitTransition { fadeOut(tween(400)) slideOutHorizontally(targetOffsetX { width - width }, tween(400)) } )熟悉之后你会发现它就是一组对称参数enter的新页面从右边来popExit的旧页面往右边走exit的旧页面往左让位popEnter的上一页从左边回来。方向反了画面就会乱。3. 完整项目实操启动页动画、底部导航和两层NavHost3.1 启动页动画播放和导航跳转的时序启动页的常见坑是动画播到一半就跳转或者跳转时没有把启动页从返回栈清掉。我写了一个带入场动画的SplashScreen跳转时机放在动画基本结束后Composable fun SplashScreen(onFinish: () - Unit) { var visible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { delay(900) visible true } LaunchedEffect(visible) { if (visible) { delay(600) onFinish() } } AnimatedVisibility( visible visible, enter fadeIn(tween(600)) scaleIn(initialScale 0.8f, animationSpec tween(600, easing FastOutSlowInEasing)) ) { Column( modifier Modifier.fillMaxSize(), horizontalAlignment Alignment.CenterHorizontally, verticalArrangement Arrangement.Center ) { Text( text Compose Demo, style MaterialTheme.typography.headlineMedium ) Text( text 动画导航完整示例, style MaterialTheme.typography.bodyMedium ) } } }注意时序ready状态延迟900毫秒才变为trueAnimatedVisibility播放600毫秒入场再延迟600毫秒后触发onFinish。为什么要多等这600毫秒因为AnimatedVisibility和NavHost的动画是两套独立的动画系统如果你在AnimatedVisibility还在入场时就调用navigate两个动画会叠加画面会闪白或抖。等入场动画基本静止再跳过渡会干净很多。外层NavHost里调用跳转时必须清掉启动页SplashScreen(onFinish { rootNavController.navigate(main) { popUpTo(splash) { inclusive true } } })popUpTo(splash) { inclusive true }的意思是从返回栈里把splash连同它上面的所有页面一起弹出。这样用户在主界面按返回键不会回到启动页而是直接退出App。3.2 底部导航为什么需要单独套一层NavHost主容器MainScreen内部有自己的NavHost专门管理三个标签页。这样的设计最直观的好处是标签切换、滚动位置、每个标签的返回栈互不影响详情页跳转又交给外层NavHost不会出现底部栏跟着详情页一起出现的尴尬。Composable fun MainScreen( openDetail: (Long) - Unit, openPermission: () - Unit ) { val tabNavController rememberNavController() val backStackEntry by tabNavController.currentBackStackEntryAsState() val currentRoute backStackEntry?.destination?.route?.substringBefore(?) val tabs listOf( TabItem(route home, label 首页, icon Icons.Default.Home), TabItem(route orders, label 订单, icon Icons.Default.List), TabItem(route profile, label 我的, icon Icons.Default.Person) ) Scaffold( bottomBar { NavigationBar { tabs.forEach { tab - NavigationBarItem( selected currentRoute tab.route, onClick { tabNavController.navigate(tab.route) { popUpTo(tabNavController.graph.findStartDestination().id) { saveState true } launchSingleTop true restoreState true } }, icon { Icon(tab.icon, contentDescription tab.label) }, label { Text(tab.label) } ) } } } ) { innerPadding - NavHost( navController tabNavController, startDestination home, modifier Modifier .fillMaxSize() .padding(innerPadding), enterTransition { fadeIn(tween(220)) slideInHorizontally { width - width / 8 } }, exitTransition { fadeOut(tween(200)) }, popEnterTransition { fadeIn(tween(200)) }, popExitTransition { fadeOut(tween(220)) } ) { composable(home) { HomeTabScreen(onItemClick openDetail) } composable(orders) { OrdersTabScreen() } composable(profile) { ProfileTabScreen(onPermissionClick openPermission) } } } }currentRoute backStackEntry?.destination?.route?.substringBefore(?)这个细节很关键带参数的路由如果直接拿原始route比较会遇到home和home?xxx不相等的问题。我把问号后面的参数截掉再比这样底部栏高亮才能稳定对应到当前标签。在首页列表点击某个item时回调里直接调外层rootNavController.navigate(detail?itemId$id)。因为这是在嵌套的MainScreen里捕获到的事件通过openDetail传上来的所以详情页会出现在外层返回栈底部栏不会出现在详情页底部。3.3 标签页状态保持saveState与restoreState的配合底部导航最常见的需求是用户从首页滚到第100条切到订单页再切回来首页的位置还在。上面的代码里已经写到了关键三件套tabNavController.navigate(tab.route) { popUpTo(tabNavController.graph.findStartDestination().id) { saveState true } launchSingleTop true restoreState true }解释一下它的作用当你切到订单页时当前首页标签的整个状态被saveState保存再切回首页时restoreState把它恢复。launchSingleTop避免连续点击底部栏同一个标签时重复压栈。没有这三句话标签切换每次都会创建新的页面实例滚动位置自然就丢了。如果你还需要每个标签拥有独立的返回栈比如首页里进入二级页面后切到订单再切回首页还希望停留在二级页面可以改用navigation()子图把每个标签配置成独立NavGraph。不过多数内容型应用只需要“保存滚动位置状态”这一层上面这套写法已经够用也更好理解。4. 深链、权限请求与返回栈的完整配合4.1 深链传参从链接直达详情页详情页路由支持深链像https://demo.example.com/products/42这样的链接可以直接打开商品详情。导航侧配置如下composable( route detail?itemId{itemId}, arguments listOf( navArgument(itemId) { type NavType.LongType } ), deepLinks listOf( navDeepLink { uriPattern https://demo.example.com/products/{itemId} } ) ) { backStackEntry - val itemId backStackEntry.arguments?.getLong(itemId) ?: -1L DetailScreen(itemId itemId) }同时需要在AndroidManifest.xml里给MainActivity加过滤规则activity android:name.MainActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.VIEW / category android:nameandroid.intent.category.DEFAULT / category android:nameandroid.intent.category.BROWSABLE / data android:schemehttps android:hostdemo.example.com android:pathPrefix/products / /intent-filter /activity这里有个容易忽略的点uriPattern里的{itemId}会被自动转换成参数但类型必须和arguments里定义一致。我在示例里用的是Long如果你链接里传的是42导航库会把它解析为Long如果链接里传了abc则转化会失败页面打不开。生产项目中我通常会在DetailScreen入口做一次空值保护避免非法链接直接把App打崩。4.2 深链导致的返回栈问题深链虽然方便却有一个常见的返回栈坑如果你通过链接进入详情页而当前返回栈里并没有首页用户按返回键时可能会跳回启动页甚至黑屏。原因在于导航库默认把深链目标加在现有栈顶没有处理栈里缺页面的情况。处理思路也很简单在composable(detail)里不要急着渲染页面先判断当前栈里是否有main。如果没有就通过额外导航把main插入到详情页下面。我常用的一种做法是在确认深链进入后调用rootNavController.navigate(main) { popUpTo(rootNavController.graph.startDestinationId) { inclusive false } } rootNavController.navigate(detail?itemId$itemId)这样返回时才会先回到主界面而不是落到不存在的位置。如果不处理深链返回栈用户反馈只会是“打开链接后按返回直接退出了”排查起来还很隐蔽。4.3 权限请求页面和导航联动权限请求我单独做了一个permission路由这样它也能享受NavHost的动画。页面内部用rememberLauncherForActivityResult请求相机权限并在授权结果里返回上一页Composable fun PermissionDemoScreen( onBack: () - Unit ) { val permission Manifest.permission.CAMERA val permissionLauncher rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted - if (granted) { // 已授权回到上一页 onBack() } else { // 未授权可展示提示文案 } } Column( modifier Modifier.fillMaxSize().padding(24.dp), horizontalAlignment Alignment.CenterHorizontally, verticalArrangement Arrangement.Center ) { Text(申请相机权限用于拍摄商品图) Spacer(Modifier.height(16.dp)) Button(onClick { permissionLauncher.launch(permission) }) { Text(申请权限) } } }这里没有在NavHost外层反复请求权限而是把跳转交给rootNavController.navigate(permission)授权完成后由onBack()调用rootNavController.popBackStack()。这种做法的好处是权限状态的UI上下文可以和导航栈保持一致避免在页面A弹授权框、授权回调却发生在页面B之类的时序错乱。5. 动画和导航很顺但这些设计细节仍然绕不开5.1 连续点击导致跳转多次动画让界面看起来流畅了但用户高速点击列表项时navigate会被触发多次导致详情页叠了好几层。在navigate时加上launchSingleTop true并配合返回栈清理rootNavController.navigate(detail?itemId$id) { launchSingleTop true }launchSingleTop保证如果目标页面已经在栈顶就不会重复创建新实例。如果需求是“详情页已经存在就把它上面的页面弹掉并复用”可以再加popUpTo(detail?itemId$id) { inclusive false }。5.2 Android 12 系统启动屏与自定义启动动画从Android 12开始系统默认有一套启动画面SplashScreen API。如果你继续在onCreate里用旧方式显示自定义启动页容易和系统启动动画叠加出现两段式闪烁。最简单的做法接入androidx.core:core-splashscreen先配置系统启动屏的主题再在内容里保留我们的Compose启动页动画。也可以把动画时间控制在700毫秒左右尽量减少察觉度。示例项目里的SplashScreen仍然保留因为业务层面经常需要展示Logo、版本号或一句欢迎语这在系统启动屏里不方便处理。只要确保Compose启动页的入场动画和系统启动屏的退场时间错开视觉上就不会打架。5.3 低端机动画掉帧与性能控制动画变多以后低端机的掉帧问题会成为用户主诉。以下几个点是我在真机上反复验证过的scaleIn、scaleOut会触发内容重新绘制比纯位移更耗GPU。启动页可以用列表进详情不建议整套都用缩放。转场过程中尽量避免大面积的graphicsLayer阴影和毛玻璃某些国产ROM在Compose动画中的模糊处理开销特别大。动画时长尽量保持在200到400毫秒。超过600毫秒会明显拖慢页面切换速度尤其底部标签切换。如果某个页面里的列表在转场期间明显卡顿可以在内部列表项加上Modifier.graphicsLayer { alpha 1f }提前创建图层减少转场时的合成压力。不过这个方法需要谨慎使用过度滥用反而增加内存。5.4 调试动画状态把系统动画时长开到10倍最后分享一个我很常用的调试手法打开开发者选项里的“动画时长缩放”并设为10x。这样每次导航动画都会被放慢你可以清晰看到旧页面是在哪个阶段退场、新页面何时入场、返回栈里的上一页是否正常恢复。之前我在排查popEnterTransition方向问题时就是用这个方式一帧一帧看出来的。配合Log.d在NavHost的onTransition相关回调里打印当前生命周期状态基本能快速定位大部分动画问题。这套示例代码重在结构清晰动画参数没有追求夸张效果全部以稳定优先。实际项目中你可以按需求把详情页转场改成SharedElementTransition或者给标签页加上带位移的AnimatedContent。先跑通这套完整流程后面加花活会从容很多。
返回列表