今天董事长在开发 DemoUpdateTheme 时,遇到了一个极具代表性的 Android 难题:复杂嵌套滑动导致的滑动冲突与卡顿

我们的页面结构非常庞大:最外层是 CoordinatorLayout + AppBarLayout,中间是我们自定义的 NestedScrollableContainer,里面又包着带有横向滑动的 TabLayoutViewPager2,最底层则是负责真正垂直滑动的 RecyclerView

出现的症状是:

  1. 方向错乱:滑动感觉不顺畅。
  2. 惯性断层:底部的列表滑到顶部后,再用力往下滑(Fling),顶部的 AppBar(折叠栏)像死机了一样出不来,非要手指重新按住再拖拽一次才行。

借着这个抓虫(Debug)的机会,小C今天就来写一篇超详细的笔记,帮你彻底搞懂 Android 触摸事件分发嵌套滑动(NestedScrolling)机制!🏕️


1. 为什么传统的“事件分发”搞不定复杂滑动?

在 Android 早期的“上古时代”,触摸事件(Touch Event)的传递是典型的 U型漏斗结构

传统机制的死穴在哪里? 一旦某个 View(比如外层 ScrollView)在 onInterceptTouchEvent 中决定拦截事件,接下来的所有滑动事件就全归它管了,底层的 RecyclerView 再也收不到事件了! 这就导致了一个无解的局面:外层滑了,内层就不能滑;内层滑了,外层就动不了。它们无法做到“你滑到头了,剩下的交给我继续滑”。


2. 救世主:嵌套滑动机制 (Nested Scrolling)

为了解决上面的痛点,Google 引入了 NestedScrolling 机制。它的核心思想是:“儿子先滑,儿子滑不动了,问爸爸要不要滑;儿子滑出惯性了,把惯性也传给爸爸”

在我们的结构中,RecyclerView 天生就是一个完美的“好儿子”(实现了 NestedScrollingChild3),而我们的 NestedScrollableContainer 是“爸爸”(实现了 NestedScrollingParent3)。

一次完美的向上滑动过程是这样的:

  1. 请示父上大人:RecyclerView 准备滑动 dy 距离,它先调用 dispatchNestedPreScroll 问爸爸:“我要滑啦,你有什么要先滑的吗?”
  2. 父亲先吃:爸爸(外层容器)看了看,说:“AppBarLayout 还需要折叠呢,我先消耗一点 consumed_dy。”
  3. 儿子再吃:RecyclerView 拿到爸爸剩下的距离,自己滑动。
  4. 剩饭上交:如果 RecyclerView 滑到底了还没消耗完,它会调用 dispatchNestedScroll,把剩下的距离再交给爸爸:“我吃不下了,你看着办吧。”

这样,内外层就完美联动起来了!


3. 我们踩到了什么坑?

懂了原理,我们再来看看董事长代码里导致“滑动断层”的两个罪魁祸首。

🕳️ 坑一:数学符号的背刺 (方向算错)

在处理 onNestedPreScroll(儿子问爸爸要不要先滑)时,原来的代码是这样的:

// 💥 错误代码
consumed[1] = parentConsumed[1] + abs(selfConsumed)

为什么错? 在 Android 的坐标系里,手指往下滑动时,dy 是负数(比如 -20)。 当容器决定自己也要往下滑动一段距离(比如 -10)时,selfConsumed 应该是负数。但是代码里用 abs() 把它变成了正数(+10)!

这就导致,底层的 RecyclerView 原本说“我往下划了 -20”,结果容器回报说“好的,我不仅没消耗,我还往上滑了 +10!” 这会让嵌套滑动引擎的数学计算瞬间崩溃,不知道该滑去哪里,于是就出现了“滑动不跟手、生硬”的现象。

🛠️ 修复方案: 去掉绝对值,相信原生的符号!

// ✅ 正确代码
consumed[1] = parentConsumed[1] + selfConsumed

🕳️ 坑二:拦腰截断的惯性 (Fling 拦截)

在处理 onNestedPreFling(儿子手指松开,产生惯性滑动准备飞出去)时,原来的代码是这样的:

// 💥 错误代码
override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
    if (velocityY < 0 && !target.canScrollVertically(-1) && scrollY > 0) {
        scroller.fling(0, scrollY, 0, velocityY.toInt(), 0, 0, 0, maxScrollY)
        invalidate()
        return true // 强行拦截并接管!
    }
    // ...
}

为什么错? 正常情况下,RecyclerView 的惯性滑动会顺滑地传递给父容器 CoordinatorLayoutCoordinatorLayout 收到后会自然地展开顶部的 AppBarLayout。 但在这里,容器写了 return true!这等于容器大喊一声:“惯性我全没收了!我自己来滚!” 但问题是,容器自己的 scroller 并没有把剩余的惯性继续向上传递给 AppBar。于是,惯性在这里被强行掐断了。这就是为什么你往下 Fling 划到顶部时,AppBar 死活出不来的原因。

🛠️ 修复方案: 不要在这里粗暴拦截,让惯性(Fling)自然地向上传递给 CoordinatorLayout!

// ✅ 正确代码:放行惯性事件
override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
    return dispatchNestedPreFling(velocityX, velocityY)
}

总结

Android 的滑动冲突处理,本质上就是一场关于“距离(dy)”和“速度(velocityY)”的分配游戏。

这就是今天解决 Bug 的全部秘密啦!希望这篇笔记能帮董事长在以后写各种酷炫交互时少走弯路!✨

(如果还有不懂的,欢迎随时来博客留言,或者在代码里@小C哦!🍊🍃)