最近项目中做了一次平板适配。项目是 Kotlin Multiplatform + Compose 写的,界面代码主要在 commonMain,也就是说 Android 和 iOS 会共用大部分页面逻辑。
一开始看到“平板适配”这几个字,很容易想到两种做法:
- 判断是不是平板,然后重新写一套平板页面;
- 在每个页面里判断屏幕宽度,手动决定显示单栏还是双栏。
但这两种方式后期都比较难维护。页面越来越多以后,每个页面都写一遍适配判断,代码会很散;如果单独写一套平板页面,又容易和手机页面逻辑不同步。
所以本文就以这个项目为例,看一下如何使用 Navigation 3 + Material3 Adaptive 实现一套更适合真实项目的平板适配方案:手机上仍然是普通的全屏页面跳转,平板横屏时自动变成左侧 start pane + 右侧 end pane 的双栏结构。
本文只看平板 / 大屏适配相关代码,业务逻辑不展开。
本文大概会按这个顺序展开:
- 先看最终效果和相关文件;
- 再看窗口判断和
SceneStrategy; - 接着看完整的双栏
Scene; - 最后看 route、返回栈和几个容易误解的点。
开始
先看一下最终要实现的效果:
从效果上看,它并不是简单把页面整体放大,而是根据窗口形态改变导航展示方式:
- 手机或竖屏:还是传统的单页全屏跳转;
- 平板横屏 / 宽窗口:左侧固定显示入口页,右侧显示功能详情页;
- 右侧继续进入二级、三级页面时,只在右侧区域切换;
- 某些编辑、预览、沉浸式页面仍然可以主动退出双栏,变成真正的全屏页面。
所以这篇文章重点不是“怎么写两个布局”,而是看清楚这几个问题:
- Navigation 3 是怎么把返回栈渲染成页面的;
SceneStrategy是怎么决定当前该用单页还是双栏的;NavEntry.metadata是怎么给页面打上 start / end / full screen 标签的;- 为什么业务页面本身不需要知道自己是在手机还是平板上运行。
如果先不看代码,可以把整体思路先记成下面这张图:
后面所有实现,其实都是围绕这张图展开:一份返回栈,按窗口能力选择不同 Scene。
相关文件
先看项目结构。这个项目里,和平板适配关系最大的文件主要集中在 commonMain:
1 2 3 4 5 6 7 8 9 10 |
composeApp/src/commonMain/kotlin/com/example/app/ ├─ App.kt ├─ navigation/ │ ├─ AppNavigationHost.kt # NavDisplay 入口,给 route 绑定 pane 元数据 │ ├─ AppNavigationState.kt # 统一维护返回栈和 Push/Pop/Replace 语义 │ ├─ AppRoute.kt # 强类型路由定义 │ └─ AppStartEndScene.kt # 平板双栏 Scene 与 SceneStrategy 核心实现 └─ ui/ ├─ FeatureScreens.kt # 模块页、详情页、二级页、三级页、全屏页 └─ StartEndPlaceholderScreen.kt # 平板右侧空状态欢迎页 |
依赖入口在 composeApp/build.gradle.kts:
1 2 3 4 5 |
commonMain.dependencies {
implementation(libs.compose.material3)
implementation(libs.compose.material3.adaptive.navigation3)
implementation(libs.jetbrains.navigation3.ui)
} |
这里的关键点是:平板适配代码放在 commonMain,因此 Android 和 iOS 共享同一套路由与双栏策略。
也就是说,本文分析的不是某一个 Android Activity 里的特殊判断,而是一套跨端都可以复用的导航显示策略。后面所有代码基本都围绕这几件事展开:返回栈、Scene、SceneStrategy、metadata。
判断窗口是否适合双栏
首先要解决的就是:什么时候才应该显示双栏?这个判断在 AppStartEndScene.kt 里。直接看代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Composable
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
fun <T : Any> rememberAppStartEndSceneStrategy(
navigationOperation: NavigationOperation,
): AppStartEndSceneStrategy<T> {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val containerDpSize = LocalWindowInfo.current.containerDpSize
val isLandscapeLike = containerDpSize.width > containerDpSize.height
return remember(windowSizeClass, isLandscapeLike, navigationOperation) {
AppStartEndSceneStrategy(
windowSizeClass = windowSizeClass,
isLandscapeLike = isLandscapeLike,
navigationOperation = navigationOperation,
)
}
} |
先把这段函数头里的几个 API 说清楚,否则后面很容易只看懂 if,看不懂为什么它要写成一个 Composable 函数:
| 写法 | 官方含义 | 这里为什么这样写 |
|---|---|---|
@Composable |
Compose 的组合函数标记。只有 Composable 里才能读取 CompositionLocal,也才能调用其他 Composable API。 | currentWindowAdaptiveInfo() 和 LocalWindowInfo.current 都依赖当前组合环境,所以这个策略创建函数必须是 Composable。 |
@OptIn(ExperimentalMaterial3AdaptiveApi::class) |
明确告诉编译器:这里使用了带实验标记的 Material3 Adaptive API。 | currentWindowAdaptiveInfo() / WindowSizeClass 所在的适配 API 还带实验标记,不写这行编译器会要求你显式确认。 |
fun <T : Any> |
Kotlin 泛型函数,并限制导航 key 不能是 nullable。 | Navigation 3 的 NavEntry<T>、Scene<T>、SceneStrategy<T> 都围绕同一个 route key 类型工作,所以这里跟官方 API 保持泛型写法。 |
rememberAppStartEndSceneStrategy(navigationOperation) |
一个自定义的 remember 函数。 |
每次组合时根据当前窗口信息生成策略,同时借助 remember 避免没有必要的重复创建。 |
它同时看两个条件:
| 条件 | 作用 |
|---|---|
currentWindowAdaptiveInfo() |
Material3 Adaptive 提供的窗口适配信息入口,可以拿到当前窗口的 WindowSizeClass。 |
windowSizeClass |
官方按窗口宽高归类后的尺寸等级,不直接等于“手机/平板”,而是描述当前可用窗口空间。 |
WIDTH_DP_MEDIUM_LOWER_BOUND |
WindowSizeClass 里 medium 宽度档的下限,达到这个宽度后,才有比较现实的双栏空间。 |
isWidthAtLeastBreakpoint(breakpoint) |
判断当前窗口宽度是否达到某个断点。这里用它决定“有没有资格进入双栏”。 |
LocalWindowInfo.current.containerDpSize |
Compose 当前容器的 dp 尺寸,比单纯读设备型号更适合分屏、横竖屏和桌面窗口。 |
containerDpSize.width > containerDpSize.height |
当前窗口更接近横屏,避免大屏竖屏也强行双栏。 |
remember(windowSizeClass, isLandscapeLike, navigationOperation) |
缓存策略对象;只有窗口等级、横屏状态或导航动作变化时,才重新创建策略。 |
也就是说,它不是粗暴写成:
1 2 3 4 5 |
if (deviceInfo.isTablet) {
useTwoPaneLayout()
} else {
useSinglePaneLayout()
} |
而是更现代的大屏适配思路:
设备类型不重要,窗口能力才重要。
折叠屏、分屏、桌面窗口、iPad 横竖屏,都应该按当前窗口尺寸响应。
SceneStrategy 决定显示方式
接下来要看 SceneStrategy。它的作用可以先简单理解成:Navigation 3 在准备显示页面之前,会先问它一句“这次要不要换一种显示方式”。核心逻辑在 AppStartEndSceneStrategy.calculateScene():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
override fun SceneStrategyScope<T>.calculateScene(
entries: List<NavEntry<T>>,
): Scene<T>? {
val topEntry = entries.lastOrNull()
if (
!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND) ||
!isLandscapeLike
) {
return null
}
if (topEntry?.isFullScreenPane() == true) {
return null
}
// 后面继续从返回栈中找 startEntry / endEntry,组装 AppStartEndScene
} |
这段代码涉及几个 Navigation 3 的核心 API,可以先拆开看:
| API | 官方语义 | 这里为什么这样用 |
|---|---|---|
SceneStrategyScope<T>.calculateScene(entries) |
Navigation 3 让自定义策略计算当前应该显示哪个 Scene 的回调。 |
每次返回栈变化后,系统都会把候选 entries 交给这里判断。 |
entries: List<NavEntry<T>> |
当前返回栈可以显示的 entry 列表。 | 这里从里面找最后一个 start pane、最后一个 end pane,再组装双栏。 |
entries.lastOrNull() |
当前最顶部的页面。 | 用它判断顶部页面是不是必须全屏,比如预览页。 |
return null |
策略不接管,让 NavDisplay 使用默认 Scene。 |
手机、竖屏、全屏页都应该回到默认全屏导航。 |
Scene<T>? |
返回一个可显示场景,也可以返回空。 | 适合双栏时返回 AppStartEndScene,不适合时返回 null。 |
这里有两个 return null 非常重要:
窗口不适合时返回 null
当窗口宽度不够,或者是竖屏形态时,策略直接返回 null。
在 Navigation 3 里,SceneStrategy 返回 null 的意思是:
我不接管这个返回栈,让
NavDisplay使用默认场景。
于是手机上就仍然是正常的全屏页面跳转。
全屏页面也返回 null
项目中有一个特殊路由:FeatureFullScreen。它被标记为真正的全屏页:
1 2 3 4 5 6 7 8 9 10 11 |
entry<AppRoute.FeatureFullScreen>(
metadata = AppStartEndScene.fullScreenPane(),
) { route ->
FeatureFullScreenScreen(
module = route.module,
platformName = platformName,
onBack = {
navigationState.popIfPossible()
},
)
} |
当这个页面在返回栈顶部时,即使当前是平板横屏,也会退出 start / end 双栏。
这就实现了一个真实项目经常需要的效果:
- 列表、详情、二级详情适合双栏;
- 但编辑器、预览页、沉浸式内容页需要全屏;
- 不要因为处于平板环境就强行塞进右侧 pane。
绘制双栏 Scene
前面已经说过,这个项目不是给平板重新写一套页面,而是通过 Navigation 3 的自定义 Scene 把多个 NavEntry 组合成一个左右双栏场景。
这一节不要只看 Row / Column,重点要理解官方 API 的角色:
| 名字 | 可以先理解成 | 在这个项目里的作用 |
|---|---|---|
NavEntry<T> |
一个“可显示的页面条目” | 里面包含 route、metadata 和真正的 Composable 内容。 |
Scene<T> |
一个“导航显示场景” | 决定当前屏幕怎么显示一个或多个 entry。 |
SceneStrategy<T> |
一个“场景选择策略” | 决定什么时候用默认全屏,什么时候用自定义双栏。 |
NavDisplay |
Navigation 3 的“渲染器” | 接收 backStack、entryProvider、sceneStrategy,最终把页面画出来。 |
接下来直接上完整代码。这里不要只看 Row 和 Column,因为右侧内容区、切换动画、占位页、metadata 标记都在同一个文件里。把这段代码连起来看,才能理解这个自定义 Scene 是怎么工作的。
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 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 |
package com.example.app.navigation import androidx.compose.animation.AnimatedContent import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalWindowInfo import androidx.navigation3.runtime.NavEntry import androidx.navigation3.scene.Scene import androidx.navigation3.scene.SceneStrategy import androidx.navigation3.scene.SceneStrategyScope import androidx.window.core.layout.WindowSizeClass import androidx.window.core.layout.WindowSizeClass.Companion.WIDTH_DP_MEDIUM_LOWER_BOUND import com.example.app.ui.StartEndWelcomeScreen private const val APP_START_PANE_KEY = "AppStartEndScene-Start" private const val APP_END_PANE_KEY = "AppStartEndScene-End" private const val APP_FULL_SCREEN_PANE_KEY = "AppStartEndScene-FullScreen" private const val APP_END_PLACEHOLDER_KEY = "AppStartEndScene-Placeholder" private const val APP_START_END_SCENE_KEY = "AppStartEndScene-Key" /** * 应用自己的 start-end Scene。 * * 为什么这里不继续沿用早期的 list/detail 命名? * * 因为当前这套布局在正式项目里,更接近“起始侧 / 结束侧”的通用双栏模型: * - 左边是 start pane * - 右边是 end pane * * 这样命名后,不会把这套能力绑死在“列表 + 详情”这个特定业务语义上。 * 未来即使左边不是列表、右边也不是详情页,这个 Scene 仍然成立。 */ class AppStartEndScene<T : Any>( override val key: Any, override val previousEntries: List<NavEntry<T>>, private val startEntry: NavEntry<T>, private val endRootContentKey: Any, private val endEntry: NavEntry<T>?, private val navigationOperation: NavigationOperation, ) : Scene<T> { /** * 当前 Scene 真正参与渲染的 entry。 * * 它只关心两件事: * - start pane 里显示谁 * - end pane 当前顶部显示谁 */ override val entries: List<NavEntry<T>> = buildList { add(startEntry) endEntry?.let(::add) } /** * Scene 的 UI 定义。 * * start pane 始终稳定; * end pane 内部通过 `AnimatedContent` 做页面切换。 * * 这样手机和 Pad 共享同一份返回栈时: * - 手机上,仍然是全屏逐级跳转 * - Pad 上,只有右侧 end pane 在变化 */ override val content: @Composable (() -> Unit) = { Row( modifier = Modifier.fillMaxSize(), ) { Column( modifier = Modifier.weight(0.35f), ) { startEntry.Content() } Column( modifier = Modifier.weight(0.65f), ) { AnimatedContent( targetState = EndPaneState( entry = endEntry, rootContentKey = endRootContentKey, ), contentKey = { state -> state.contentKey }, transitionSpec = { /** * end pane 的动画规则有两层语义: * * 1. 左侧切换到另一个功能模块 * 例如:`存储 -> 权限` * 这种属于整条 end flow 被替换,应直接切换 * * 2. 同一功能模块内部继续深入 * 例如:`详情 -> 二级页 -> 三级页` * 这种才属于真正的 push/pop,需要左右滑动 * * 因此这里同时比较: * - `rootContentKey`:当前 end flow 属于哪个根页面 * - `navigationOperation`:最近一次导航语义 * * 这样页面作者只需要声明: * - 我是不是 end pane * * 不需要再声明: * - 我是第几层页面 */ if (initialState.rootContentKey != targetState.rootContentKey) { EnterTransition.None togetherWith ExitTransition.None } else { when (navigationOperation) { NavigationOperation.Push -> { slideInHorizontally( initialOffsetX = { width -> width }, ) togetherWith slideOutHorizontally( targetOffsetX = { width -> -width }, ) } NavigationOperation.Pop -> { slideInHorizontally( initialOffsetX = { width -> -width }, ) togetherWith slideOutHorizontally( targetOffsetX = { width -> width }, ) } NavigationOperation.Replace, NavigationOperation.Restore -> { EnterTransition.None togetherWith ExitTransition.None } } } }, label = "app-end-pane-transition", ) { state -> if (state.entry == null) { StartEndWelcomeScreen() } else { state.entry.Content() } } } } } companion object { /** * 标记这个 entry 可以出现在 start pane。 */ fun startPane() = mapOf(APP_START_PANE_KEY to true) /** * 显式标记这个 entry 可以出现在 end pane。 * * 当前项目里,end pane 已经是默认约定: * - 没有标记为 `startPane` * - 也没有标记为 `fullScreenPane` * * 那么就默认视为 end pane。 * * 所以这个方法现在更多是“允许显式声明”, * 而不是必须在每个右侧页面上手写。 */ fun endPane() = mapOf(APP_END_PANE_KEY to true) /** * 标记这个 entry 是一个真正的全屏页面。 * * 它不再属于 start/end 双栏里的 end pane, * 而是要临时退出双栏 Scene,交给 `NavDisplay` 按整屏方式展示。 */ fun fullScreenPane() = mapOf(APP_FULL_SCREEN_PANE_KEY to true) } } /** * 记住应用自己的 start-end SceneStrategy。 * * 判定规则保持简单: * - 宽度至少达到中等断点 * - 且窗口更偏横向,避免大屏竖屏也强行双栏 * - start pane 必须存在 * - end pane 可以为空;为空时右侧显示占位页 */ @Composable @OptIn(ExperimentalMaterial3AdaptiveApi::class) fun <T : Any> rememberAppStartEndSceneStrategy( navigationOperation: NavigationOperation, ): AppStartEndSceneStrategy<T> { val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass val containerDpSize = LocalWindowInfo.current.containerDpSize val isLandscapeLike = containerDpSize.width > containerDpSize.height return remember(windowSizeClass, isLandscapeLike, navigationOperation) { AppStartEndSceneStrategy( windowSizeClass = windowSizeClass, isLandscapeLike = isLandscapeLike, navigationOperation = navigationOperation, ) } } /** * 应用自己的 SceneStrategy。 * * 这里最关键的是: * Scene 的 `key` 固定为同一个常量 key。 * * 这样无论 start pane 是“模块页”还是“登录页”, * 只要它们都属于同一个 start/end 双栏场景, * Navigation 3 都不会把整个 Scene 当成“整块替换”。 * * 这样一来: * - start pane 底部 Tab 切换时,不会触发整页级的场景替换 * - end pane 的动画机会仍然留给 Scene 内部的 `AnimatedContent` */ class AppStartEndSceneStrategy<T : Any>( private val windowSizeClass: WindowSizeClass, private val isLandscapeLike: Boolean, private val navigationOperation: NavigationOperation, ) : SceneStrategy<T> { override fun SceneStrategyScope<T>.calculateScene( entries: List<NavEntry<T>>, ): Scene<T>? { val topEntry = entries.lastOrNull() if ( !windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND) || !isLandscapeLike ) { return null } /** * 如果当前顶部页面已经是全屏页, * 那么双栏 Scene 必须主动退出。 * * 这样 Pad 下才会真正显示“覆盖整个内容区”的全屏页面, * 而不是继续把它硬塞进 end pane。 */ if (topEntry?.isFullScreenPane() == true) { return null } val endEntry = topEntry ?.takeIf { entry -> entry.isEndPane() } val endRootEntry = entries.firstOrNull { entry -> entry.isEndPane() } val startEntry = entries.findLast { entry -> entry.isStartPane() } ?: return null return AppStartEndScene( key = APP_START_END_SCENE_KEY, previousEntries = entries.dropLast(1), startEntry = startEntry, endRootContentKey = endRootEntry?.contentKey ?: APP_END_PLACEHOLDER_KEY, endEntry = endEntry, navigationOperation = navigationOperation, ) } } /** * end pane 当前要展示的状态。 * * 这里故意不直接把 `NavEntry?` 作为动画状态, * 而是再包一层,显式携带: * - 当前 end flow 属于哪个根页面 * * 这样动画逻辑才能准确区分: * - 当前是不是同一条 end flow * - 当前内容是否真的发生切换 */ private data class EndPaneState<T : Any>( val entry: NavEntry<T>?, val rootContentKey: Any, ) { val contentKey: Any = entry?.contentKey ?: APP_END_PLACEHOLDER_KEY } /** * 当前 entry 是否属于 start pane。 */ private fun <T : Any> NavEntry<T>.isStartPane(): Boolean { return metadata.containsKey(APP_START_PANE_KEY) } /** * 当前 entry 是否属于 full screen 页面。 */ private fun <T : Any> NavEntry<T>.isFullScreenPane(): Boolean { return metadata.containsKey(APP_FULL_SCREEN_PANE_KEY) } /** * 当前 entry 是否属于 end pane。 * * 当前项目采用“默认右侧”的约定: * - 不是 start pane * - 不是 full screen * * 那么就按 end pane 处理。 * * 这样多数业务页面都不需要反复写: * `metadata = AppStartEndScene.endPane()` */ private fun <T : Any> NavEntry<T>.isEndPane(): Boolean { return metadata.containsKey(APP_END_PANE_KEY) || (!isStartPane() && !isFullScreenPane()) } |
这段代码建议按下面这个顺序看:
- 先看
content,它决定左右两栏真正怎么画; - 再看
AnimatedContent,它决定右侧页面变化时怎么动; - 最后看 companion object 里的几个方法,它们就是给 route 打标签用的。
如果直接从泛型、接口开始看,反而容易被 Navigation 3 的概念绕进去。
先把 Scene 里的官方 API 说清楚
上面这段代码比较长,如果第一次看 Navigation 3 的 Scene,很容易只看到 Row、Column,忽略真正关键的几个 API:
| API / 参数 | 官方语义 | 这里为什么这样写 |
|---|---|---|
Scene<T> |
Navigation 3 的显示场景接口,表示这次应该如何把一个或多个 NavEntry 画到屏幕上。 |
默认情况下一个页面就是一个场景;这里自定义成 start/end 两个 pane 组成的场景。 |
override val key |
当前 Scene 的身份标识。 | 固定成同一个 key,让双栏整体保持稳定,不因为右侧页面变化就把整个 Scene 当成新场景。 |
previousEntries |
在这个 Scene 之前仍然属于历史上下文的 entries。 | 交给 Navigation 3 维护返回和预测返回时的上下文。 |
entries |
当前 Scene 实际负责显示的 entries。 | 这里只放 startEntry 和当前 endEntry,因为 Scene 只需要知道当前屏幕上显示什么,不等于完整返回栈。 |
content |
Scene 最终要渲染出来的 Composable 内容。 | 在这里用 Row 分成左右两栏。 |
NavEntry.Content() |
Navigation 3 提供的入口,用来执行这个 entry 对应的 Composable 页面内容。 | 左侧直接执行 startEntry.Content(),右侧根据状态执行 entry.Content()。 |
AnimatedContent(targetState) |
Compose 动画 API,目标状态变化时自动做内容切换动画。 | 右侧 pane 变化最频繁,所以只包右侧,避免左侧也跟着重绘和滑动。 |
contentKey |
AnimatedContent 判断“内容身份”的 key。 |
用 rootContentKey 区分模块替换,用 entry 自身区分同模块内层级变化。 |
transitionSpec |
定义旧内容到新内容怎么切换。 | 根据 Push / Pop / Replace 做不同动画,保持导航语义正确。 |
Row |
Compose 横向布局容器。 | 平板横屏时左右 pane 天然是横向排列,所以最外层用 Row。 |
Column |
Compose 纵向布局容器。 | 每个 pane 内部可能先放标题、分割线,再放真正页面内容,所以 pane 内用 Column。 |
Modifier.fillMaxSize() |
让 Composable 占满父容器给它的空间。 | 双栏 Scene 要接管整个导航显示区域,不能只包住内容本身。 |
Modifier.weight(1f) |
在 Row / Column 中按权重分配剩余空间。 |
左右两栏各给 1f,就是平均分屏;pane 内页面内容给 1f,标题之外的空间都交给页面。 |
EnterTransition.None / ExitTransition.None |
不执行进入 / 退出动画。 | Replace 或 Restore 时不表现成深入或返回,避免误导用户这是层级跳转。 |
initialOffsetX / targetOffsetX |
横向动画的起始 / 结束偏移量。 | width、-width 分别表示从右侧进、从左侧进、向右出、向左出。 |
右侧动画为什么要看 NavigationOperation
NavigationOperation 不是业务枚举,而是这套导航状态里记录的“最近一次导航动作”。它决定右侧 pane 应该怎么动:
| 动作 | 可以理解成 | 动画语义 |
|---|---|---|
NavigationOperation.Push |
进入更深一层页面 | 新页面从右往左进来,旧页面向左退出。 |
NavigationOperation.Pop |
从详情返回上一层 | 上一页从左边回来,当前页向右退出。 |
NavigationOperation.Replace |
切换另一条功能链路 | 不做 push/pop 方向动画,避免把“切换模块”误表现成“进入下一层”。 |
NavigationOperation.Restore |
恢复已有状态 | 通常不需要方向动画,避免恢复时出现突兀滑动。 |
这样写的好处是,动画不只是“好看”,而是和导航动作的含义对齐。
AppStartEndScene 是什么
1 2 3 4 5 6 7 8 |
class AppStartEndScene<T : Any>(
override val key: Any,
override val previousEntries: List<NavEntry<T>>,
private val startEntry: NavEntry<T>,
private val endRootContentKey: Any,
private val endEntry: NavEntry<T>?,
private val navigationOperation: NavigationOperation,
) : Scene<T> |
Scene<T> 是 Navigation 3 提供的接口。它表示:当前导航栈不一定只能显示一个全屏页面,也可以显示一个“场景”。
默认手机导航一般是:
1
|
栈顶 entry -> 全屏显示 |
这次适配想在平板横屏时变成:
1 2 |
startEntry -> 左侧显示 endEntry -> 右侧显示 |
所以它实现了自己的 Scene<T>。
这里的 T 是导航 key 的类型。当前项目中最终对应 NavKey,也就是 AppRoute.Home、AppRoute.Feature 这些 route。写成泛型,是为了和 Navigation 3 官方 API 保持一致,不把这个 Scene 写死到某一个具体 route 类型上。
构造参数
1 2 3 4 5 6 |
override val key: Any, override val previousEntries: List<NavEntry<T>>, private val startEntry: NavEntry<T>, private val endRootContentKey: Any, private val endEntry: NavEntry<T>?, private val navigationOperation: NavigationOperation, |
| 参数 | 含义 | 为什么这里需要它 |
|---|---|---|
key |
这个 Scene 的身份标识 | 这里固定传 APP_START_END_SCENE_KEY,表示只要还是 start/end 双栏,就是同一个场景。 |
previousEntries |
当前 Scene 之前的 entry | Navigation 3 的 Scene API 需要,用于保留导航历史语义。 |
startEntry |
左侧 pane 要显示的 entry | 例如 Home 或 LoginHome。 |
endRootContentKey |
右侧功能链路的根 key | 用来判断是“同一链路继续 push/pop”,还是“切换到另一条功能链路”。 |
endEntry |
右侧当前显示的 entry | 例如 FeatureSubPage、FeatureThirdPage。可以为 null。 |
navigationOperation |
最近一次导航动作 | 决定右侧动画方向:Push、Pop、Replace、Restore。 |
注意:startEntry、endEntry、endRootContentKey 不是官方自动判断出来的,而是后面的 AppStartEndSceneStrategy 从返回栈里挑出来的。
entries 为什么只放当前要显示的页面
1 2 3 4 |
override val entries: List<NavEntry<T>> = buildList {
add(startEntry)
endEntry?.let(::add)
} |
Scene.entries 不是完整返回栈。完整返回栈在 NavDisplay(backStack = navigationState.backStack) 里。
这里的 entries 表示“当前 Scene 真正参与显示的 entry”。
比如返回栈是:
1
|
Home -> Feature -> FeatureSubPage -> FeatureThirdPage |
在双栏里真正要显示的是:
1 2 |
左侧:Home 右侧:FeatureThirdPage |
所以当前 Scene 的 entries 只需要放这两个 entry。中间的 Feature、FeatureSubPage 仍然存在于 backStack 中,只是当前不直接显示。
这就是 Navigation 3 自定义 Scene 的价值:返回栈可以很深,但当前场景可以只渲染其中一部分。
content 就是真正的页面内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
override val content: @Composable (() -> Unit) = {
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.weight(0.35f)) {
startEntry.Content()
}
Column(modifier = Modifier.weight(0.65f)) {
AnimatedContent(
targetState = EndPaneState(
entry = endEntry,
rootContentKey = endRootContentKey,
),
transitionSpec = { createEndPaneTransition() },
contentKey = { state -> state.entry?.contentKey },
) { state ->
if (state.entry == null) {
StartEndWelcomeScreen()
} else {
state.entry.Content()
}
}
}
}
} |
Scene.content 是官方 API 要求提供的 UI 内容,它本质上就是一个 Composable lambda。
默认情况下,NavDisplay 渲染栈顶 entry 的内容。现在有了自定义 Scene,NavDisplay 会渲染 Scene.content。
所以这里直接写:
1 2 3 4 5 6 7 8 9 |
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.weight(0.35f)) {
startEntry.Content()
}
Column(modifier = Modifier.weight(0.65f)) {
// end pane
}
} |
这表示当前导航场景是一整块左右布局,而不是单个全屏页面。
左右宽度为什么这样分
1 2 |
Column(modifier = Modifier.weight(0.35f)) Column(modifier = Modifier.weight(0.65f)) |
这是 Compose Row 的权重分配:两个子项按 0.35 : 0.65 分配宽度。
这里这样设计,是因为:
- 左侧通常是导航入口、模块列表、Tab,不需要太宽;
- 右侧是主要阅读和操作区域,需要更大空间。
这不是官方强制比例。真实项目可以改成固定宽度,例如 width(360.dp),也可以根据窗口尺寸动态调整。
startEntry.Content 是什么
NavEntry 不只是 route 数据,它还带有这个 route 对应的 Composable 内容。
比如在 AppNavigationHost.kt 里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
entry<AppRoute.Home> {
ModuleHomeScreen(
platformName = platformName,
selectedModule = navigationState.currentModuleOrNull(),
onOpenModule = { module ->
navigationState.replacePath(
AppRoute.Home,
AppRoute.Feature(module),
)
},
onOpenLoginTab = {
navigationState.replacePath(
AppRoute.LoginHome,
AppRoute.LoginStatus,
)
},
)
} |
当策略选中 Home 作为 startEntry 后,调用:
1
|
startEntry.Content() |
就是在左侧 pane 渲染 Home 对应的页面。
右侧的:
1
|
state.entry.Content() |
同理,就是渲染当前右侧 entry 对应的页面。
右侧为什么使用 AnimatedContent
左侧 pane 是稳定入口,右侧 pane 才是不断 push / pop 的内容区。
AnimatedContent 是 Compose Animation 的 API。它的语义是:当 targetState 改变时,按照 transitionSpec 在旧内容和新内容之间做切换。
这里的 targetState 是:
1 2 3 4 |
EndPaneState(
entry = endEntry,
rootContentKey = endRootContentKey,
) |
它没有只传 endEntry,而是额外带上 rootContentKey,因为右侧动画要区分两种情况:
| 情况 | 例子 | 动画应该怎样 |
|---|---|---|
| 同一条链路继续深入 | Feature -> FeatureSubPage |
Push / Pop 滑动。 |
| 切换到另一条链路 | 从“存储模块”切到“权限模块” | 直接替换,不滑动。 |
这也是为什么代码里有:
1 2 3 |
if (initialState.rootContentKey != targetState.rootContentKey) {
EnterTransition.None togetherWith ExitTransition.None
} |
根链路变了,就不要做前进 / 后退动画,否则用户会误以为两个模块之间存在页面层级关系。
Push、Pop、Replace、Restore 动画
1 2 3 |
NavigationOperation.Push ->
slideInHorizontally(initialOffsetX = { width -> width }) togetherWith
slideOutHorizontally(targetOffsetX = { width -> -width }) |
Push 表示进入下一层,所以新页面从右侧进来,旧页面向左出去。
1 2 3 |
NavigationOperation.Pop ->
slideInHorizontally(initialOffsetX = { width -> -width }) togetherWith
slideOutHorizontally(targetOffsetX = { width -> width }) |
Pop 表示返回上一层,所以方向相反。
1 2 3 |
NavigationOperation.Replace,
NavigationOperation.Restore ->
EnterTransition.None togetherWith ExitTransition.None |
Replace 是换路径,不是深入;Restore 是恢复状态,也不是用户主动进入下一层。因此这里不做滑动。
右侧为空时显示欢迎页
当返回栈只有一个 start pane,例如只有 Home,右侧还没有任何功能页时,如果右侧直接空白,体验会很奇怪。
所以这里放了一个欢迎页 / 占位页:
1 2 3 4 5 |
if (state.entry == null) {
StartEndWelcomeScreen()
} else {
state.entry.Content()
} |
这说明平板双栏适配还要考虑“右侧空状态”,不是简单把已有页面塞到右边。
使用 metadata 标记页面位置
知道了 Scene 怎么画以后,还要看页面是怎么被放进左侧、右侧或者全屏的。这里用到的是 NavEntry.metadata。
可以把 metadata 理解成给 route 贴标签:这个页面适合放在 start pane,还是必须全屏显示。这里没有把这些判断写进页面内部,而是在 entryProvider 里统一声明。
先把这一组 API 的关系说清楚:
| API | 官方语义 | 这里为什么这样用 |
|---|---|---|
entryProvider { 页面注册 } |
Navigation 3 中声明 route 和页面内容对应关系的地方。 | 所有页面都在这里集中登记,顺手也集中声明 pane 标签。 |
entry<AppRoute.Home>(metadata, content) |
给某一种 route 类型注册页面内容。 | Home 是左侧入口,所以注册时带上 startPane()。 |
metadata |
NavEntry 上的附加信息,给 SceneStrategy 或其他导航组件读取。 |
不把“页面应该在左边还是右边”写死到页面里,而是写在导航层。 |
AppStartEndScene.startPane() |
自定义的 metadata 工厂方法。 | 给 entry 打上 start pane 标签,策略就能把它放到左侧。 |
AppStartEndScene.fullScreenPane() |
自定义的 metadata 工厂方法。 | 给预览、编辑等沉浸式页面打标签,让策略返回 null,改用默认全屏显示。 |
图里这条线很关键:route 本身只表示页面,真正决定它显示在哪个 pane 的,是 entryProvider 上配置的 metadata。直接看代码:
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 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
NavDisplay(
backStack = navigationState.backStack,
modifier = modifier,
onBack = {
navigationState.popIfPossible()
},
sceneStrategy = rememberAppStartEndSceneStrategy<NavKey>(
navigationOperation = navigationState.lastOperation,
),
transitionSpec = {
slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(durationMillis = 300),
) togetherWith slideOutHorizontally(
targetOffsetX = { fullWidth -> -fullWidth },
animationSpec = tween(durationMillis = 300),
)
},
popTransitionSpec = {
slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth },
animationSpec = tween(durationMillis = 300),
) togetherWith slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(durationMillis = 300),
)
},
predictivePopTransitionSpec = {
slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth },
animationSpec = tween(durationMillis = 300),
) togetherWith slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(durationMillis = 300),
)
},
entryProvider = entryProvider {
entry<AppRoute.Home>(
metadata = AppStartEndScene.startPane(),
) {
ModuleHomeScreen(
platformName = platformName,
selectedModule = navigationState.currentModuleOrNull(),
onOpenModule = { module ->
navigationState.replacePath(
AppRoute.Home,
AppRoute.Feature(module),
)
},
onOpenLoginTab = {
navigationState.replacePath(
AppRoute.LoginHome,
AppRoute.LoginStatus,
)
},
)
}
entry<AppRoute.LoginHome>(
metadata = AppStartEndScene.startPane(),
) {
LoginHomeScreen(
platformName = platformName,
onOpenModuleTab = {
navigationState.replacePath(AppRoute.Home)
},
)
}
entry<AppRoute.LoginStatus> {
LoginStatusScreen(
platformName = platformName,
)
}
entry<AppRoute.Feature> { route ->
FeatureDetailScreen(
module = route.module,
platformName = platformName,
onOpenSubPage = {
navigationState.navigateTo(
AppRoute.FeatureSubPage(route.module),
)
},
onBack = {
navigationState.popIfPossible()
},
)
}
entry<AppRoute.FeatureSubPage> { route ->
FeatureSubPageScreen(
module = route.module,
platformName = platformName,
onOpenThirdPage = {
navigationState.navigateTo(
AppRoute.FeatureThirdPage(route.module),
)
},
onBack = {
navigationState.popIfPossible()
},
)
}
entry<AppRoute.FeatureThirdPage> { route ->
FeatureThirdPageScreen(
module = route.module,
platformName = platformName,
onOpenFullScreenPage = {
navigationState.navigateTo(
AppRoute.FeatureFullScreen(route.module),
)
},
onBack = {
navigationState.popIfPossible()
},
)
}
entry<AppRoute.FeatureFullScreen>(
metadata = AppStartEndScene.fullScreenPane(),
) { route ->
FeatureFullScreenScreen(
module = route.module,
platformName = platformName,
onBack = {
navigationState.popIfPossible()
},
)
}
},
) |
上面这段代码看起来比较长,但真正和平板适配有关的地方主要有三处:
sceneStrategy:告诉NavDisplay,在合适的时候使用自定义双栏 Scene;metadata = AppStartEndScene.startPane():告诉策略这个 entry 可以作为左侧入口;metadata = AppStartEndScene.fullScreenPane():告诉策略这个 entry 不要放进双栏,要回到全屏显示。
也就是说,页面自己并不知道“我现在在左边还是右边”,这个信息是由导航层统一描述的。
NavDisplay 参数说明
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
NavDisplay(
backStack = navigationState.backStack,
onBack = { navigationState.popIfPossible() },
sceneStrategy = rememberAppStartEndSceneStrategy<NavKey>(
navigationOperation = navigationState.lastOperation,
),
entryProvider = entryProvider {
entry<AppRoute.Home>(
metadata = AppStartEndScene.startPane(),
) {
ModuleHomeScreen(
platformName = platformName,
selectedModule = navigationState.currentModuleOrNull(),
onOpenModule = { module ->
navigationState.replacePath(AppRoute.Home, AppRoute.Feature(module))
},
onOpenLoginTab = {
navigationState.replacePath(AppRoute.LoginHome, AppRoute.LoginStatus)
},
)
}
},
) |
| 参数 | 官方语义 | 这里的作用 |
|---|---|---|
backStack |
当前导航返回栈 | 所有页面共享这一份栈,手机和平板不分两套栈。 |
modifier |
Compose 通用修饰符 | 把外层传入的布局约束继续交给 NavDisplay。 |
onBack |
返回事件处理 | 调用 popIfPossible() 出栈。 |
sceneStrategy |
场景选择策略 | 决定当前是否启用 start/end 双栏。 |
transitionSpec |
普通 Push 转场 | 用于默认全屏导航;当双栏策略返回 null 时尤其重要。 |
popTransitionSpec |
普通 Pop 转场 | 用于默认全屏返回。 |
predictivePopTransitionSpec |
预测返回转场 | 支持系统返回手势。 |
slideInHorizontally / slideOutHorizontally |
Compose 横向滑入/滑出动画 | 用正负 fullWidth 表示从右进、从左进、向左出、向右出。 |
togetherWith |
把进入动画和退出动画组合成一个 ContentTransform |
让新页面进入和旧页面退出同时发生。 |
tween(durationMillis = 300) |
基于时长的补间动画 | 保持页面切换速度稳定。 |
entryProvider |
route 到页面的映射 | 声明每个 route 显示哪个 Composable,以及带什么 metadata。 |
Home 为什么是 startPane
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 |
entry<AppRoute.Home>(
metadata = AppStartEndScene.startPane(),
) {
ModuleHomeScreen(
platformName = platformName,
selectedModule = navigationState.currentModuleOrNull(),
onOpenModule = { module ->
navigationState.replacePath(AppRoute.Home, AppRoute.Feature(module))
},
onOpenLoginTab = {
navigationState.replacePath(AppRoute.LoginHome, AppRoute.LoginStatus)
},
)
}
entry<AppRoute.LoginHome>(
metadata = AppStartEndScene.startPane(),
) {
LoginHomeScreen(
platformName = platformName,
onOpenModuleTab = {
navigationState.replacePath(AppRoute.Home)
},
)
} |
因为它们是入口页,不是详情页:
Home是模块入口;LoginHome是登录相关入口。
在平板双栏模型里,入口页应该稳定地放在左侧。
为什么右侧页面可以不用都写 endPane
看 AppStartEndScene.kt 里的判断:
1 2 3 |
private fun <T : Any> NavEntry<T>.isEndPane(): Boolean {
return metadata.containsKey(APP_END_PANE_KEY) || (!isStartPane() && !isFullScreenPane())
} |
这表示项目采用“默认右侧”的约定:
1
|
不是 start pane,也不是 full screen,那就是 end pane。 |
这几行虽然短,但里面也有几个容易被忽略的点:
| 写法 | 含义 | 这里为什么这样写 |
|---|---|---|
metadata.containsKey(APP_END_PANE_KEY) |
判断这个 entry 的 metadata 里是否显式带了右侧 pane 标签。 | 如果某个页面需要强制放右侧,可以通过 endPane() 明确声明。 |
!isStartPane() && !isFullScreenPane() |
既不是左侧入口,也不是全屏页。 | 项目里大部分业务页都是详情或子页面,把它们默认放右侧,可以少写很多重复 metadata。 |
metadata |
NavEntry 自带的附加信息表。 |
页面自己不用知道平板逻辑,导航注册处给它贴标签即可。 |
takeIf { 条件 } |
Kotlin 标准库函数;条件成立时返回对象本身,否则返回 null。 |
前面筛选 start/end entry 时,用它把“不符合 pane 类型”的候选页面自动丢掉。 |
所以 Feature、FeatureSubPage、FeatureThirdPage 不需要每个都写:
1
|
metadata = AppStartEndScene.endPane() |
这能减少大量重复代码。
FullScreen 页面为什么要单独标记
1 2 3 4 5 6 7 8 9 10 11 |
entry<AppRoute.FeatureFullScreen>(
metadata = AppStartEndScene.fullScreenPane(),
) { route ->
FeatureFullScreenScreen(
module = route.module,
platformName = platformName,
onBack = {
navigationState.popIfPossible()
},
)
} |
如果不标记,它会按默认规则被当成 end pane,只显示在右侧 65% 的区域。
但它的产品语义是全屏页,比如预览页、沉浸页、编辑器页。所以必须通过 metadata 告诉策略:当这个页面在栈顶时,不要启用双栏。
什么时候进入双栏,什么时候退出双栏
AppStartEndScene 负责“怎么画双栏”,但它不负责“什么时候该用双栏”。
这个判断在 AppStartEndSceneStrategy 里。
rememberAppStartEndSceneStrategy
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Composable
fun <T : Any> rememberAppStartEndSceneStrategy(
navigationOperation: NavigationOperation,
): SceneStrategy<T> {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val containerDpSize = LocalWindowInfo.current.containerDpSize
val isLandscapeLike = containerDpSize.width > containerDpSize.height
return remember(windowSizeClass, isLandscapeLike, navigationOperation) {
AppStartEndSceneStrategy(
windowSizeClass = windowSizeClass,
isLandscapeLike = isLandscapeLike,
navigationOperation = navigationOperation,
)
}
} |
这里用了两个窗口条件:
currentWindowAdaptiveInfo().windowSizeClass:Material 3 Adaptive 提供的窗口尺寸分类;LocalWindowInfo.current.containerDpSize:当前窗口实际 dp 尺寸,用来判断是否更像横屏。
这里没有判断“设备是不是平板”,而是判断“当前窗口是否适合双栏”。这样对 Android 平板、桌面窗口、分屏模式都更稳。
calculateScene
SceneStrategy 的核心方法是:
1 2 3 |
override fun SceneStrategyScope<T>.calculateScene(
entries: List<NavEntry<T>>,
): Scene<T>? |
可以理解成 NavDisplay 在问策略:
当前这些 entries,要不要组合成一个自定义 Scene?
如果返回 AppStartEndScene,就启用双栏;如果返回 null,就交回 Navigation 3 默认显示,也就是普通全屏。
这里的判断顺序是:
1 2 3 4 5 |
1. 窗口宽度不够 -> return null 2. 不是横向空间充足 -> return null 3. 栈顶是 full screen -> return null 4. 找不到 start pane -> return null 5. 条件满足 -> 返回 AppStartEndScene |
对应成图就是:
1 2 3 4 5 6 7 8 9 10 |
flowchart TD
A[calculateScene(entries)] --> B{宽度达到 Medium 吗}
B -- 否 --> N[return null]
B -- 是 --> C{横向空间更多吗}
C -- 否 --> N
C -- 是 --> D{topEntry 是 fullScreenPane 吗}
D -- 是 --> N
D -- 否 --> E{能找到 startEntry 吗}
E -- 否 --> N
E -- 是 --> F[return AppStartEndScene] |
关键代码:
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 |
if (
!windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND) ||
!isLandscapeLike
) {
return null
}
if (topEntry?.isFullScreenPane() == true) {
return null
}
val endEntry = topEntry
?.takeIf { entry -> entry.isEndPane() }
val endRootEntry = entries.firstOrNull { entry ->
entry.isEndPane()
}
val startEntry = entries.findLast { entry ->
entry.isStartPane()
} ?: return null
return AppStartEndScene(
key = APP_START_END_SCENE_KEY,
previousEntries = entries.dropLast(1),
startEntry = startEntry,
endRootContentKey = endRootEntry?.contentKey ?: APP_END_PLACEHOLDER_KEY,
endEntry = endEntry,
navigationOperation = navigationOperation,
) |
startEntry 为什么用 findLast
1
|
val startEntry = entries.findLast { entry -> entry.isStartPane() } |
返回栈里可能出现多个 start pane,例如从模块首页切到登录首页。此时左侧应该显示最近的入口,所以用 findLast。
endRootEntry 为什么用 firstOrNull
1
|
val endRootEntry = entries.firstOrNull { entry -> entry.isEndPane() } |
它不是为了找右侧当前页,而是为了找右侧这条功能链路的根。
比如:
1
|
Home -> Feature -> FeatureSubPage -> FeatureThirdPage |
当前右侧页是 FeatureThirdPage,但右侧根是 Feature。当根变了,就说明切换了模块或路径,动画应该直接替换,而不是 Push/Pop 滑动。
Scene 的 key 为什么固定
1
|
key = APP_START_END_SCENE_KEY |
key 是 Scene 的身份。如果每次左侧或右侧页面变化都换一个 key,Navigation 3 容易把它当成整个场景替换。
这里固定 key,是为了表达:
1
|
只要还是 start/end 双栏模式,这就是同一个 Scene。 |
具体显示哪个左侧页面、哪个右侧页面,由 startEntry 和 endEntry 决定。
返回栈和导航操作
做到这里以后,还有一个很容易踩坑的地方:不要因为平板是双栏,就再维护一套平板专用导航。
这里没这么做。手机、平板都共用同一份 backStack,只是最后渲染方式不一样。这样返回逻辑、状态恢复、深层页面跳转都不会分裂成两套。直接看完整代码:
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 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
package com.example.app.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberNavBackStack import androidx.savedstate.serialization.SavedStateConfiguration import com.example.app.feature.AppModule import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.subclass /** * 最近一次导航操作的语义。 * * 这个枚举的职责只有一个: * 告诉导航层“这次返回栈变化属于哪种操作”。 * * 为什么要单独维护这层语义? * * 因为当前项目除了顶层 `NavDisplay` 动画, * 还需要在 Pad 的右侧 end pane 内部做局部切换动画。 * * 对于这类“Scene 内部动画”来说,仅仅知道“页面内容变了”还不够, * 还需要知道: * - 这是继续前进 * - 还是返回上一层 * - 还是直接替换整条路径 * * 这样才能决定右侧是: * - 正向滑入 * - 反向滑回 * - 还是直接切换 */ enum class NavigationOperation { /** * 向当前路径继续深入一层。 */ Push, /** * 从当前路径返回上一层。 */ Pop, /** * 直接替换整条导航路径。 * * 例如: * - 切换底部 Tab * - 在左侧重新选择另一个模块 */ Replace, /** * 恢复已有状态,而不是一次用户主动导航。 * * 例如: * - 首次进入页面 * - 设备旋转后的重新组合 * - 状态恢复 */ Restore, } /** * 应用级导航状态。 * * 为什么这里不再只暴露 `NavBackStack` 扩展函数? * * 因为当前项目已经不只是“把 route 放进栈里”这么简单, * 还需要统一维护: * - 返回栈本身 * - 最近一次导航操作语义 * - 当前选中的业务模块 * * 如果继续让页面直接操作 `backStack.add/removeLast()`, * 那么右侧动画所依赖的“Push / Pop / Replace”语义就会丢失。 * * 所以正式项目里更合理的方式是: * 由一个统一的 `AppNavigationState` 负责导航入口, * 页面只调用它提供的方法,不直接修改返回栈。 */ @Stable class AppNavigationState internal constructor( val backStack: NavBackStack<NavKey>, ) { /** * 最近一次导航操作。 * * 这个状态由导航基础设施统一维护, * 不是让具体页面自己上报。 */ var lastOperation by mutableStateOf(NavigationOperation.Restore) private set /** * 压入一个新的 route。 * * 这属于标准的前进语义,因此记录为 `Push`。 */ fun navigateTo(route: NavKey) { lastOperation = NavigationOperation.Push backStack.add(route) } /** * 用一整条新路径替换当前返回栈。 * * 这类操作不属于“继续深入”或“返回上一层”, * 而是直接重建当前显示路径,因此记录为 `Replace`。 */ fun replacePath( vararg routes: NavKey, ) { lastOperation = NavigationOperation.Replace clearStack() if (routes.isEmpty()) { backStack.add(AppRoute.Home) return } routes.forEach(backStack::add) } /** * 返回上一页,同时保留根页面。 * * 返回成功时,记录为 `Pop`; * 如果已经没有可返回页面,则不修改导航语义。 */ fun popIfPossible(): Boolean { if (backStack.size <= 1) { return false } lastOperation = NavigationOperation.Pop backStack.removeLast() return true } /** * 读取当前正在查看的模块。 * * 这个值主要给宽屏 start pane 做“当前选中项”高亮。 */ fun currentModuleOrNull(): AppModule? { for (index in backStack.size - 1 downTo 0) { when (val route = backStack.get(index)) { is AppRoute.Feature -> return route.module is AppRoute.FeatureSubPage -> return route.module is AppRoute.FeatureThirdPage -> return route.module is AppRoute.FeatureFullScreen -> return route.module else -> Unit } } return null } /** * 清空整个返回栈。 * * 这里保持私有,避免业务代码绕开统一导航入口。 */ private fun clearStack() { while (backStack.isNotEmpty()) { backStack.removeLast() } } } /** * 创建并记住应用级导航状态。 * * KMP 下需要显式提供 `SavedStateConfiguration`, * 这样 iOS 也能正确保存和恢复强类型 route。 */ @Composable fun rememberAppNavigationState(): AppNavigationState { val savedStateConfiguration = remember { SavedStateConfiguration { serializersModule = SerializersModule { polymorphic(baseClass = NavKey::class) { subclass(AppRoute.Home.serializer()) subclass(AppRoute.LoginHome.serializer()) subclass(AppRoute.LoginStatus.serializer()) subclass(AppRoute.Feature.serializer()) subclass(AppRoute.FeatureSubPage.serializer()) subclass(AppRoute.FeatureThirdPage.serializer()) subclass(AppRoute.FeatureFullScreen.serializer()) } } } } val backStack = rememberNavBackStack( savedStateConfiguration, AppRoute.Home, ) return remember(backStack) { AppNavigationState( backStack = backStack, ) } } |
这段代码可以重点看三个地方:
NavigationOperation:记录这次导航变化的语义;AppNavigationState:统一包住backStack,不让页面直接改栈;rememberAppNavigationState():创建可恢复的强类型返回栈。
这样写以后,页面调用导航时只表达意图,动画和双栏切换都交给导航层处理。
为什么要记录 lastOperation
1 2 |
var lastOperation by mutableStateOf(NavigationOperation.Restore)
private set |
因为右侧动画不能只看“栈变了”,还要知道“这次变化是什么语义”。
| 操作 | 入口方法 | 语义 | 动画 |
|---|---|---|---|
Push |
navigateTo(route) |
进入下一层 | 从右向左滑。 |
Pop |
popIfPossible() |
返回上一层 | 从左向右滑。 |
Replace |
replacePath(AppRoute.Home, AppRoute.Feature(module)) |
换一条路径 | 不滑动,直接替换。 |
Restore |
初始化 / 恢复 | 恢复状态 | 不滑动。 |
比如左侧点击另一个模块时:
1 2 3 4 |
navigationState.replacePath(
AppRoute.Home,
AppRoute.Feature(module),
) |
这不是“进入下一页”,而是“换模块”。如果做 Push 动画,会给用户错误暗示。所以这里记录为 Replace。
route 保持干净
最后再看 route。这里我比较喜欢的一点是:AppRoute 本身没有塞任何平板适配逻辑,它只表达“有哪些页面”。
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 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
package com.example.app.navigation import androidx.navigation3.runtime.NavKey import com.example.app.feature.AppModule import kotlinx.serialization.Serializable /** * 应用的强类型路由定义。 * * Navigation 3 直接把路由对象放进返回栈,因此这里的每个路由都实现 `NavKey`。 */ @Serializable sealed interface AppRoute : NavKey { @Serializable data object Home : AppRoute /** * start pane 的登录页。 * * 这个页面本身仍然属于左侧 start pane, * 但当它成为当前 start 页面时, * 右侧 end pane 会切换到正式的 `LoginStatus` screen。 */ @Serializable data object LoginHome : AppRoute /** * 登录模块的右侧状态页。 * * 这个页面是一个真正的 end screen, * 不是简单的占位文案。 * * 这样后续即使这里要接: * - ViewModel * - 登录态查询 * - 网络请求 * - 权限校验 * - 用户资料加载 * * 也都可以继续沿着正式页面的结构扩展, * 不需要再从 placeholder 里拆代码出来。 */ @Serializable data object LoginStatus : AppRoute @Serializable data class Feature( val module: AppModule, ) : AppRoute /** * 功能详情下的二级页。 * * 这个路由的职责很单纯: * - 手机模式下,它就是详情页继续向下 push 的下一页 * - Pad 模式下,它仍然属于右侧 end pane 里的下一层 * * 这里先不继续拆更多类型, * 因为当前目标只是先把“详情 -> 二级页”的布局和返回关系跑通。 */ @Serializable data class FeatureSubPage( val module: AppModule, ) : AppRoute /** * 功能详情下的三级页。 * * 这一层继续沿用同样的设计原则: * - 手机下,它是更深一层的全屏页面 * - Pad 下,它仍然属于右侧 end flow * * 这里故意不再把三级页拆成更复杂的数据结构, * 因为当前目标只是让你观察 Navigation 3 在更深层级下的切换效果。 */ @Serializable data class FeatureThirdPage( val module: AppModule, ) : AppRoute /** * 功能链路中的全屏页。 * * 它和前面的 `Feature / FeatureSubPage / FeatureThirdPage` 有一个本质区别: * * - 前三者属于右侧 end flow * - 这个页面不再属于右侧 end flow,而是一个真正的全屏页面 * * 这样设计后: * - 手机下,它仍然只是正常的全屏下一页 * - Pad 下,它会临时退出主从布局,直接覆盖整个内容区域 * * 这正好可以给你演示: * “同一个功能链里,前几层属于右侧 end pane,继续深入后再切到全屏页” * 这样的混合导航模型是怎么落到一份共享返回栈上的。 */ @Serializable data class FeatureFullScreen( val module: AppModule, ) : AppRoute } |
这个分层很重要:
| 职责 | 放在哪里 |
|---|---|
| 有哪些 route | AppRoute.kt |
| route 对应哪个页面 | AppNavigationHost.kt |
| route 属于哪个 pane | AppNavigationHost.kt 的 metadata |
| 返回栈和导航语义 | AppNavigationState.kt |
| 双栏布局和动画 | AppStartEndScene.kt |
| 是否启用双栏 | AppStartEndSceneStrategy |
这样页面组件本身不需要关心“我现在是在手机全屏,还是平板右侧”。同一批页面可以被不同场景复用。
容易误解的几个点
Scene.entries 不是完整返回栈
完整返回栈在 NavDisplay(backStack = navigationState.backStack) 里。Scene.entries 只是当前这个 Scene 实际拿来显示的 entry 集合。
也就是说,双栏 Scene 只关心“现在左边画谁、右边画谁”,它不负责保存整条历史。
不要只判断是不是 Pad
这里判断的是窗口宽度和横向空间,而不是设备型号。这样做更适合分屏、桌面窗口和 KMP。
如果只写 isTablet,用户在平板上开分屏、小窗或者外接显示器时,布局就很容易不准确。
右侧页面不一定都要写 endPane
这里约定普通页面默认就是 end pane,只有 start 和 full screen 需要显式标记。
这样写的好处是新增普通内容页时不需要每次都补 metadata,只有“特殊页面”才特殊声明。
切换模块不一定是 Push 动画
切换模块是 Replace,不是深入下一层。做 Push 会制造错误的层级关系。
用户点击左侧另一个模块时,他的心理预期是“换一个模块内容”,不是“进入当前模块的下一层”。
全屏页不应该强行放右边
全屏页的语义就是跳出双栏,例如预览、编辑、沉浸式页面。它应该通过 fullScreenPane() 让策略返回 null,回到普通全屏显示。
这套方案的优点
一份返回栈,多种显示形态
同样的返回栈:
1
|
Home -> Feature(Storage) -> FeatureSubPage(Storage) -> FeatureThirdPage(Storage) |
在手机上看起来是四个全屏页面逐级跳转;在平板上则是:
1 2 |
左侧:Home 右侧:FeatureThirdPage(Storage) |
返回栈没有变,只是 Scene 的渲染方式变了。
页面不关心屏幕形态
业务页面只调用:
1 2 3 |
navigationState.navigateTo(AppRoute.FeatureSubPage(route.module)) navigationState.popIfPossible() navigationState.replacePath(AppRoute.Home, AppRoute.Feature(module)) |
不用在每个页面里写:
1 2 3 4 5 6 7 |
if (windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
// 大屏横向窗口:交给 SceneStrategy 渲染 start/end 双栏
renderStartEndScene()
} else {
// 小屏或不适合双栏的窗口:仍按普通单页导航渲染
renderSingleEntryScene()
} |
这让业务 UI 更干净。
支持混合导航模型
同一条链路里可以同时存在:
1
|
start / end 双栏页 -> end pane 深入页 -> end pane 三级页 -> 全屏页 |
这比单纯的 list-detail 更灵活。
动画语义更准确
通过 NavigationOperation,项目区分了:
- 继续深入;
- 返回上一层;
- 换模块;
- 状态恢复。
因此右侧 pane 不会出现不符合用户心理预期的滑动动画。
可以继续优化的地方
上面的代码已经能把核心流程跑通。如果放到正式项目里,我觉得还可以继续往下面几个方向扩展:
start / end 宽度可配置
目前是:
1 2 |
Column(modifier = Modifier.weight(0.35f)) Column(modifier = Modifier.weight(0.65f)) |
后续可以根据窗口宽度使用不同权重,例如:
| 宽度 | start | end |
|---|---|---|
| Medium | 40% | 60% |
| Expanded | 32% | 68% |
| Extra Large | 固定 360dp | 剩余空间 |
增加分隔线或拖拽调整
现在左右 pane 之间没有明显分隔器。生产项目中可以加入:
- vertical divider;
- pane 阴影;
- 可拖拽调整宽度;
- 折叠 start pane。
更丰富的空状态
StartEndWelcomeScreen 现在是简单文字卡片。可以升级为:
- 最近打开模块;
- 快捷入口;
- 教程插画;
- 搜索框。
更细粒度的 route metadata
目前核心 metadata 是:
1 2 3 |
startPane() endPane() fullScreenPane() |
未来可以扩展:
modalPane();supportingPane();requiresExpandedWidth();preferredPaneWidth()。
总结
这个项目的平板适配最值得学习的点,不是某个具体 UI 组件,而是整体架构:
1 2 3 4 5 6 7 8 9 |
flowchart LR
A[一份 NavBackStack] --> B{SceneStrategy 判断窗口能力}
B -->|手机 / 竖屏 / 窄窗口| C[默认全屏 Scene]
B -->|宽窗口 + 横屏| D[AppStartEndScene]
D --> E[start pane]
D --> F[end pane]
F --> G{顶部 route 是否 fullScreenPane}
G -->|是| C
G -->|否| F |
它把平板适配拆成了四个清晰问题:
- 什么时候启用双栏?
用 WindowSizeClass + 横竖比例 判断。
1. 哪些页面在左边,哪些页面在右边?
用 route metadata 标记 startPane / endPane / fullScreenPane。
1. 返回栈如何统一?
用 AppNavigationState 包住 NavBackStack,统一处理 Push / Pop / Replace。
1. 右侧 pane 如何自然切换?
用 AnimatedContent + NavigationOperation + rootContentKey 区分深入、返回和替换。
一句话概括:
这不是“给平板写一套新页面”,而是“让同一份导航模型在不同窗口下渲染成不同 Scene”。
这正是 KMP + Compose Multiplatform 项目做大屏适配时非常值得采用的方向。