NestedScrolling Parent1,2,3 Child1,2,3之间的关系以及是如何完成嵌套滑动的

71 阅读6分钟

1) 一句话大图

  • 目标:让“内层可滚动的 Child”和“外层可滚动的 Parent”协同消费同一手势/滚动(谁该先吃、吃多少、吃不完谁接着吃)。

  • 核心机制:Child 在滚动周期中,按顺序向父系链路发起:

    1. dispatchNestedPreScroll(父先吃)→
    2. 自己吃 →
    3. dispatchNestedScroll(把没吃完的给父吃)→
    4. 结束时再 dispatchNested(Fling/PreFling) 与 stopNestedScroll。
  • 1/2/3 的演进

    • v1:只有“触摸滚动”(没有类型维度)。
    • v2:加入 type(TYPE_TOUCH / TYPE_NON_TOUCH),把手势滚动非手势滚动(fling、平滑滚动)区分开。
    • v3:加入**“post 消费补充”**通道(consumed 额外输出),父可以在 onNestedScroll 再声明“我也吃了一点” ,便于复杂多级协作。

2) 接口族谱与关系(谁扩展了谁)

实现“越高版本”就自动兼容“更低版本”调用;AndroidX 会用 ViewParentCompat / ViewCompat 做回退。

角色v1v2(新增)v3(新增)关键变化
ChildNestedScrollingChildNestedScrollingChild2NestedScrollingChild3v2 给所有关键 API 增加 type 重载;v3 的 dispatchNestedScroll(...) 新增 int[] consumed 出参
ParentNestedScrollingParentNestedScrollingParent2NestedScrollingParent3v2 给 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) 性能与正确性要点

  1. 按需开始:仅在 DOWN 时 startNestedScroll(axes,type),并保证成对 stopNestedScroll。
  2. 可复用数组:consumed/offsetInWindow 复用,避免频繁分配。
  3. 区分 type:手势(TYPE_TOUCH)与 fling(TYPE_NON_TOUCH)分开;不要把动画滚动跑在 TOUCH 链上。
  4. offsetInWindow:父如果在过程中位移了 child,要用它修正触点坐标(否则手势“跳动”)。
  5. v3 回填:使用 Child3/Parent3 时务必正确累加 consumed;否则父辈“吃了却没记录”,会出现二次消费/边缘回弹错位。
  6. Fling 分工:先 dispatchNestedPreFling 问父是否要抢;Child 自己 fling 后还要 dispatchNestedFling 通知父跟随或拦截。
  7. 冲突排查:打印每帧的 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 的消费回填,你的嵌套滑动就会又顺又稳。