1) 一句话大图
-
目标:让“内层可滚动的 Child”和“外层可滚动的 Parent”协同消费同一手势/滚动(谁该先吃、吃多少、吃不完谁接着吃)。
-
核心机制:Child 在滚动周期中,按顺序向父系链路发起:
- dispatchNestedPreScroll(父先吃)→
- 自己吃 →
- dispatchNestedScroll(把没吃完的给父吃)→
- 结束时再 dispatchNested(Fling/PreFling) 与 stopNestedScroll。
-
1/2/3 的演进:
- v1:只有“触摸滚动”(没有类型维度)。
- v2:加入 type(TYPE_TOUCH / TYPE_NON_TOUCH),把手势滚动与非手势滚动(fling、平滑滚动)区分开。
- v3:加入**“post 消费补充”**通道(consumed 额外输出),父可以在 onNestedScroll 再声明“我也吃了一点” ,便于复杂多级协作。
2) 接口族谱与关系(谁扩展了谁)
实现“越高版本”就自动兼容“更低版本”调用;AndroidX 会用 ViewParentCompat / ViewCompat 做回退。
| 角色 | v1 | v2(新增) | v3(新增) | 关键变化 |
|---|---|---|---|---|
| Child | NestedScrollingChild | NestedScrollingChild2 | NestedScrollingChild3 | v2 给所有关键 API 增加 type 重载;v3 的 dispatchNestedScroll(...) 新增 int[] consumed 出参 |
| Parent | NestedScrollingParent | NestedScrollingParent2 | NestedScrollingParent3 | v2 给 onNestedPreScroll/onNestedScroll/... 增加 type;v3 的 onNestedScroll(...) 新增 int[] consumed 出参 |
-
type 的意义(v2) :把“触摸链(drag)”和“非触摸链(fling/程序平滑滚动)”隔离,互不串扰。典型:RecyclerView 在手指离开后继续 fling,应该走 TYPE_NON_TOUCH。
-
补充消费(v3) :
- Child→Parent:dispatchNestedScroll(..., type, /out/ consumedFromParents)
- Parent3:onNestedScroll(..., type, /out/ consumedByThisParent) ——父把自己消耗的再次写回。
- 这样多级父可以累计消耗量,Child 能拿到“父辈实际又吃了多少”,从而修正自己的剩余位移/效果(如边缘发光、OverScroll)。
3) 一次“嵌套滑动”的完整时序(含 Pre / Post / Fling)
以 竖向为例(横向同理),dx/dy > 0 代表手指向上滑。
DOWN:
Child.startNestedScroll(SCROLL_AXIS_VERTICAL, TYPE_TOUCH)
Parent*.onStartNestedScroll → true? // 声明愿意协作
Parent*.onNestedScrollAccepted
MOVE(每一帧):
Child.dispatchNestedPreScroll(dx, dy, /*out*/consumed, offsetInWindow, TYPE_TOUCH)
↑ Parent*.onNestedPreScroll(target, dx, dy, /*out*/consumed, TYPE_TOUCH) // 父先吃
Child 自己消费 (dx - consumed[0], dy - consumed[1])
Child.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow, TYPE_TOUCH[, /*v3 out*/consumedByParents])
↑ Parent*.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, TYPE_TOUCH[, /*v3 out*/consumedThisParent])
UP/CANCEL:
(若有 fling)
Child.dispatchNestedPreFling(vx, vy): Boolean // 父可拦截/先吃 fling
Child 执行自身 fling(若上面未被消费)
Child.dispatchNestedFling(vx, vy, consumedByChild)
Child.stopNestedScroll(TYPE_TOUCH)
- PreScroll 阶段(父优先):典型“AppBar 先收起,再让列表滚”。
- PostScroll 阶段(把没吃完的交上去):比如列表到顶了,剩余 dy 交给外层 NestedScrollView 继续滚。
- Fling 阶段:先问 dispatchNestedPreFling,父可抢先处理(如顶部头图回弹或 AppBar 收起);Child 若自己 fling,也要告知 dispatchNestedFling,便于父做跟随滚动或边缘效果。
4) 常见参与者怎么“分工”
- RecyclerView(Child3) :已经内建完整 child 侧逻辑(pre/post/非触摸),你只实现 Parent 即可。
- AppBarLayout/CoordinatorLayout(Parent2/3) :常在 pre 阶段抢消费 dy 收合 AppBar;在 post 阶段再吃“剩余”。
- NestedScrollView:既可当 Parent 也可当 Child;与 RecyclerView 组合时建议避免两层竖向滚动同向叠加(常见抖动/卡顿来源)。
5) 自定义 Child/Parent 的“最小骨架”
5.1 自定义Child(Kotlin 伪码,建议直接实现Child3)
class MyScrollable @JvmOverloads constructor(...): View, NestedScrollingChild3 {
private val childHelper = NestedScrollingChildHelper(this).apply { isNestedScrollingEnabled = true }
override fun setNestedScrollingEnabled(enabled: Boolean) = childHelper.setNestedScrollingEnabled(enabled)
override fun isNestedScrollingEnabled() = childHelper.isNestedScrollingEnabled
override fun startNestedScroll(axes: Int, type: Int) = childHelper.startNestedScroll(axes, type)
override fun stopNestedScroll(type: Int) = childHelper.stopNestedScroll(type)
override fun hasNestedScrollingParent(type: Int) = childHelper.hasNestedScrollingParent(type)
private val tmpConsumed = IntArray(2)
private val tmpOffset = IntArray(2)
override fun onTouchEvent(e: MotionEvent): Boolean {
when (e.actionMasked) {
ACTION_DOWN -> startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH)
ACTION_MOVE -> {
val dy = lastY - e.y
// 1) 让父先吃
if (dispatchNestedPreScroll(0, dy.toInt(), tmpConsumed, tmpOffset, ViewCompat.TYPE_TOUCH)) {
// 减去父吃掉的
}
// 2) 自己吃,算出 dyConsumed/dyUnconsumed
val (consumedY, unconsumedY) = scrollSelf(dy - tmpConsumed[1])
// 3) 把没吃完的交给父,并接收 v3 父的“补充消费”
val consumedByParents = IntArray(2)
dispatchNestedScroll(0, consumedY, 0, unconsumedY, tmpOffset,
ViewCompat.TYPE_TOUCH, consumedByParents)
// 如需:根据 consumedByParents 再修正自身状态/边缘效果
}
ACTION_UP, ACTION_CANCEL -> stopNestedScroll(ViewCompat.TYPE_TOUCH)
}
return true
}
// Child3 额外重载
override fun dispatchNestedScroll(dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int,
offsetInWindow: IntArray?, type: Int, consumed: IntArray) =
childHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow, type, consumed)
// 其余 Child1/2 方法委托给 helper(省略)
}
5.2 自定义Parent(建议直接实现Parent3)
class MyParentLayout @JvmOverloads constructor(...): ViewGroup, NestedScrollingParent3 {
private val parentHelper = NestedScrollingParentHelper(this)
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean {
return axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 // 只接竖向
}
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) =
parentHelper.onNestedScrollAccepted(child, target, axes, type)
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
// 例如:先收合顶部头图/Toolbar
val y = consumeByHeader(dy)
consumed[1] = y
}
// v3:父在 post 阶段还能再声明“我也吃了一点”
override fun onNestedScroll(target: View,
dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int,
type: Int, consumed: IntArray
) {
val y = consumeBySelf(dyUnconsumed) // 吃一部分“剩菜”
consumed[1] += y // 告诉 child:这部分也被我吃了
}
override fun onStopNestedScroll(target: View, type: Int) =
parentHelper.onStopNestedScroll(target, type)
}
要点:
-
pre 阶段用 consumed[] 抢先吃;post 阶段对“剩余”处理。
-
v3 的 consumed 是“额外吃掉”的出参,不是重报“总消费”;要累加。
6) 设计取舍与常见场景
- AppBarLayout + RecyclerView:AppBar 在 pre 阶段优先消费上滑(收起);下滑时根据是否到顶/到底分配到 child 或 parent。
- 外层 NestedScrollView + 内层 RecyclerView:尽量避免同向都能滚;通常让其中一个“包裹内容、不可滚”,或用 setNestedScrollingEnabled(false) 让滚动只发生在一层。
- 横竖双向:axes 位掩码可同时声明;但一个手势周期内通常只协商其一,避免歧义。
7) 性能与正确性要点
- 按需开始:仅在 DOWN 时 startNestedScroll(axes,type),并保证成对 stopNestedScroll。
- 可复用数组:consumed/offsetInWindow 复用,避免频繁分配。
- 区分 type:手势(TYPE_TOUCH)与 fling(TYPE_NON_TOUCH)分开;不要把动画滚动跑在 TOUCH 链上。
- offsetInWindow:父如果在过程中位移了 child,要用它修正触点坐标(否则手势“跳动”)。
- v3 回填:使用 Child3/Parent3 时务必正确累加 consumed;否则父辈“吃了却没记录”,会出现二次消费/边缘回弹错位。
- Fling 分工:先 dispatchNestedPreFling 问父是否要抢;Child 自己 fling 后还要 dispatchNestedFling 通知父跟随或拦截。
- 冲突排查:打印每帧的 preConsumed/postUnconsumed/parentConsumedV3,很快就能看到“谁在乱吃”。
8) 高频坑
- 只实现 v1 导致 fling 不协作:手指离开后父完全接收不到,表现为“拖动配合正常,fling 失灵”。升级到 v2(含 type)或直接 v3。
- post 阶段误以为父可以改 child 的 dyConsumed:v1/v2 不行;需要 v3 的 consumed[] 回写。
- 两层同向可滚导致抖动:外层 NestedScrollView + 内层 RecyclerView 同时竖向滚,建议禁掉一层或重写 onStartNestedScroll 只让一层接受。
- 忘记 stopNestedScroll:后续事件链被污染,出现“莫名其妙的父还在吃”。
9) 什么时候选 v1 / v2 / v3?
- 新实现:直接上 v3(Child3/Parent3),一次到位。
- 旧项目只做触摸、无复杂父链:v1/v2 也可,但强烈建议至少到 v2(type) ,否则 fling 难协作。
- 使用 NestedScrollingChildHelper / NestedScrollingParentHelper 减少样板代码。
小结
-
v1 → v2 → v3 是“无类型 → 有类型 → 父可回填消费”的演进。
-
嵌套滑动三步曲:pre(父先吃)→ child 吃 → post(父接着吃) ,再加 fling 的 pre/post。
-
实战中,RecyclerView(Child3) + CoordinatorLayout / 自定义 Parent3 基本能覆盖 95% 场景;记住 type 区分 和 v3 的消费回填,你的嵌套滑动就会又顺又稳。