先把结论放在前面:CoordinatorLayout 与 NestedScrollView 是一套协议的两端——CoordinatorLayout 实现 NestedScrollingParent,NestedScrollView 实现 NestedScrollingChild,两者通过"滚动量先问父级、父级消费一部分、把剩余量回传子级"的协商机制实现联动。 会用 AppBarLayout + CollapsingToolbarLayout 只是会用;能讲清这套"协商式"协议与传统的"拦截式"事件分发有何不同,才是这题要答的。
这题的区分度在于机制层:面试官问"为什么 AppBarLayout 能折叠",答"因为它监听了滚动事件"是不及格的;答"因为子容器把 dy 交给父级先消费,父级折叠完成后把未消费的部分还给子级继续滚",才是机制层的答案。
协商式滚动 vs 拦截式事件分发
这是本篇最核心的一组对比:
| 维度 | 传统事件分发(onInterceptTouchEvent) |
嵌套滚动(NestedScrolling) |
|---|---|---|
| 协作方式 | 父容器抢占事件,可中断子容器 | 父容器受邀消费,消费多少由父决定 |
| 控制粒度 | 整个手势 | 按像素增量(dx/dy)协商 |
| 顺序 | 父先判定 | 子先发起,父先问,子后滚 |
| 状态传递 | 通过 MotionEvent 携带 | 通过 NestedScrollingParent 回调返回消费值 |
| 典型代表 | ViewPager 与列表嵌套 |
CoordinatorLayout + AppBarLayout |
// Child 侧:发起嵌套滚动的标准写法
class MyScrollView(context: Context, attrs: AttributeSet?) : NestedScrollView(context, attrs) {
override fun startNestedScroll(axes: Int): Boolean =
startNestedScroll(axes, View.SCROLL_AXIS_VERTICAL) // 第二个参数是类型
override fun onNestedPreScroll(targetDx: Int, targetDy: Int, consumed: IntArray, type: Int) {
// 先问父级"你打算消费多少",consumed 被父级回填
super.onNestedPreScroll(targetDx, targetDy, consumed, type)
}
override fun onNestedScroll(targetDx: Int, targetDy: Int, consumedX: Int, consumedY: Int, type: Int) {
// 父级滚完自己之后,把剩余量交回继续滚
super.onNestedScroll(targetDx, targetDy, consumedX, consumedY, type)
}
}
四个回调的时机是这题的必答点:
| 回调 | 时机 | 用途 |
|---|---|---|
onNestedPreScroll |
每次滚动之前 | 让父级先消费(如 AppBar 折叠) |
onNestedScroll |
父级消费之后 | 让子级处理剩余量 |
onNestedPreFling / onNestedFling |
惯性阶段 | 决定"甩"的动作先给谁 |
onStopNestedScroll |
手势结束 | 收尾,Behavior 要在这里复位状态 |
Behavior:把联动策略从布局里剥出来
CoordinatorLayout 的核心扩展点是 Behavior:它让任意子 View 都能参与嵌套滚动的协商,而不依赖 AppBarLayout 那套固定结构。
// 自定义 Behavior:让一个 View 在内容滚到顶部时"顶上来"
class HeaderBehavior(
private val header: View,
private val collapseHeight: Int
) : ViewBehavior<View>(header) {
// 声明参与哪种轴的嵌套滚动,决定能收到哪些回调
override fun onStartNestedScroll(
parent: CoordinatorLayout, child: View, directTargetChild: View,
axes: Int, type: Int
): Boolean = axes == View.SCROLL_AXIS_VERTICAL
override fun onNestedPreScroll(
parent: CoordinatorLayout, child: View, target: View,
dx: Int, dy: Int, consumed: IntArray, type: Int
) {
val offsetTop = child.translationY
// dy > 0 表示内容上滚(顶部收缩),dy < 0 表示下拉展开
val newOffset = when {
dy > 0 && offsetTop > -collapseHeight -> (offsetTop - dy).coerceAtLeast(-collapseHeight)
dy < 0 && offsetTop < 0f -> (offsetTop - dy).coerceAtMost(0f)
else -> offsetTop
}
val consumedDy = (offsetTop - newOffset).toInt()
if (consumedDy != 0) {
child.translationY = newOffset
consumed[1] += consumedDy // 明确告知父级已消费量
}
}
// 手势结束时复位或启动 fling,这一步不做就会留下"卡住一半"的状态
override fun onStopNestedScroll(parent: CoordinatorLayout, child: View, target: View, type: Int) {
val offsetTop = child.translationY
if (offsetTop < 0 && offsetTop > -collapseHeight) {
ViewCompat.animate(child)
.translationY(if (Math.abs(offsetTop) < collapseHeight / 2) -collapseHeight.toFloat() else 0f)
.setDuration(200).start()
}
}
}
这段代码里有三个必答点:① onStartNestedScroll 里判断 axes,决定这个 Behavior 会不会收到回调(不判断就是"写了 Behavior 却没效果"的头号原因);② 消费量必须回填到 consumed[1],否则父级不知道已被消费,后续联动的链会断;③ onStopNestedScroll 必须做状态收尾(吸附到某个位置),否则快速滑动会停在半空中。
SlidingUpPanelLayout 与 DrawerLayout 的协议角色
SlidingUpPanelLayout自身是NestedScrollingParent,它让"半展开的面板"能与内部列表协作滚动,这就是第175篇 BottomSheet 半展态滚动冲突的解法线索。DrawerLayout同样是NestedScrollingParent,它通过onNestedPreScroll判断横向拖拽意图并控制抽屉开合。AppBarLayout是ScrollingViewBehavior的宿主:它把自己的可折叠高度作为"消费量"上报,内容滚到顶后就把剩余 dy 交回 NestedScrollView。
这条链的完整顺序是:内容上滚 → onNestedPreScroll → AppBarLayout 折叠(消费 dy)→ 折叠完成后 onNestedScroll 回传剩余量 → NestedScrollView 滚动内容。能按这个顺序口述完,机制层就过了。
最常见的坑是
第一层坑:自定义 Behavior 未处理 onStopNestedScroll,联动状态残留。
表现是快速滑动后面板停在半展态偏上或偏下的位置,既不吸附也不回弹。根因是手势结束后没有按位移阈值做吸附决策。规范是在 onStopNestedScroll(或 onNestedFling)里按阈值吸附到最近的档位。
第二层坑:RecyclerView 直接嵌 RecyclerView,不设嵌套滚动标志,滚动冲突。
// 明确关闭内层的纵向嵌套,避免与外层抢
innerRv.isNestedScrollingEnabled = false
// 或者用同一轴向时,让内层不参与嵌套
根因是内外两层都能消费同一方向的滚动,手势归属不确定。规范是同轴向嵌套必须显式关闭内层嵌套滚动,异轴向(横内纵外)则用 isNestedScrollingEnabled=false 保持横内滚动由内层自己处理。
第三层坑:在 onNestedPreScroll 里改 translationY 却忘回填 consumed,导致父链断裂。
表现是 AppBar 折叠正常,但页面里另一个 Behavior 不动,或者 ViewPager 与 AppBar 联动时 AppBar 只动一半。根因是消费量没上报,父级按"未被消费"处理。规范是改了什么就报什么,consumed[1] += consumedDy 不能省。
这题在面试里怎么问、怎么答
问:NestedScrolling 的机制是什么?和传统事件分发有什么区别?
答:嵌套滚动是"协商式",子容器发起滚动前用 startNestedScroll 找父级,父级返回是否接受;每次滚动先调 onNestedPreScroll 让父级消费一部分,父级消费后调 onNestedScroll 把剩余量交回子级。传统事件分发是"拦截式",父容器在 onInterceptTouchEvent 里直接决定要不要拿走整条手势流。协商式的优势是父与子可以按像素分工(AppBar 折叠 200px、剩余 600px 内容滚动),而拦截式只能整段切走。
问:AppBarLayout 为什么会随内容滚动而折叠?
答:它实现了 NestedScrollingParent(通过内部 Behavior 参与协商)。内容容器上滚时先调 onNestedPreScroll,AppBarLayout 拿自己剩余的折叠高度消费 dy,并把消费后剩余的 dy 通过 onNestedScroll 回传给内容容器继续滚动。AppBar 折叠完(消费了完整高度)后消费量变成 0,NestedScrollView 就开始滚内容。
问:自定义 Behavior 要注意什么?
答:四点。① onStartNestedScroll 里必须判断 axes,否则收不到回调。② 消费量必须回填到 consumed,否则父链断裂。③ onStopNestedScroll 要做状态收尾与吸附。④ 不要在 Behavior 里做重计算(如复杂测量或 IO),因为它在滚动路径上执行。
问: fling(惯性滚动)时嵌套滚动怎么协作?
答:走 onNestedPreFling 与 onNestedFling 两个回调。onNestedPreFling 让父级先决定是否消费这次甩动(AppBar 通常会),onNestedFling 则是子级已经处理完、剩下的部分交给父级。要能提到"fling 阶段的状态判断阈值和 drag 不同,所以吸附逻辑要放在 fling 回调里而不是 drag 里"。
问:为什么 CoordinatorLayout 里 AppBar 下面用 NestedScrollView,用 ScrollView 就不好?
答:两者都能滑动,但只有 NestedScrollView 实现了 NestedScrollingChild,会向父级发起嵌套滚动协商。普通 ScrollView 不参与协商,AppBarLayout 收不到 onNestedPreScroll,因此不会折叠。这个回答能直接说明"不是样式问题,是协议实现问题"。
落到项目里怎么做
一条能写进规范的红线:所有自定义 Behavior 必须实现 onStartNestedScroll 的轴判断、必须回填 consumed、必须在 onStopNestedScroll 做吸附收尾,三项缺一不可,并在评审时逐条对照。 这三项正是本篇三类坑的对应面。
配套实践三条:① 把常用 Behavior(顶部渐变栏、吸底按钮栏、折叠搜索框)抽成组件库统一维护,避免每个页面重复实现与各自出错;② 在慢速滑动与快速 fling 两种手势下都做一轮验证,因为吸附逻辑主要在 fling 阶段生效;③ 用 NestedScrollView + AppBarLayout 与自定义 Behavior 做一次性能对比,确认 Behavior 里没有引入滚动路径上的额外计算。
给正在准备面试的你
这题的答法要落到"嵌套滚动是协商式协议,与拦截式事件分发的区别在于按像素分工"这条主线。推荐这条线:先给两组机制的对比 → 讲四个回调的时机 → 讲 Behavior 的三个必答点(轴判断、consumed 回填、stop 收尾)→ 讲 AppBar + NestedScrollView 的完整滚动顺序 → 补三个坑 → 收在"Behavior 三项规范"。能被追问"AppBar 为什么会折叠"并答出"onNestedPreScroll 消费 + onNestedScroll 回传剩余量"的完整顺序,说明理解到机制层;能主动区分 fling 与 drag 的状态处理差异,说明做过真实联动。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Fragment-与-ViewModel-作用域:谁该持有谁
下一篇预告:BottomSheet-与抽屉:容器类交互组件
有任何问题欢迎在评论区留言交流。