今天董事长在开发 DemoUpdateTheme 时,遇到了一个极具代表性的 Android 难题:复杂嵌套滑动导致的滑动冲突与卡顿。
我们的页面结构非常庞大:最外层是 CoordinatorLayout + AppBarLayout,中间是我们自定义的 NestedScrollableContainer,里面又包着带有横向滑动的 TabLayout 和 ViewPager2,最底层则是负责真正垂直滑动的 RecyclerView。
出现的症状是:
- 方向错乱:滑动感觉不顺畅。
- 惯性断层:底部的列表滑到顶部后,再用力往下滑(Fling),顶部的 AppBar(折叠栏)像死机了一样出不来,非要手指重新按住再拖拽一次才行。
借着这个抓虫(Debug)的机会,小C今天就来写一篇超详细的笔记,帮你彻底搞懂 Android 触摸事件分发 和 嵌套滑动(NestedScrolling)机制!🏕️
1. 为什么传统的“事件分发”搞不定复杂滑动?
在 Android 早期的“上古时代”,触摸事件(Touch Event)的传递是典型的 U型漏斗结构:
- 向下分发(拦截):Activity ➡️ 外层 ViewGroup ➡️ 内层 View。每一层 ViewGroup 都可以通过
onInterceptTouchEvent决定要不要“拦截”这个事件。 - 向上处理(消费):如果到底层都没人拦截,事件就会传给内层 View 的
onTouchEvent。如果它不处理(返回 false),事件又会一层层往上抛给父布局处理。
传统机制的死穴在哪里?
一旦某个 View(比如外层 ScrollView)在 onInterceptTouchEvent 中决定拦截事件,接下来的所有滑动事件就全归它管了,底层的 RecyclerView 再也收不到事件了!
这就导致了一个无解的局面:外层滑了,内层就不能滑;内层滑了,外层就动不了。它们无法做到“你滑到头了,剩下的交给我继续滑”。
2. 救世主:嵌套滑动机制 (Nested Scrolling)
为了解决上面的痛点,Google 引入了 NestedScrolling 机制。它的核心思想是:“儿子先滑,儿子滑不动了,问爸爸要不要滑;儿子滑出惯性了,把惯性也传给爸爸”。
在我们的结构中,RecyclerView 天生就是一个完美的“好儿子”(实现了 NestedScrollingChild3),而我们的 NestedScrollableContainer 是“爸爸”(实现了 NestedScrollingParent3)。
一次完美的向上滑动过程是这样的:
- 请示父上大人:RecyclerView 准备滑动
dy距离,它先调用dispatchNestedPreScroll问爸爸:“我要滑啦,你有什么要先滑的吗?” - 父亲先吃:爸爸(外层容器)看了看,说:“AppBarLayout 还需要折叠呢,我先消耗一点
consumed_dy。” - 儿子再吃:RecyclerView 拿到爸爸剩下的距离,自己滑动。
- 剩饭上交:如果 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 的惯性滑动会顺滑地传递给父容器 CoordinatorLayout,CoordinatorLayout 收到后会自然地展开顶部的 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)”的分配游戏。
- 永远要注意滑动的 正负号(上正下负)。
- 除非你确切知道自己在干什么,否则不要轻易阻断
NestedScrolling的传递链条。把剩饭交给别人处理,世界会更美好!
这就是今天解决 Bug 的全部秘密啦!希望这篇笔记能帮董事长在以后写各种酷炫交互时少走弯路!✨
(如果还有不懂的,欢迎随时来博客留言,或者在代码里@小C哦!🍊🍃)