最近项目中做了一次平板适配。项目是 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:
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:
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 里。直接看代码:
@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) |
缓存策略对象;只有窗口等级、横屏状态或导航动作变化时,才重新创建策略。 |
也就是说,它不是粗暴写成:
if (deviceInfo.isTablet) {
useTwoPaneLayout()
} else {
useSinglePaneLayout()
}
而是更现代的大屏适配思路:
设备类型不重要,窗口能力才重要。
折叠屏、分屏、桌面窗口、iPad 横竖屏,都应该按当前窗口尺寸响应。
SceneStrategy 决定显示方式
接下来要看 SceneStrategy。它的作用可以先简单理解成:Navigation 3 在准备显示页面之前,会先问它一句“这次要不要换一种显示方式”。核心逻辑在 AppStartEndSceneStrategy.calculateScene():
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。它被标记为真正的全屏页:
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 是怎么工作的。
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 是什么
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 提供的接口。它表示:当前导航栈不一定只能显示一个全屏页面,也可以显示一个“场景”。
默认手机导航一般是:
栈顶 entry -> 全屏显示
这次适配想在平板横屏时变成:
startEntry -> 左侧显示
endEntry -> 右侧显示
所以它实现了自己的 Scene<T>。
这里的 T 是导航 key 的类型。当前项目中最终对应 NavKey,也就是 AppRoute.Home、AppRoute.Feature 这些 route。写成泛型,是为了和 Navigation 3 官方 API 保持一致,不把这个 Scene 写死到某一个具体 route 类型上。
构造参数
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 为什么只放当前要显示的页面
override val entries: List<NavEntry<T>> = buildList {
add(startEntry)
endEntry?.let(::add)
}
Scene.entries 不是完整返回栈。完整返回栈在 NavDisplay(backStack = navigationState.backStack) 里。
这里的 entries 表示“当前 Scene 真正参与显示的 entry”。
比如返回栈是:
Home -> Feature -> FeatureSubPage -> FeatureThirdPage
在双栏里真正要显示的是:
左侧:Home
右侧:FeatureThirdPage
所以当前 Scene 的 entries 只需要放这两个 entry。中间的 Feature、FeatureSubPage 仍然存在于 backStack 中,只是当前不直接显示。
这就是 Navigation 3 自定义 Scene 的价值:返回栈可以很深,但当前场景可以只渲染其中一部分。
content 就是真正的页面内容
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。
所以这里直接写:
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.weight(0.35f)) {
startEntry.Content()
}
Column(modifier = Modifier.weight(0.65f)) {
// end pane
}
}
这表示当前导航场景是一整块左右布局,而不是单个全屏页面。
左右宽度为什么这样分
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 里:
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 后,调用:
startEntry.Content()
就是在左侧 pane 渲染 Home 对应的页面。
右侧的:
state.entry.Content()
同理,就是渲染当前右侧 entry 对应的页面。
右侧为什么使用 AnimatedContent
左侧 pane 是稳定入口,右侧 pane 才是不断 push / pop 的内容区。
AnimatedContent 是 Compose Animation 的 API。它的语义是:当 targetState 改变时,按照 transitionSpec 在旧内容和新内容之间做切换。
这里的 targetState 是:
EndPaneState(
entry = endEntry,
rootContentKey = endRootContentKey,
)
它没有只传 endEntry,而是额外带上 rootContentKey,因为右侧动画要区分两种情况:
| 情况 | 例子 | 动画应该怎样 |
|---|---|---|
| 同一条链路继续深入 | Feature -> FeatureSubPage |
Push / Pop 滑动。 |
| 切换到另一条链路 | 从“存储模块”切到“权限模块” | 直接替换,不滑动。 |
这也是为什么代码里有:
if (initialState.rootContentKey != targetState.rootContentKey) {
EnterTransition.None togetherWith ExitTransition.None
}
根链路变了,就不要做前进 / 后退动画,否则用户会误以为两个模块之间存在页面层级关系。
Push、Pop、Replace、Restore 动画
NavigationOperation.Push ->
slideInHorizontally(initialOffsetX = { width -> width }) togetherWith
slideOutHorizontally(targetOffsetX = { width -> -width })
Push 表示进入下一层,所以新页面从右侧进来,旧页面向左出去。
NavigationOperation.Pop ->
slideInHorizontally(initialOffsetX = { width -> -width }) togetherWith
slideOutHorizontally(targetOffsetX = { width -> width })
Pop 表示返回上一层,所以方向相反。
NavigationOperation.Replace,
NavigationOperation.Restore ->
EnterTransition.None togetherWith ExitTransition.None
Replace 是换路径,不是深入;Restore 是恢复状态,也不是用户主动进入下一层。因此这里不做滑动。
右侧为空时显示欢迎页
当返回栈只有一个 start pane,例如只有 Home,右侧还没有任何功能页时,如果右侧直接空白,体验会很奇怪。
所以这里放了一个欢迎页 / 占位页:
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。直接看代码:
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 参数说明
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
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 里的判断:
private fun <T : Any> NavEntry<T>.isEndPane(): Boolean {
return metadata.containsKey(APP_END_PANE_KEY) || (!isStartPane() && !isFullScreenPane())
}
这表示项目采用“默认右侧”的约定:
不是 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 不需要每个都写:
metadata = AppStartEndScene.endPane()
这能减少大量重复代码。
FullScreen 页面为什么要单独标记
entry<AppRoute.FeatureFullScreen>(
metadata = AppStartEndScene.fullScreenPane(),
) { route ->
FeatureFullScreenScreen(
module = route.module,
platformName = platformName,
onBack = {
navigationState.popIfPossible()
},
)
}
如果不标记,它会按默认规则被当成 end pane,只显示在右侧 65% 的区域。
但它的产品语义是全屏页,比如预览页、沉浸页、编辑器页。所以必须通过 metadata 告诉策略:当这个页面在栈顶时,不要启用双栏。
什么时候进入双栏,什么时候退出双栏
AppStartEndScene 负责“怎么画双栏”,但它不负责“什么时候该用双栏”。
这个判断在 AppStartEndSceneStrategy 里。
rememberAppStartEndSceneStrategy
@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 的核心方法是:
override fun SceneStrategyScope<T>.calculateScene(
entries: List<NavEntry<T>>,
): Scene<T>?
可以理解成 NavDisplay 在问策略:
当前这些 entries,要不要组合成一个自定义 Scene?
如果返回 AppStartEndScene,就启用双栏;如果返回 null,就交回 Navigation 3 默认显示,也就是普通全屏。
这里的判断顺序是:
1. 窗口宽度不够 -> return null
2. 不是横向空间充足 -> return null
3. 栈顶是 full screen -> return null
4. 找不到 start pane -> return null
5. 条件满足 -> 返回 AppStartEndScene
对应成图就是:
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]
关键代码:
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
val startEntry = entries.findLast { entry -> entry.isStartPane() }
返回栈里可能出现多个 start pane,例如从模块首页切到登录首页。此时左侧应该显示最近的入口,所以用 findLast。
endRootEntry 为什么用 firstOrNull
val endRootEntry = entries.firstOrNull { entry -> entry.isEndPane() }
它不是为了找右侧当前页,而是为了找右侧这条功能链路的根。
比如:
Home -> Feature -> FeatureSubPage -> FeatureThirdPage
当前右侧页是 FeatureThirdPage,但右侧根是 Feature。当根变了,就说明切换了模块或路径,动画应该直接替换,而不是 Push/Pop 滑动。
Scene 的 key 为什么固定
key = APP_START_END_SCENE_KEY
key 是 Scene 的身份。如果每次左侧或右侧页面变化都换一个 key,Navigation 3 容易把它当成整个场景替换。
这里固定 key,是为了表达:
只要还是 start/end 双栏模式,这就是同一个 Scene。
具体显示哪个左侧页面、哪个右侧页面,由 startEntry 和 endEntry 决定。
返回栈和导航操作
做到这里以后,还有一个很容易踩坑的地方:不要因为平板是双栏,就再维护一套平板专用导航。
这里没这么做。手机、平板都共用同一份 backStack,只是最后渲染方式不一样。这样返回逻辑、状态恢复、深层页面跳转都不会分裂成两套。直接看完整代码:
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
var lastOperation by mutableStateOf(NavigationOperation.Restore)
private set
因为右侧动画不能只看“栈变了”,还要知道“这次变化是什么语义”。
| 操作 | 入口方法 | 语义 | 动画 |
|---|---|---|---|
Push |
navigateTo(route) |
进入下一层 | 从右向左滑。 |
Pop |
popIfPossible() |
返回上一层 | 从左向右滑。 |
Replace |
replacePath(AppRoute.Home, AppRoute.Feature(module)) |
换一条路径 | 不滑动,直接替换。 |
Restore |
初始化 / 恢复 | 恢复状态 | 不滑动。 |
比如左侧点击另一个模块时:
navigationState.replacePath(
AppRoute.Home,
AppRoute.Feature(module),
)
这不是“进入下一页”,而是“换模块”。如果做 Push 动画,会给用户错误暗示。所以这里记录为 Replace。
route 保持干净
最后再看 route。这里我比较喜欢的一点是:AppRoute 本身没有塞任何平板适配逻辑,它只表达“有哪些页面”。
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,回到普通全屏显示。
这套方案的优点
一份返回栈,多种显示形态
同样的返回栈:
Home -> Feature(Storage) -> FeatureSubPage(Storage) -> FeatureThirdPage(Storage)
在手机上看起来是四个全屏页面逐级跳转;在平板上则是:
左侧:Home
右侧:FeatureThirdPage(Storage)
返回栈没有变,只是 Scene 的渲染方式变了。
页面不关心屏幕形态
业务页面只调用:
navigationState.navigateTo(AppRoute.FeatureSubPage(route.module))
navigationState.popIfPossible()
navigationState.replacePath(AppRoute.Home, AppRoute.Feature(module))
不用在每个页面里写:
if (windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
// 大屏横向窗口:交给 SceneStrategy 渲染 start/end 双栏
renderStartEndScene()
} else {
// 小屏或不适合双栏的窗口:仍按普通单页导航渲染
renderSingleEntryScene()
}
这让业务 UI 更干净。
支持混合导航模型
同一条链路里可以同时存在:
start / end 双栏页 -> end pane 深入页 -> end pane 三级页 -> 全屏页
这比单纯的 list-detail 更灵活。
动画语义更准确
通过 NavigationOperation,项目区分了:
- 继续深入;
- 返回上一层;
- 换模块;
- 状态恢复。
因此右侧 pane 不会出现不符合用户心理预期的滑动动画。
可以继续优化的地方
上面的代码已经能把核心流程跑通。如果放到正式项目里,我觉得还可以继续往下面几个方向扩展:
start / end 宽度可配置
目前是:
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 是:
startPane()
endPane()
fullScreenPane()
未来可以扩展:
modalPane();supportingPane();requiresExpandedWidth();preferredPaneWidth()。
总结
这个项目的平板适配最值得学习的点,不是某个具体 UI 组件,而是整体架构:
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 + 横竖比例判断。 -
哪些页面在左边,哪些页面在右边?
用 route metadata 标记startPane / endPane / fullScreenPane。 -
返回栈如何统一?
用AppNavigationState包住NavBackStack,统一处理Push / Pop / Replace。 -
右侧 pane 如何自然切换?
用AnimatedContent + NavigationOperation + rootContentKey区分深入、返回和替换。
一句话概括:
这不是“给平板写一套新页面”,而是“让同一份导航模型在不同窗口下渲染成不同 Scene”。
这正是 KMP + Compose Multiplatform 项目做大屏适配时非常值得采用的方向。