coui-nav
coui-nav 是一个自研的 Compose Multiplatform 导航运行时,核心模型为连续栈深度。整条返回栈由单个 Animatable<Float>(animatedTop)驱动;每个 entry 的视觉是其相对深度的纯函数。由此,连续出入栈、完全自定义的 float 驱动转场、以及 1:1 跟手的手势返回都自然成立。它零依赖 androidx.navigation3。
安装
在 build.gradle.kts 中添加依赖:
implementation("io.github.suqi8.coui.kmp:coui-nav:<version>")为路由层级标注 @Serializable,使返回栈能在配置变更与进程死亡后保存与恢复。
基本用法
import kotlinx.serialization.Serializable
import io.github.suqi8.coui.kmp.nav.core.NavDisplay
import io.github.suqi8.coui.kmp.nav.core.NavKey
import io.github.suqi8.coui.kmp.nav.core.rememberNavBackStack
import io.github.suqi8.coui.kmp.nav.transition.NavTransitions
@Serializable
sealed interface Route : NavKey {
@Serializable data object Home : Route
@Serializable data class Detail(val id: String) : Route
}
@Composable
fun App() {
val backStack = rememberNavBackStack<Route>(Route.Home)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
) {
entry<Route.Home> {
HomeScreen(onOpen = { id -> backStack.add(Route.Detail(id)) })
}
entry<Route.Detail> { route ->
DetailScreen(route.id, onBack = { backStack.removeLastOrNull() })
}
}
}rememberNavBackStack 返回 NavBackStack(即 SnapshotStateList<NavKey>)。可直接操作它(add / removeLastOrNull),也可用 NavController 包装以使用 push / pop / replace / popUntil。
WARNING
rememberNavBackStack 是 inline fun <reified T : NavKey>。当你用单个具体 key 作种子时,请把路由超类型作为显式类型参数传入——rememberNavBackStack<Route>(Route.Home)——以保证整个密封层级可序列化。写成 rememberNavBackStack(Route.Home) 会把 T 推断为 Route.Home,之后 push 其它子类型(如 Route.Detail)在保存/恢复时会序列化失败。
连续出入栈
由于整栈由单个 float 驱动,一次性 push 或 pop 多个 entry 会作为一段连续的运动播放,而不会塌缩成单次顶层交叉淡入:
// 一次 push 三个:animatedTop 经单一弹簧从 N 连续扫到 N+3。
backStack.add(Route.Detail("1"))
backStack.add(Route.Detail("2"))
backStack.add(Route.Detail("3"))
// pop 回到根:animatedTop 从 N 连续退到 0,反向连续运动。
while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)reconciler 把每次变化分类为 Push / Pop / MultiPush(n) / MultiPop(n) / Replace / ReplaceAll,并经 NavTransitionScope.change 暴露给转场,使你能对「连续 pop n 层」与「单次 pop」做不同动画。
转场
内置预设库 NavTransitions:
| 预设 | 描述 |
|---|---|
COUIDefault(默认) | 全宽滑动 + 四分之一宽视差 + 被覆盖层轻微透明衰减 |
Modal | 上滑、下层保留可见 |
None | 瞬时无动画 |
前缘平滑圆角裁剪与全屏调暗 scrim 不烘焙在任何预设内——它们由正交的 NavDisplayEffects 层提供(enableCornerClip、dimAmount,默认均开启);转场本身只通过 scrimFraction 塑造调暗随运动的曲线。
在 NavDisplay(transition = ...) 上设全局默认,并用 entry(transition = ...) 按路由覆盖:
NavDisplay(backStack, transition = NavTransitions.COUIDefault) {
entry<Route.Home> { HomeScreen() }
entry<Route.Detail>(transition = NavTransitions.Modal) { DetailScreen(it.id) }
}滑动关闭方向
关闭顶层 entry 的交互式滑动手势,与转场沿同一轴向:水平滑动类转场用水平滑动关闭,底部上滑的 Modal 用下滑关闭。它是按需开启的——dismissDirection 默认为 None,且所有内置预设默认都不开启。请按路由用 entry(swipeDismiss = ...) 开启,或自定义转场时声明方向。
NavSwipeDirection 的取值是物理屏幕方向(手指往哪个方向移动),不随布局方向镜像,因此请按 LTR / RTL 选对方向:
| 方向 | 手指移动 |
|---|---|
LeftToRight | 向右 —— LTR 下的返回滑动 |
RightToLeft | 向左 —— RTL 下的返回滑动 |
TopToBottom | 向下 —— 底部上滑 Modal 的关闭 |
BottomToTop | 向上 |
None(默认) | 禁用 —— 仅经返回按钮 / 系统返回 pop |
一旦手势认领成功,它会在本次手势余下过程中独占指针(消费两个轴向),因此跨轴的轻微滑动既不会抢走它、也不会中途取消;在手指抬起前,页面内的点击 / 滚动都被屏蔽。可按路由设置或覆盖——包括用 NavSwipeDirection.None 让某路由仅按钮关闭:
import io.github.suqi8.coui.kmp.nav.transition.NavSwipeDirection
NavDisplay(backStack) {
// 在该路由上开启 LTR 返回滑动(默认关闭)。
entry<Route.Home>(swipeDismiss = NavSwipeDirection.LeftToRight) { HomeScreen() }
// 底部表单:下滑关闭。
entry<Route.Sheet>(transition = NavTransitions.Modal, swipeDismiss = NavSwipeDirection.TopToBottom) { SheetScreen() }
// 不设置(默认 None):仅按钮关闭。
entry<Route.Confirm> { ConfirmScreen() }
}自定义转场仍可声明自然方向,供路由继承:navGraphicsTransition(dismissDirection = NavSwipeDirection.LeftToRight) { ... }。
自定义转场
读取原始 float 深度,自由写 graphicsLayer。该 block 在延迟读取的图层内执行,读 relativeDepth 不触发重组:
import io.github.suqi8.coui.kmp.nav.transition.navGraphicsTransition
val myTransition = navGraphicsTransition { scope ->
val d = scope.relativeDepth // animatedTop - index
translationX = -d * scope.layoutSize.width.toFloat()
scaleX = 1f - 0.1f * d.coerceIn(0f, 1f)
scaleY = scaleX
cameraDistance = 16f * scope.density.density
}NavTransitionScope 暴露 relativeDepth、role、change、gesture、layoutSize、layoutDirection、density。
通用样式(淡入淡出、缩放、共享轴等)刻意不作为预设内置——在这个 builder 上每种只需几行。例如交叉淡入淡出:
val fade = navGraphicsTransition { scope ->
val d = scope.relativeDepth
alpha = if (d <= 0f) (1f + d).coerceIn(0f, 1f) else 1f - d.coerceIn(0f, 1f)
}转场同时拥有自己的 scrim 曲线:渲染在最顶层之下的全屏调暗遮罩由 NavTransition.scrimFraction 决定——曲线取自上层(即覆盖者)转场(与被覆盖层变换一致的边界归属规则),并以下方被覆盖层为参照求值(深度 0 = 完全显露,1 = 完全覆盖)——NavDisplayEffects.dimAmount 只封顶最大暗度。默认曲线随深度线性变化(下层显露得越多,调暗越浅);经 scrim 参数传入自定义曲线,例如卡片式的「手势期间保持、提交后扫程内才淡出」:
val cardStyle = navGraphicsTransition(
scrim = { scope ->
val d = scope.relativeDepth.coerceIn(0f, 1f)
val g = scope.gesture
if (g != null) (d / (1f - g.progress).coerceAtLeast(0.001f)).coerceIn(0f, 1f) else d
},
) { scope -> /* transform */ }进度饱和在 1 之下
gesture.progress 永远不会精确到达 1——驱动器把手指驱动的进度饱和在「完全弹出」边界之下(同时屏蔽了把返回手势进度报过 1 的设备)。因此除以 1 - progress 是安全的,前提是分母的下限不超过该余量(即上例的 0.001)。更大的下限(如 0.01)会在最后百分之一破坏拖拽恒等式 depth == 1 - progress,在这类设备上表现为 scrim 明显坍缩。
一个完全基于该公共 API 实现的平台级完整转场——居中缩放、贴边、带阻尼的纵向跟手、提交后飞出以及上述手势保持 scrim——见示例应用中的 CrossActivityTransition(example/shared/src/commonMain/kotlin/navigation/CrossActivityTransition.kt)。
Settle 物理
转场还通过 NavTransition.motion 声明自己的 settle 物理:当几何必须自行收敛时(手势松开、或程序化出入栈触发)所用的时间曲线。NavMotion 按相位各持一份 NavSettleSpec:
| 相位 | 触发时机 | 默认值 |
|---|---|---|
commit | 手势松手提交 pop(预测返回 / 边缘滑动);为 Spring 时以松手速度作为种子 | 临界阻尼弹簧(dampingRatio 1、stiffness 146) |
cancel | 手势松手取消,条目弹回静止位 | 同上弹簧 |
programmatic | 从静止出发的整步 push/pop(含多步) | 既有曲线(NavProgrammaticEasing)上的 500ms tween |
NavSettleSpec 二选一:Spring(dampingRatio, stiffness, clampOvershoot) 或固定时长 Tween(durationMillis, easing):
val snappy = navGraphicsTransition(
motion = NavMotion(
// 快甩松手会过冲并弹回,幅度随甩速缩放。
commit = NavSettleSpec.Spring(dampingRatio = 0.8f, stiffness = 280f, clampOvershoot = false),
programmatic = NavSettleSpec.Tween(durationMillis = 450, easing = NavProgrammaticEasing),
),
) { scope -> /* transform */ }宿主从最顶 presented 条目的有效转场解析 motion(per-route 覆写优先),因此 pop 期间由离场条目自己的 motion 驱动其退场。
过冲规则:
- 默认情况下,commit settle 会把种子速度钳在精确的无过冲下界上,导航永不回弹。传
clampOvershoot = false保留完整松手速度;欠阻尼弹簧(dampingRatio < 1)从结构上必然过冲,必须显式传该 opt-out(构造器会拒绝歧义组合)。 - cancel settle 恒以静止位为边界钉住:越过静止位会翻转输入拦截、边界归属与调暗遮罩,因此 cancel 无论配什么弹簧都不会越界。
Tween无法携带松手速度:作为 commit 曲线时从提交点起跑(速度截断),携带速度的程序化 settle 会回退到弹簧。
settle 上下文
driver 自行动画期间,scope 暴露 settle: NavSettle?——与 gesture 互补的自驱动上下文(手指驱动 → gesture 非空;settle 中 → settle 非空;静止 → 两者皆空):
| 字段 | 含义 |
|---|---|
phase | Commit(松手提交)/ Cancel(弹回静止)/ Programmatic(从静止的程序化出入栈) |
releaseVelocity | 播种的松手速度,progress-units/s 朝 pop 为正;即使曲线是 Tween(无法消费速度)也照常记录 |
elapsedMillis | settle 开始以来的墙钟毫秒数,逐帧延迟读取源(在 graphicsLayer { } 内读取) |
这使墙钟曲线与速度叠加无需任何额外动画机制即可表达——叠加弹簧就是基于 (releaseVelocity, elapsedMillis) 的闭式数学:
val withBounce = navGraphicsTransition(
motion = NavMotion(commit = NavSettleSpec.Tween(450, NavProgrammaticEasing)),
) { scope ->
val s = scope.settle
// 确定性轨道来自 tween;其上叠加独立的速度比例回弹。
if (s?.phase == NavSettlePhase.Commit) {
val kick = (s.releaseVelocity * scope.layoutSize.width * 10f).coerceIn(0f, 1000f)
val omega = sqrt(200f)
val omegaD = omega * sqrt(1f - 0.75f * 0.75f)
val t = s.elapsedMillis / 1000f
val overlay = -(kick / omegaD) * exp(-0.75f * omega * t) * sin(omegaD * t)
scaleX = ((100f + overlay) / 100f).coerceAtMost(1f)
scaleY = scaleX
// 与运动 easing 解耦的墙钟淡变:
alpha = (1f - 5f * (s.elapsedMillis / 450f)).coerceAtLeast(0f)
}
/* 其余深度驱动几何照常 */
}注意一点:grab-anytime 打断会替换 settle(elapsedMillis 重新计时);墙钟曲线要么容忍重启,要么在 settle == null 时回退到深度轴公式。
程序化与预测性转场分离
默认情况下一套转场同时伺服两种驱动——视觉是深度的纯函数,手势与程序化 settle 重放同一份几何。当设计需要两套独立效果时(平台本身对返回键 pop 与预测返回手势就是完全不同的两套动画),用 navDirectionalTransition 组合:
val platformLike = navDirectionalTransition(
push = classicOpen, // 前进(及 replace/初始)
pop = classicClose, // 程序化 pop;缺省回落到 push
predictivePop = gestureCard, // 手势驱动期间;缺省回落到 pop
)分发规则:活动手势(预测返回或边缘滑动——上下文冻结贯穿整段松手 settle)恒选 predictivePop;否则 NavChange.Pop / MultiPop 选 pop,其余选 push。静态契约按自然来源合并:opaqueDepth 取三者最大值,dismissDirection 与 commit/cancel 物理来自 predictivePop,程序化曲线来自 pop(push.motion 永不被消费——路由级不对称请用 per-route 覆写)。
一个需要了解的取舍:手势可以在程序化 settle 中途抢占,抢占瞬间分发切到 predictivePop。若两个分支在该深度上的几何不一致,会有一帧风格跳变——在意时让两个分支在可抢占区间内保持几何接近。示例中的 CrossActivityTransition 正是这样构建的:程序化 push/pop 走经典 450ms 平移+淡变,预测返回走手势缩放卡片并配速度种子的回弹提交弹簧。
正交效果
NavDisplayEffects 承载叠加在当前转场之上的横切效果,按深度独立于转场本身计算:
| 属性 | 默认值 | 描述 |
|---|---|---|
enableCornerClip | true | 顶层 entry 在下层之上运动期间,以平滑圆角裁剪它 |
cornerClipRadius | 0.dp | 裁剪半径;传 rememberNavSystemCornerRadius() 可跟随设备屏幕圆角(平台报告为 0 时同样不裁圆角) |
cornerClipMode | Leading | 裁哪些角:Leading —— 与屏幕边缘相接的前缘角,适合滑动类转场;All —— 四角全裁,适合整页缩放的卡片类转场 |
dimAmount | 0.5f | 最顶层之下全屏调暗遮罩的最大 alpha;随运动的曲线归转场所有(scrimFraction),此处只封顶暗度。0f 关闭 |
blockInputDuringTransition | false | 拦截转场中途 entry 上的触摸输入,点击不会落到半动画状态的页面 |
backdropColor | Unspecified | 所有 entry 图层背后的纯色底——卡片类转场把下层页面缩到不足全尺寸、露出宿主背后的区域;传主题背景色使其读作页面向外延伸 |
NavDisplayEffects.Default 即上表默认值;NavDisplayEffects.None 全部关闭。卡片式配置示例:
NavDisplay(
backStack = backStack,
effects = NavDisplayEffects(
cornerClipRadius = rememberNavSystemCornerRadius(),
cornerClipMode = NavCornerClipMode.All,
dimAmount = 0.32f,
backdropColor = COUITheme.colorScheme.background,
),
) { /* ... */ }手势返回
返回已内置,且与普通 pop 共用同一个 animatedTop 与同一套 NavTransition——默认无需为预测返回单独维护一套动画(需要分离时用 navDirectionalTransition 选择性开启)。手势进行中,手指以 1:1 驱动 animatedTop(snapTo,不过插值器);松手时按「速度优先、位置兜底」判定 commit 或 cancel,并把松手瞬时速度作为初速度交接给有效 commit 曲线,保证运动连续。
两个来源汇入它:页面内滑动(全平台的 Compose 手势,按上文滑动关闭方向区分方向)与平台返回(系统预测返回 / ESC / 自定义触发)。二者流入同一驱动。
各平台的输入来源与语义不同——运行时把它们归一到同一条返回流,但跟手观感并非处处一致:
| 平台 | 来源 | 语义 |
|---|---|---|
| Android | 系统预测返回 | 连续的系统手势:progress + 侧滑边沿全程流式上报;手指 1:1 驱动 animatedTop,松手按速度交接 commit/cancel |
| iOS | 交互式边缘侧滑 | 连续的左边缘拖拽(运行时内实现);与 Android 相同的 1:1 驱动与速度交接 |
| Desktop | ESC 键 | 离散、非交互:按键触发一次性 commit 直接进入收敛弹簧(无逐帧跟手 progress) |
| Web | 自定义来源 | 由调用方定义:从你自己的触发源(如浏览器返回键 / 自定义按钮)喂入同一返回流;语义取决于来源 |
保存与恢复
rememberNavBackStack 经 rememberSaveable + 基于 kotlinx.serialization 的 Saver 持久化返回栈。
WARNING
对 rememberNavBackStack 的栈而言,@Serializable 是硬性要求而非软提示。失败点有两处:key 类型未标 @Serializable 时,rememberNavBackStack 首次组合即抛 SerializationException(序列化器在此捕获);key 实例逃出捕获的层级——或层级内存在非序列化子类型——则整个会话导航正常,到状态保存时才抛出(Android 上即应用退后台时)。若无法让 key 可序列化,请改用纯内存栈(navBackStackOf),不要使用 rememberNavBackStack。
条目状态与 contentKey
每个条目的 rememberSaveable 状态以其 contentKey 为作用域——默认就是路由值本身,除非通过 entry<T>(contentKey = { route -> ... }) 派生。在全栈唯一性检查之外还有两条要求:
- 不同的 key 必须打印出不同的字符串。 可保存状态槽位以 contentKey 的
toString()为键。两个不相等却打印出同一字符串的 key——不同包下同名的data class路由(data class的toString()不含包名),或 contentKey 工厂分别返回的Int 1与String "1"——会在 reconcile 时被一条可操作的IllegalArgumentException拒绝,而不是静默共享并互相破坏对方的已存状态。 - 字符串必须由值派生。
data class/data object路由天然满足。保留默认恒等toString()(com.app.Detail@1a2b3c)的路由类能通过所有运行时检查——字符串在会话内是唯一的——但进程死亡后会解析出全新的字符串,该条目的rememberSaveable状态会被静默重置。请坚持使用data class/data object路由,或让工厂返回由值派生的 key。
唯一性同时意味着连点防抖是应用层职责:导航按钮被快速点击两次会把同一路由值推入两次并被拒绝。请让 push 幂等——跳过已在栈上的 key(见示例应用的 Navigator.push)——或让每个实例携带唯一值。
条目生命周期与 ViewModel
每个 entry 运行在自己的 LifecycleOwner 与 ViewModelStoreOwner 之下,collectAsStateWithLifecycle、viewModel() 与基于 store 的依赖注入无需额外配置即可按屏幕划分作用域。
生命周期是深度的纯函数:静止的顶层为 RESUMED;被覆盖层、正在进入与正在离开的层为 STARTED;正在移除的 entry 降为 CREATED 直到卸载。手势驱动期间所有层封顶为 STARTED——RESUMED 意为「已静止的唯一顶层」,依赖它的逻辑不会因手指在转场阈值附近徘徊而反复触发。
ViewModel store 由 display 持有,而非 entry 的组合:被覆盖的 entry 只要仍在返回栈上就保留其 ViewModel,entry 被 pop 时清空对应 store。
嵌套 NavDisplay
NavDisplay 可以嵌套在 entry 内部(各 tab 持有自己的栈、流程内嵌子流程)。返回、状态与生命周期均正确嵌套:
- 返回:entry 内的返回消费者(嵌套 display、entry 内的返回处理器)注册在一个仅当该 entry 是交互顶层时才激活的作用域派发器之下。顶层 entry 的内层栈优先消费返回;内层栈到达根后,返回穿透给外层 display。被覆盖 entry 的内层栈永远不会截获返回。
- 状态:内层 display 的返回栈、可保存状态与 ViewModel 全部归属宿主 entry 的命名空间——pop 宿主 entry 会销毁整个嵌套作用域,内层 ViewModel(存放在宿主 entry 的
ViewModelStore内)随之清理。 - 生命周期:内层 entry 以宿主 entry 的上限封顶——宿主被覆盖时其内部所有内容不超过
STARTED。
一条须知规则:滑动手势由外层 display 优先认领(识别器有意为 parent-first)。请把可交互的内层栈放在外层根 entry 上(那里外层滑动本就禁用),或在宿主路由上用 entry(swipeDismiss = NavSwipeDirection.None) 关闭外层手势。
多窗格布局(模式,而非 API)
库有意保持单一扁平栈;自适应布局在 NavDisplay 外围组合实现。NavDisplay 以自身容器测量,放进窗格后所有转场与手势天然以窗格为参照,无需任何配置:
if (isWideScreen) {
Row {
NavigationRail(...) // 常驻侧栏
Box(Modifier.weight(1f).clipToBounds()) { NavDisplay(backStack, ...) { ... } }
}
} else {
NavDisplay(backStack, ...) { ... }
}该模式的几条规则(均为实践验证):
- 窗格必须裁剪(
clipToBounds):转场图层会平移出窗格边界(被覆盖页视差、飞出动画),不裁剪会画到旁边的侧栏上。 - 窗格内屏幕圆角归零(
cornerClipRadius = 0.dp):窗格边界在屏幕中间,设备圆角只属于物理屏幕边缘。 - 去掉侧栏已消费的边缘 insets:紧贴 rail 右侧的窗格不应再叠加屏幕缺口的 start padding。
- 同一父级下多个 display 受支持:ViewModel 注册表按 display 隔离,每个 display 的返回处理器仅在自身栈超过一个条目时启用。
- 详情窗格需要占位根路由——空返回栈会被拒绝;且跨栈迁移 key 会丢失其状态(saveable/ViewModel 作用域按 display 隔离);要在窗格形态切换时保住状态,请用 app 级
movableContentOf移动整个NavDisplay,而不是迁移 key。
示例应用的宽屏布局(example/shared/src/commonMain/kotlin/AppContent.kt)实现了该模式的常驻 rail 变体。
向上一屏回传结果
v1 核心不提供内置结果通道(NavController 上没有 navigateForResult / setResult)。请把结果留在导航运行时之外,用你已有的状态机制传递,或自行在返回栈之上叠一层极小的结果总线。
推荐形态是在栈旁挂一个按 requestKey 寻址的总线——用 MutableSharedFlow(replay = 1) 或带缓冲的 Channel,确保 pop 前一刻发出的结果不会因调用方屏幕当时未在收集而丢失:
// navigateForResult(route, requestKey):push 并为 requestKey 装一条通道。
// setResult(requestKey, value):向通道 emit,然后 pop。
// observeResult(requestKey):在调用方屏幕以 SharedFlow 收集结果。把这套思路(或 SavedStateHandle 风格的持有者)移植到你自己的 app 层即可;它被有意排除在 coui-nav v1 之外,以保持运行时是一个纯粹的深度驱动栈。
范围与扩展边界(v1)
coui-nav v1 有意做成一个单一、扁平的返回栈。以下能力在 v1 中明确不支持,延后到 v2+;请勿提前依赖:
| 能力 | v1 状态 |
|---|---|
| Dialog / 底部表单 场景策略(覆盖型目的地) | 不支持 |
| 跨目的地的共享元素转场(SharedTransition) | 不支持 |
| KSP / 注解式路由注册 | 不支持(用 entry<T> { } DSL 注册 entry) |
NavController 内置结果通道 | 不支持(见上一节) |
这些边界的存在是为了让 v1 的 API 面保持精简、让连续深度模型保持唯一事实来源。它们已记录在册,留待 v2+。
