最近项目中做了一次平板适配。项目是 Kotlin Multiplatform + Compose 写的,界面代码主要在 commonMain,也就是说 Android 和 iOS 会共用大部分页面逻辑。

一开始看到“平板适配”这几个字,很容易想到两种做法:

  • 判断是不是平板,然后重新写一套平板页面;
  • 在每个页面里判断屏幕宽度,手动决定显示单栏还是双栏。

但这两种方式后期都比较难维护。页面越来越多以后,每个页面都写一遍适配判断,代码会很散;如果单独写一套平板页面,又容易和手机页面逻辑不同步。

所以本文就以这个项目为例,看一下如何使用 Navigation 3 + Material3 Adaptive 实现一套更适合真实项目的平板适配方案:手机上仍然是普通的全屏页面跳转,平板横屏时自动变成左侧 start pane + 右侧 end pane 的双栏结构。

本文只看平板 / 大屏适配相关代码,业务逻辑不展开。

本文大概会按这个顺序展开:

  • 先看最终效果和相关文件;
  • 再看窗口判断和 SceneStrategy
  • 接着看完整的双栏 Scene
  • 最后看 route、返回栈和几个容易误解的点。

开始

先看一下最终要实现的效果:

手机和平板布局对比

从效果上看,它并不是简单把页面整体放大,而是根据窗口形态改变导航展示方式:

  • 手机或竖屏:还是传统的单页全屏跳转;
  • 平板横屏 / 宽窗口:左侧固定显示入口页,右侧显示功能详情页;
  • 右侧继续进入二级、三级页面时,只在右侧区域切换;
  • 某些编辑、预览、沉浸式页面仍然可以主动退出双栏,变成真正的全屏页面。

所以这篇文章重点不是“怎么写两个布局”,而是看清楚这几个问题:

  1. Navigation 3 是怎么把返回栈渲染成页面的;
  2. SceneStrategy 是怎么决定当前该用单页还是双栏的;
  3. NavEntry.metadata 是怎么给页面打上 start / end / full screen 标签的;
  4. 为什么业务页面本身不需要知道自己是在手机还是平板上运行。

如果先不看代码,可以把整体思路先记成下面这张图:

平板适配心智模型

后面所有实现,其实都是围绕这张图展开:一份返回栈,按窗口能力选择不同 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 判定流程

接下来要看 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,最终把页面画出来。

接下来直接上完整代码。这里不要只看 RowColumn,因为右侧内容区、切换动画、占位页、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())
}

这段代码建议按下面这个顺序看:

  1. 先看 content,它决定左右两栏真正怎么画;
  2. 再看 AnimatedContent,它决定右侧页面变化时怎么动;
  3. 最后看 companion object 里的几个方法,它们就是给 route 打标签用的。

如果直接从泛型、接口开始看,反而容易被 Navigation 3 的概念绕进去。

先把 Scene 里的官方 API 说清楚

上面这段代码比较长,如果第一次看 Navigation 3 的 Scene,很容易只看到 RowColumn,忽略真正关键的几个 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 不执行进入 / 退出动画。 ReplaceRestore 时不表现成深入或返回,避免误导用户这是层级跳转。
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.HomeAppRoute.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 例如 HomeLoginHome
endRootContentKey 右侧功能链路的根 key 用来判断是“同一链路继续 push/pop”,还是“切换到另一条功能链路”。
endEntry 右侧当前显示的 entry 例如 FeatureSubPageFeatureThirdPage。可以为 null。
navigationOperation 最近一次导航动作 决定右侧动画方向:Push、Pop、Replace、Restore。

注意:startEntryendEntryendRootContentKey 不是官方自动判断出来的,而是后面的 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。中间的 FeatureFeatureSubPage 仍然存在于 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 的流转关系

图里这条线很关键: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()
                    },
                )
            }
        },
    )

上面这段代码看起来比较长,但真正和平板适配有关的地方主要有三处:

  1. sceneStrategy:告诉 NavDisplay,在合适的时候使用自定义双栏 Scene;
  2. metadata = AppStartEndScene.startPane():告诉策略这个 entry 可以作为左侧入口;
  3. metadata = AppStartEndScene.fullScreenPane():告诉策略这个 entry 不要放进双栏,要回到全屏显示。

也就是说,页面自己并不知道“我现在在左边还是右边”,这个信息是由导航层统一描述的。

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 类型”的候选页面自动丢掉。

所以 FeatureFeatureSubPageFeatureThirdPage 不需要每个都写:

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,
        )
    }
}

这里用了两个窗口条件:

  1. currentWindowAdaptiveInfo().windowSizeClass:Material 3 Adaptive 提供的窗口尺寸分类;
  2. 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。

具体显示哪个左侧页面、哪个右侧页面,由 startEntryendEntry 决定。


返回栈和导航操作

做到这里以后,还有一个很容易踩坑的地方:不要因为平板是双栏,就再维护一套平板专用导航。

这里没这么做。手机、平板都共用同一份 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

它把平板适配拆成了四个清晰问题:

  1. 什么时候启用双栏?
    WindowSizeClass + 横竖比例 判断。

  2. 哪些页面在左边,哪些页面在右边?
    用 route metadata 标记 startPane / endPane / fullScreenPane

  3. 返回栈如何统一?
    AppNavigationState 包住 NavBackStack,统一处理 Push / Pop / Replace

  4. 右侧 pane 如何自然切换?
    AnimatedContent + NavigationOperation + rootContentKey 区分深入、返回和替换。

一句话概括:

这不是“给平板写一套新页面”,而是“让同一份导航模型在不同窗口下渲染成不同 Scene”。

这正是 KMP + Compose Multiplatform 项目做大屏适配时非常值得采用的方向。