Miuix之miuix-nav深度解析:手势返回与预测性返回的自研导航运行时
Miuix之miuix-nav深度解析:手势返回与预测性返回的自研导航运行时
Miuix 是一个 Compose Multiplatform 的 UI 组件库,其导航模块 miuix-nav 是一个自研的手势返回 + 预测性返回导航运行时:整条返回栈由单个浮点值驱动,让 1:1 跟手的边缘滑动、系统预测性返回与程序化跳转共用同一套转场动画,且零依赖 androidx.navigation。本文带你从设计思想到平台差异,完整看懂这套运行时。
一、核心设计:用「连续栈深度」驱动整条返回栈 💡
传统导航库里,转场动画往往是"每次跳转配一段动画",而 miuix-nav 的做法更彻底:
- 整条返回栈由**单个
Animatable<Float>(animatedTop)**驱动; - 每个页面(entry)的视觉状态,只是它相对深度
relativeDepth = animatedTop - index的纯函数; - 因此手势、动画、状态恢复天然对齐——不存在"两套动画需要同步"的问题。
这一模型带来三个直接好处:
| 能力 | 为什么自然成立 |
|---|---|
| 1:1 跟手返回 | 手指直接 snapTo 驱动同一个浮点值,不经过任何缓动插值 |
| 连续多步出入栈 | 一次 push/pop 多层时,深度连续扫过每一层,而不是塌缩成一次交叉淡入 |
| 自定义转场 | 拿到原始 float 深度,自由写 graphicsLayer 变换即可 |
核心实现位于 miuix-nav/src/commonMain/kotlin/top/yukonga/miuix/kmp/nav/runtime/,其中 NavDriver.kt 是跟手与收敛的物理内核,NavPresentation.kt 负责把深度映射为各页面的角色与可见窗口。官方指南见 docs/zh_CN/guide/miuix-nav.md。
二、快速上手:NavDisplay 与 NavController 的使用方式 🚀
在 build.gradle.kts 中添加依赖:
implementation("top.yukonga.miuix.kmp:miuix-nav:<version>")
最小用法是:定义可序列化的路由、创建返回栈、用 NavDisplay 注册 entry:
@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 = { backStack.add(Route.Detail(it)) }) }
entry<Route.Detail> { route -> DetailScreen(route.id) }
}
}
几点实用建议:
rememberNavBackStack是 inline 函数:用具体 key 作种子时,请显式传入超类型rememberNavBackStack<Route>(Route.Home),否则整个密封层级不可序列化;- 返回栈本质是
SnapshotStateList<NavKey>,可以直接add/removeLastOrNull,也可以用 NavController.kt 包装出push/pop/replace/popUntil等更语义化的 API; - 路由必须是
data class/data object(值派生的toString()),否则进程死亡后rememberSaveable状态会被静默重置。
三、手势返回:速度优先、位置兜底的松手判定 🖐️
miuix-nav 的手势返回不是"另起炉灶",而是与普通 pop 共用同一个 animatedTop 驱动,因此预测性返回默认不需要单独维护一套动画。
1:1 跟手的实现
手势进行中,每帧调用 snapToFinger,把深度精确钉在 topIndex - 手指进度 上——手指到深度这条轴上没有任何缓动,所以拖动恒等式 depth == 1 - progress 永远成立。更妙的是"抓取锚点"(grab-anchor)模型:即使共享弹簧还在飞行中被打断,手势也能从采样点平滑接管,第一帧零跳变。
松手后去留如何判定
NavDriver.kt 中的 navBackCommitDecision 采用速度优先、位置兜底:
- 松手速度足够大(
|v| ≥ 1.0进度/秒)→ 只看方向,轻甩即回 / 甩出即提交; - 速度落在死区内 → 看位置:进度 ≥ 0.5 提交,否则回弹。
提交(commit)时,松手瞬时速度会作为弹簧初速度交接给收敛曲线,保证"手指驱动的位移 → 弹簧收敛"之间数值和一阶导数都连续,视觉上没有顿挫。
手势仲裁:不打扰页面内的滚动
NavSwipeArbitrator.kt 实现了"子组件优先"的仲裁:第一次位移消费开启确认窗口,只有当导航自己判定内容没有取得所有权、且沿关闭方向的累计位移超过 touch slop 后,导航才会接管指针。一旦某一方取得所有权,整段触摸序列内不再转移。
滑动关闭方向是按需开启的(entry(swipeDismiss = ...)),方向值是物理屏幕方向,不随 RTL 镜像:
| 方向 | 手指移动 | 典型场景 |
|---|---|---|
LeftToRight |
向右 | LTR 布局下的返回滑动 |
RightToLeft |
向左 | RTL 布局下的返回滑动 |
TopToBottom |
向下 | 底部上滑 Modal 的关闭 |
四、预测性返回:多平台输入归一到同一条返回流 📱
不同平台的返回输入源完全不同,miuix-nav 通过 PredictiveBackHandler.kt 把它们归一到同一条流,再汇入同一个驱动:
| 平台 | 输入来源 | 语义 |
|---|---|---|
| Android | 系统预测性返回 | 连续流式手势:progress + 侧滑边沿逐帧上报,1:1 跟手 |
| iOS | 运行时内实现的交互式边缘侧滑 | 连续,与 Android 相同的跟手与速度交接 |
| Desktop | ESC 键 |
离散触发:一次性 commit 直接进入收敛弹簧 |
| Web | 调用方自定义(如浏览器返回) | 从你自己的触发源喂入同一返回流即可 |
这套桥接还有一个"免费福利":处理器注册在共享 dispatcher 上,按"后组合、先启用者优先"仲裁,所以打开的 Dialog / 底部 Sheet 会自动先于导航消费返回事件,无需额外接线。
五、Settle 物理:commit / cancel / programmatic 三相位 🎢
转场通过 NavMotion 声明自己的收敛物理,按相位各持一条曲线:
| 相位 | 触发时机 | 默认值 |
|---|---|---|
commit |
松手提交 pop,以松手速度为种子 | 临界阻尼弹簧(damping 1、stiffness 146) |
cancel |
松手取消,条目弹回静止位 | 同上 |
programmatic |
从静止出发的整步 push/pop | 500ms 既定曲线 tween |
几个精心设计的细节:
- 默认永不回弹:commit 会把种子速度钳在精确的"无过冲下界"上;想要弹性回弹,需显式传
clampOvershoot = false; - cancel 恒以静止位钉住边界:欠阻尼弹簧也不可能越过静止位翻转变换归属;
- 转场作用域暴露
scope.settle(相位、releaseVelocity、elapsedMillis),可以写出"墙钟曲线 + 速度比例回弹叠加"这类高级效果。
六、自定义转场:读原始深度,自由写 graphicsLayer 🎨
内置预设库 NavTransitions 提供三套:
MiuixDefault:全宽滑动 + 四分之一宽视差 + 轻微透明衰减;Modal:底部上滑,下层保留可见(适合底部表单);None:瞬时无动画。
想要卡片式、缩放式转场,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
}
由于 block 在延迟读取的图层内执行,读 relativeDepth 不触发重组,动画零成本。若设计需要"按钮 pop"与"预测性返回手势"两套独立动画(平台本身就是如此),用 navDirectionalTransition 组合 push / pop / predictivePop 三条分支即可。示例应用中有一个完整的平台级转场实现值得参考:CrossActivityTransition.kt——居中缩放、贴边、带阻尼的纵向跟手、提交后飞出。
七、状态保存、生命周期与嵌套导航 🗂️
- 栈的保存与恢复:
rememberNavBackStack经rememberSaveable+kotlinx.serialization持久化,配置变更与进程死亡后返回栈完整恢复(@Serializable是硬性要求); - 条目生命周期是深度的纯函数:静止顶层为
RESUMED;被覆盖 / 进入中 / 离开中的层为STARTED;手势驱动期间所有层封顶STARTED,避免手指在阈值附近徘徊时逻辑反复触发; - ViewModel 按 display 持有:被覆盖的 entry 只要还在栈上就保留其 ViewModel,pop 时才清空;
- 嵌套
NavDisplay:各 tab 持有自己的栈时,内层栈优先消费返回,内层到根后返回穿透给外层,且内层生命周期被宿主 entry 封顶。
这套运行时在 miuix-nav/src/commonTest 与 desktopTest 下有相当完整的测试覆盖(驱动收敛、settle 物理、滑动冲突、嵌套分发等),可以按 NavDriverTest.kt 等文件进一步研读。
八、小结:v1 刻意保持的精简边界 ✅
miuix-nav v1 有意做成单一、扁平的返回栈,以下能力被明确延后到 v2+:Dialog / 底部表单场景策略、跨目的地共享元素转场、KSP 注解式路由、内置结果通道(回传结果请在应用层用 SharedFlow / Channel 自建一条小型总线)。
一句话总结它的设计哲学:把"栈的深度"作为唯一事实来源,手势、转场、物理、状态恢复全部收敛为这一个浮点值的纯函数——这正是"预测性返回"在多平台上既顺滑、又无需为每套动画打补丁的根本原因。

