Miuix之miuix-nav深度解析:手势返回与预测性返回的自研导航运行时

原创2026-10-11 17:52:191,696 阅读

Miuix之miuix-nav深度解析:手势返回与预测性返回的自研导航运行时

Miuix 是一个 Compose Multiplatform 的 UI 组件库,其导航模块 miuix-nav 是一个自研的手势返回 + 预测性返回导航运行时:整条返回栈由单个浮点值驱动,让 1:1 跟手的边缘滑动、系统预测性返回与程序化跳转共用同一套转场动画,且零依赖 androidx.navigation。本文带你从设计思想到平台差异,完整看懂这套运行时。

Miuix多平台UI组件界面展示

一、核心设计:用「连续栈深度」驱动整条返回栈 💡

传统导航库里,转场动画往往是"每次跳转配一段动画",而 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示例应用的TabRow与页面导航界面

四、预测性返回:多平台输入归一到同一条返回流 📱

不同平台的返回输入源完全不同,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 自建一条小型总线)。

一句话总结它的设计哲学:把"栈的深度"作为唯一事实来源,手势、转场、物理、状态恢复全部收敛为这一个浮点值的纯函数——这正是"预测性返回"在多平台上既顺滑、又无需为每套动画打补丁的根本原因。

登录后查看全文
miuix