一、思维模型(先有脑图)
一句话:先问父能不能抢(preXxx)→ 自己处理剩余 → 事后告诉父我消费了多少 & 还剩多少(dispatchNestedScroll)→ 父做"事后消费"(折叠 AppBar / 回弹等)→ 若是 fling,还要走 preFling/fling。
-
pre 阶段(父优先) :dispatchNestedPreScroll / onNestedPreScroll
-
子阶段(自己滚) :Child 自己 scrollBy / layout 偏移
-
post 阶段(父事后) :dispatchNestedScroll / onNestedScroll
-
fling 阶段:dispatchNestedPreFling → 子 fling → dispatchNestedFling
触摸滚动 = TYPE_TOUCH;惯性滚动(fling 动画驱动)= TYPE_NON_TOUCH。
二、接口族对照(1/2/3 有何不同?)
代际 | Child 接口 | Parent 接口 | 核心升级点 |
---|---|---|---|
v1 | NestedScrollingChild | NestedScrollingParent | 只支持"未区分类型"的滚动,API 少,无法良好区分触摸与非触摸滚动 |
v2 | NestedScrollingChild2 | NestedScrollingParent2 | 加入 type (TYPE_TOUCH/TYPE_NON_TOUCH),所有 start/stop/pre/post 方法带 type,可正确处理 fling |
v3 | NestedScrollingChild3 | NestedScrollingParent3 | 在 post 阶段新增可写的 "二次消耗回传" :Parent 能在 onNestedScroll(..., consumed[]) 中声明"我又额外吃掉了多少",Child 通过 dispatchNestedScroll(..., consumedByParent) 感知并修正自身状态(更精细协作) |
常量:ViewCompat.SCROLL_AXIS_HORIZONTAL / SCROLL_AXIS_VERTICAL;
类型:ViewCompat.TYPE_TOUCH = 0,ViewCompat.TYPE_NON_TOUCH = 1。
三、完整时序(以竖向滚为例)
A. 触摸滚动(TYPE_TOUCH,手指 MOVE)
-
DOWN:Child 调 startNestedScroll(VERTICAL, TYPE_TOUCH)
-
MOVE 前:Child 调 dispatchNestedPreScroll(dx, dy, consumed, offset, TYPE_TOUCH)
- Parent 在 onNestedPreScroll 优先吃(比如先折叠 AppBar),写回 consumed。
-
Child 自己滚:用 dy - consumedY 的剩余量去 scrollBy()。算出 childConsumedY 与 childUnconsumedY(到边了还剩多少)。
-
MOVE 后:Child 调 dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offset, TYPE_TOUCH[, consumedByParent])
- Parent2:只能"事后处理"未消耗部分(如继续折叠 AppBar)。
- Parent3 :还可通过 consumedByParent[ ] 说"我又吃了一点",Child 感知到这点回写修正。
-
UP/CANCEL:Child 调 stopNestedScroll(TYPE_TOUCH)。
B. 惯性滚动(TYPE_NON_TOUCH,fling 动画驱动)
-
Child 先 dispatchNestedPreFling(vx, vy) 询问父是否"优先甩"(AppBar 先把势能吃完)。
-
若父未完全吃掉,Child 自己开 fling,同时:
- startNestedScroll(VERTICAL, TYPE_NON_TOUCH)
- 在 computeScroll() 循环里,每一帧 照着触摸滚动的 pre → child → post 时序,改用 TYPE_NON_TOUCH 调度(这一步很多自定义 Child 容易忘)。
- 结束后 stopNestedScroll(TYPE_NON_TOUCH)。
-
子也应调用 dispatchNestedFling(vx, vy, consumed=true/false) 通知父"我是否已经启动了 fling"。
四、典型容器的角色
- RecyclerView :已实现 Child(3) ,天然会配合父容器(含触摸与 fling)。
- NestedScrollView :既能当 Parent (包裹复杂子滚动)也能当 Child(被更外层协调)。
- AppBarLayout + CoordinatorLayout :Parent + Behavior 组合,通过 Behavior 接口实现 pre/post/ fling,常用于"折叠工具栏"。
五、Child 侧最小模板(NestedScrollingChild3,Kotlin)
适用于你写自定义可滚容器(非 RecyclerView)。关键点 :两段 start/stopNestedScroll、两处 dispatchNestedPreScroll/dispatchNestedScroll、以及 fling 动画帧里也要分发。
kotlin
class MyScrollChildView @JvmOverloads constructor(
ctx: Context, attrs: AttributeSet? = null
) : ViewGroup(ctx, attrs), NestedScrollingChild3 {
private val childHelper = NestedScrollingChildHelper(this).apply {
isNestedScrollingEnabled = true
}
private val offset = IntArray(2)
private val preConsumed = IntArray(2)
private val postExtraConsumed = IntArray(2) // v3 回传
// 省略 measure/layout...
// ---- NestedScrollingChild 代理 ----
override fun setNestedScrollingEnabled(enabled: Boolean) = childHelper.isNestedScrollingEnabled.let {
childHelper.isNestedScrollingEnabled = 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)
// v3:带额外 consumed 的分发
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
)
}
// 触摸滚动
private var lastY = 0f
override fun onTouchEvent(e: MotionEvent): Boolean {
when (e.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastY = e.y
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH)
}
MotionEvent.ACTION_MOVE -> {
val dy = (lastY - e.y).toInt()
lastY = e.y
// 1) pre:让父先吃
if (dispatchNestedPreScroll(0, dy, preConsumed, offset, ViewCompat.TYPE_TOUCH)) {
// dy 被部分吃掉
}
var remain = dy - preConsumed[1]
// 2) child:自己滚(示意:contentOffsetY += remainWithinRange(remain))
val consumedByChild = scrollSelfVertically(remain) // 返回实际吃掉
val unconsumedByChild = remain - consumedByChild
// 3) post:告诉父亲我吃了/还剩多少
postExtraConsumed[0] = 0; postExtraConsumed[1] = 0
dispatchNestedScroll(
0, consumedByChild,
0, unconsumedByChild,
offset, ViewCompat.TYPE_TOUCH, postExtraConsumed
)
// 若父在 v3 又吃掉一部分(负号表示父向相反方向修正),你可按需修正自身状态
if (postExtraConsumed[1] != 0) {
adjustByParentExtra(postExtraConsumed[1])
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
stopNestedScroll(ViewCompat.TYPE_TOUCH)
// 计算速度 → 走 fling 流程(见下)
}
}
return true
}
// ---- fling 动画(TYPE_NON_TOUCH)----
private val scroller = OverScroller(context)
private var lastAnimY = 0
fun fling(velocityY: Int) {
// 先问 preFling
if (dispatchNestedPreFling(0f, velocityY.toFloat())) return
scroller.fling(0, scrollY, 0, velocityY, 0, 0, minY(), maxY())
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH)
ViewCompat.postInvalidateOnAnimation(this)
// 告知父我已启动子 fling
dispatchNestedFling(0f, velocityY.toFloat(), true)
}
override fun computeScroll() {
if (scroller.computeScrollOffset()) {
val currY = scroller.currY
val dy = currY - lastAnimY
lastAnimY = currY
// 与触摸 MOVE 同步的 pre → child → post 三段,但 type=NON_TOUCH
preConsumed[1] = 0
dispatchNestedPreScroll(0, dy, preConsumed, null, ViewCompat.TYPE_NON_TOUCH)
val remain = dy - preConsumed[1]
val consumedByChild = scrollSelfVertically(remain)
val unconsumedByChild = remain - consumedByChild
postExtraConsumed[1] = 0
dispatchNestedScroll(
0, consumedByChild, 0, unconsumedByChild,
null, ViewCompat.TYPE_NON_TOUCH, postExtraConsumed
)
ViewCompat.postInvalidateOnAnimation(this)
} else {
stopNestedScroll(ViewCompat.TYPE_NON_TOUCH)
lastAnimY = 0
}
}
// ---- 工具方法(自行实现)----
private fun scrollSelfVertically(dy: Int): Int { /* clamp 后返回实际消耗 */ return 0 }
private fun adjustByParentExtra(extra: Int) { /* v3 回传修正 */ }
private fun minY() = 0
private fun maxY() = 1000
}
六、Parent 侧最小模板(NestedScrollingParent3,Kotlin)
典型场景:顶部有可折叠 Header(如 AppBar),下面是 Child(如 RecyclerView)。pre 先吃掉 dy 折叠 Header,post 再处理 Child 到边后的"未消耗"。
kotlin
class CollapsingParentLayout @JvmOverloads constructor(
ctx: Context, attrs: AttributeSet? = null
) : ViewGroup(ctx, attrs), NestedScrollingParent3 {
private val parentHelper = NestedScrollingParentHelper(this)
private var headerHeight = 0
private var headerOffset = 0 // [0, headerHeight],表示已折叠量
// measure/layout 省略,header 在上、content 在下
// ---- 必备 Overrides ----
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 onStopNestedScroll(target: View, type: Int) {
parentHelper.onStopNestedScroll(target, type)
// 可在此根据 headerOffset 吸顶/回弹
}
// 1) pre:父优先消耗(例如:向上滑时先折叠 Header)
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
val goingUp = dy > 0
val goingDown = dy < 0
// 向上:尽量折叠(未满高度前都吃)
if (goingUp && headerOffset < headerHeight) {
val eat = minOf(dy, headerHeight - headerOffset)
headerOffset += eat
offsetChildrenTopAndBottom(-eat) // 整体向上挪
consumed[1] = eat
}
// 向下:若子内容顶部已到边,先展开 Header 一部分
if (goingDown && !target.canScrollVertically(-1) && headerOffset > 0) {
val eat = minOf(-dy, headerOffset)
headerOffset -= eat
offsetChildrenTopAndBottom(eat)
consumed[1] += eat
}
}
// 2) post:事后消耗(Child 已经滚完/到边后)
// v3 版:最后一个 consumed[] 允许"再吃一口",Child 会感知
override fun onNestedScroll(
target: View,
dxConsumed: Int, dyConsumed: Int,
dxUnconsumed: Int, dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
// 例:子竖向向上滚到顶仍有剩余,则继续折叠 Header
if (dyUnconsumed > 0 && headerOffset < headerHeight) {
val eat = minOf(dyUnconsumed, headerHeight - headerOffset)
headerOffset += eat
offsetChildrenTopAndBottom(-eat)
consumed[1] += eat // 告诉 Child:这部分也被我吃啦
}
// 向下未消耗:展开 Header
if (dyUnconsumed < 0 && headerOffset > 0) {
val eat = minOf(-dyUnconsumed, headerOffset)
headerOffset -= eat
offsetChildrenTopAndBottom(eat)
consumed[1] += eat
}
}
// 3) fling:父子协调(优先级:preFling → 子/父惯性)
override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
// 若 Header 还能折叠/展开,父可截获一部分惯性
val canConsume = (velocityY > 0 && headerOffset < headerHeight) ||
(velocityY < 0 && headerOffset > 0)
return canConsume.also {
if (it) flingHeader(velocityY) // 自己开一段惯性
}
}
override fun onNestedFling(
target: View, velocityX: Float, velocityY: Float, consumed: Boolean
): Boolean {
// 子已经消耗了 fling(consumed=true)时,父可选择是否继续联动
if (!consumed) flingHeader(velocityY)
return true
}
private fun flingHeader(velocityY: Float) { /* 启动父的 OverScroller 撑完势能 */ }
}
注:真实项目通常直接用 AppBarLayout + CoordinatorLayout,上面的模式就是它们内部 Behavior 的工作方式简化版。
七、常见组合的取舍与"坑位"
-
RecyclerView inside NestedScrollView?
一般不推荐;若业务必须:
- 让内层 RecyclerView 保持 isNestedScrollingEnabled=true(默认如此,利用协议协同);
- 或反向:关闭内层嵌套滚动(isNestedScrollingEnabled=false),交给外层 NestedScrollView 统一滚。二选一,避免"双滚动源"争抢。
-
别忘了 TYPE_NON_TOUCH :很多自定义 Child 只处理触摸,fling 时没分发 pre/post,导致 AppBar 不联动。
-
offsetInWindow:父移动了你(或自身)时,这个数组能让 Child 感知"窗口系"位移,进而校准手势坐标/松手点。
-
v3 的"二次消耗" :如果你需要 Parent 事后再吞一点并要求 Child 知道(精细、顺滑),用 Parent3 + Child3。否则 Parent2 足够。
-
start/stop 成对:DOWN → start(TYPE_TOUCH);UP/CANCEL → stop(TYPE_TOUCH);fling 开始/结束配对 TYPE_NON_TOUCH。
-
axes 要匹配:只垂直就别报水平;否则 onStartNestedScroll 可能直接拒绝协作。
八、快速定位与调试技巧
- 打印:Parent/Child 各处回调的 type、dy、consumed[]、unconsumed;fling 时注意一帧一帧的分发。
- 观察:AppBar 折叠是否只在 pre 吃?到边后 post 吃?fling 是否连贯?
- 工具:ViewCompat.postInvalidateOnAnimation() 保证动画 vsync 刷新;ViewConfiguration 设门槛,避免抖动。
九、你该怎么选
- 只要基础联动(折叠工具栏 / 列表到顶再下拉露出头部):Parent2 + Child2/3 足矣。
- 需要"父事后再吃并反馈给子"的极致顺滑:上 Parent3 + Child3。
- 业务无特殊联动:靠现成的 RecyclerView(Child) + AppBarLayout/CoordinatorLayout(Parent+Behavior)最快落地。