Compose Navigation 3:共享元素转场 (Shared Element) 实战指南

本文旨在解决 Navigation 3 官方文档中关于 Shared Element 实现细节缺失的问题,提供基于 LocalNavAnimatedContentScope 的标准解决方案。

1. 核心背景与痛点

Google 于 2025 年发布的 Navigation 3 (1.0.0-beta01) 彻底重构了 Compose 的导航范式,引入了类型安全的 NavKey 和类似集合操作的返回栈管理。

然而,在追求极致 UI 体验时,开发者面临一个严重的文档缺失问题:官方文档仅介绍了 Nav 2 中的共享元素实现,却未提及如何在 Nav 3 的 NavDisplay 架构下获取关键的 AnimatedContentScope

本文将补全这一缺失环节,助你在 Nav 3 中实现如丝般顺滑的 Shared Element Transition。

2. 环境准备

在开始之前,请确保你的项目依赖已升级至支持 Navigation 3 的版本。

  • Navigation 3: androidx.navigation:navigation-compose:2.8.0-alpha08 或更高版本(对应文档提及的 Beta 阶段)。

  • Compose UI: 建议保持最新稳定版以获得最佳动画性能。

3. 架构差异分析

在 Navigation 2 中,我们习惯于直接从 composable 作用域中获取 AnimatedContentScope。但在 Navigation 3 中,NavDisplayentryProvider 并不直接暴露该作用域,导致 Modifier.sharedElement 缺少必要的参数。

解决方案核心: 利用 Navigation 3 引入的全新局部组合值 —— LocalNavAnimatedContentScope

4. 完整实现步骤

4.1 定义导航键 (NavKeys)

Navigation 3 推荐使用 Serializable 对象作为导航键,实现类型安全的参数传递。

 @Serializable
 data object NavHomePage : NavKey
 ​
 @Serializable
 data object NavNextPage(val id: String) : NavKey

4.2 构建 SharedTransitionLayout 容器

共享元素转场必须被包裹在 SharedTransitionLayout 中。我们将 NavDisplay 放置于此作用域内。

 @OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
 fun AppNavigation() {
     // 1. 创建 Nav 3 的返回堆栈
     val backStack = rememberNavBackStack(NavHomePage)
 ​
     SharedTransitionLayout {
         // this is SharedTransitionScope
 ​
         NavDisplay(
             backStack = backStack,
             onBack = { backStack.removeLastOrNull() }, // 像操作列表一样处理返回
             entryProvider = entryProvider {
                 
                 // 配置首页
                 entry<NavHomePage> {
                     HomeScreen(
                         sharedTransitionScope = this@SharedTransitionLayout,
                         onNavigateToDetail = { id -> 
                             backStack.add(NavNextPage(id)) // 类型安全入栈
                         }
                     )
                 }
 ​
                 // 配置详情页
                 entry<NavNextPage> { navKey ->
                     DetailScreen(
                         id = navKey.id, // 直接从 Key 中解构参数
                         sharedTransitionScope = this@SharedTransitionLayout
                     )
                 }
             }
         )
     }
 }

4.3 核心实现:注入 AnimatedContentScope

这是官方文档缺失的关键一步。在具体的页面组件(如 HomeScreenDetailScreen)中,我们需要结合 SharedTransitionScopeLocalNavAnimatedContentScope

 @OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
 fun HomeScreen(
     sharedTransitionScope: SharedTransitionScope,
     onNavigateToDetail: (String) -> Unit
 ) {
     // 【关键】从 Local Composition 获取 Nav 3 提供的动画作用域
     val animatedContentScope = LocalNavAnimatedContentScope.current
 ​
     with(sharedTransitionScope) {
         Image(
             painter = painterResource(id = R.drawable.your_image),
             contentDescription = null,
             modifier = Modifier
                 .size(100.dp)
                 .clickable { onNavigateToDetail("image_1") }
                 // 应用共享元素修饰符
                 .sharedElement(
                     sharedContentState = rememberSharedContentState(key = "image_1"),
                     animatedVisibilityScope = animatedContentScope // 此处填入获取到的 Scope
                 )
         )
     }
 }

同理,在目标页面 DetailScreen 中使用相同的 Key 和获取方式即可完成匹配。

5. 常见问题与调试 (Troubleshooting)

Q1: 为什么找不到 LocalNavAnimatedContentScope

  • 原因: 依赖版本过低或未正确导入 Navigation 3 包。

  • 解决: 确认 import 路径通常位于 androidx.navigation.compose 包下,并检查 Gradle 依赖版本。

Q2: 动画没有触发,而是直接跳变?

  • 原因: sharedElementkey 不匹配,或者两个页面不在同一个 SharedTransitionLayout 作用域内。

  • 解决: 确保 SharedTransitionLayout 包裹了整个 NavDisplay,而不是分别包裹每个 Screen。

Q3: 为什么不继续用 Nav 2 的方式?

  • 理由: Nav 3 的 entryProvider DSL 结构更简洁,且去除了繁琐的参数传递。使用 LocalNavAnimatedContentScope 可以避免层层透传 Scope,代码耦合度更低。

6. 总结

在 Navigation 3 中实现 Shared Element 的核心在于打破思维定势:

  1. 架构上:拥抱 NavBackStack 的集合操作逻辑。

  2. API 上:放弃从函数参数寻找 AnimatedContentScope,转而使用 LocalNavAnimatedContentScope.current

这一改动虽然隐蔽,但极大简化了代码结构,无需层层透传参数,保证了代码的整洁性。

作话

我在此之前一直找不到如何实现,因为官方文档根本没有提到过这一茬,要不是 Ai 帮我看了一眼官方博客,咱也不知道 Google 能把这一个代码藏得那么深。

毕竟我在网上没有看到有人提过这个,包括解决方法,甚至也有人向官方提问过如何实现这个,但是就是没有一点蛛丝马迹,因为你没办法在 entry 当中找到能够显示传入的 AnimatedContentScope,没想到它居然直接就用 LocalXXX.current 就可以了。

平时我自己码页面的时候也喜欢用 compositionLocalOf 去把那些嵌套传递的东西直接从函数签名当中搬出来,这样就不需要嵌套传递一个参数传到发莽去了。

但是如果你已经实现了 Nav 3 甚至是 Nav 2 即将迁移到 Nav 3 的情况下,这样一个用法可以保证你的整个项目不会大刀阔斧的改动,而且干净整洁,不用从上面一层层传递一个 scope 到下面来,真的很爽。

这篇文章其实修改过,原先的稿件我觉得写的不太好,所以重新整理了一下。

评论