嵌套滚动(Nested Scrolling 1/2/3)

一、思维模型(先有脑图)

一句话:先问父能不能抢(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)

  1. DOWN:Child 调 startNestedScroll(VERTICAL, TYPE_TOUCH)

  2. MOVE 前:Child 调 dispatchNestedPreScroll(dx, dy, consumed, offset, TYPE_TOUCH)

    • Parent 在 onNestedPreScroll 优先吃(比如先折叠 AppBar),写回 consumed。
  3. Child 自己滚:用 dy - consumedY 的剩余量去 scrollBy()。算出 childConsumedY 与 childUnconsumedY(到边了还剩多少)。

  4. MOVE 后:Child 调 dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offset, TYPE_TOUCH[, consumedByParent])

    • Parent2:只能"事后处理"未消耗部分(如继续折叠 AppBar)。
    • Parent3 :还可通过 consumedByParent[ ] 说"我又吃了一点",Child 感知到这点回写修正
  5. UP/CANCEL:Child 调 stopNestedScroll(TYPE_TOUCH)。

B. 惯性滚动(TYPE_NON_TOUCH,fling 动画驱动)

  1. Child 先 dispatchNestedPreFling(vx, vy) 询问父是否"优先甩"(AppBar 先把势能吃完)。

  2. 若父未完全吃掉,Child 自己开 fling,同时:

    • startNestedScroll(VERTICAL, TYPE_NON_TOUCH)
    • 在 computeScroll() 循环里,每一帧 照着触摸滚动的 pre → child → post 时序,改用 TYPE_NON_TOUCH 调度(这一步很多自定义 Child 容易忘)。
    • 结束后 stopNestedScroll(TYPE_NON_TOUCH)。
  3. 子也应调用 dispatchNestedFling(vx, vy, consumed=true/false) 通知父"我是否已经启动了 fling"。


四、典型容器的角色

  • RecyclerView已实现 Child(3) ,天然会配合父容器(含触摸与 fling)。
  • NestedScrollView :既能当 Parent (包裹复杂子滚动)也能当 Child(被更外层协调)。
  • AppBarLayout + CoordinatorLayoutParent + 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)最快落地。
相关推荐
疯狂踩坑人3 小时前
【面试系列】浏览器篇
前端·面试
uhakadotcom3 小时前
常识:python之中的伪随机数安全风险
后端·面试·github
聪明的笨猪猪4 小时前
Java Redis “核心应用” 面试清单(含超通俗生活案例与深度理解)
java·经验分享·笔记·面试
晨非辰5 小时前
《超越单链表的局限:双链表“哨兵位”设计模式,如何让边界处理代码既优雅又健壮?》
c语言·开发语言·数据结构·c++·算法·面试
聪明的笨猪猪5 小时前
Java Redis “底层结构” 面试清单(含超通俗生活案例与深度理解)
java·经验分享·笔记·面试
绝无仅有5 小时前
面试真实经历某商银行大厂Java问题和答案总结(三)
后端·面试·github
绝无仅有5 小时前
面试真实经历某商银行大厂Java问题和答案总结(五)
后端·面试·github
wxweven13 小时前
校招面试官揭秘:我们到底在寻找什么样的技术人才?
java·面试·校招
聪明的笨猪猪15 小时前
Java Redis “缓存设计”面试清单(含超通俗生活案例与深度理解)
java·经验分享·笔记·面试