先把结论放在前面:滑动冲突的本质不是"谁先谁后",而是"同一次手势里有多个 View 都认领了同一种手势语义"。 解法只有两条路——要么让某一方主动放弃(拦截或豁免),要么把滚动量的归属权交出去(嵌套滚动)。 至于具体用哪种,取决于滚动容器之间是"父子嵌套"还是"兄弟重叠"。
这题的典型翻车点是:面试官问"怎么解决滑动冲突",答"在 onInterceptTouchEvent 里判断方向后返回 true"。这句话本身没错,但它是一半答案——缺的那一半是"拦截之后发生了什么"以及"什么时候不该拦截"。 只答这一句,追问一层就会露底:拦截后子 View 收到的是什么?为什么不能一 DOWN 就豁免?为什么 NestedScroll 体系下不推荐拦截?
先给冲突分类,方案才不会用错
滑动冲突的解决方案选错,比方案本身写错更常见。按容器关系分三类,每类的正确解法完全不同。
第一类:父子嵌套的同类滚动(ScrollView 里放 RecyclerView、垂直 ViewPager 里放垂直列表)。特点是两个容器都想响应纵向位移。这时正解是嵌套滚动:子容器声明 NestedScrollingChild,在 onStartNestedScroll 阶段询问父容器"接不接",父容器作为 NestedScrollingParent 在 onNestedScroll 里按剩余空间消费一部分,把没消费掉的位移回传给子容器,形成"父容器滚到头、子容器接着滚"的效果。CoordinatorLayout + AppBarLayout + RecyclerView 就是这套机制的样板。
第二类:父子嵌套的异类手势(横滑 ViewPager 套在竖滑 ScrollView 里,横向 RecyclerView 放在纵向列表中)。特点是两者手势语义不同但起点重叠。这时正解是方向锁定的拦截:判定出方向后把整条手势交给某一方,另一方在本次手势内彻底不参与。
第三类:兄弟重叠(两个可滑动的 View 叠在同一个位置,比如地图浮在列表上、下拉刷新和横向 Banner 同屏)。特点是它们不是父子,没有拦截关系,只能靠"谁在视觉上上层、谁先消费"以及"下层是否启用"来避让,必要时用 hitTest 或手势仲裁。
把这三类分清,是这题从"背过"到"会做"的分水岭。 面试里被追问"那如果是两个平级的可滑动控件呢",能立刻答出"没有父子拦截关系,要靠层级与手势仲裁",说明确实处理过真实工程问题。
最常见的坑是
第一层坑:在 ACTION_DOWN 就调用 requestDisallowInterceptTouchEvent(true),把整个手势周期锁死。
后果是父容器在本次手势内彻底失效:用户在横向列表里做了一个略带纵向意图的斜滑,子 View 一按就锁死,父容器再也等不到接管的机会。规范的做法是"延迟豁免":DOWN 只记录基准坐标,MOVE 中位移超过 touchSlop 且方向判定明确属于自己时,才申请豁免并锁定方向,本次手势不再改判。 需要"允许中途改判"的场景(比如纵向列表里嵌横向卡片),则用累计位移 + 累计速度双阈值:位移小但速度快的甩动也算方向意图。
第二层坑:只看位移不看 touchSlop,阈值写成 0 或 1。
系统在 ViewConfiguration 里定义了 scaledTouchSlop(典型 8dp),它就是"用户是否在滑动"与"用户是否在点击"的分界经验值。阈值小于它会导致"点击时手抖一下就变成滑动";阈值远大于它会让"轻微滑动无响应",用户感知为"卡"。任何手写方向判定都应该从 ViewConfiguration.get(context).scaledTouchSlop 取值,而不是写死常量。
第三层坑:嵌套滚动场景继续用拦截方案,边界行为全错。
拦截方案在以下场景会明显失灵:① 多指同时操作,两个容器都在抢;② 到达边界后的回弹(Overscroll)无法传递;③ 父容器需要"先滚到头再让子容器滚"而不是"抢到就算";④ 子容器在 dispatch 中途改变状态导致 CANCEL 时状态错乱。CoordinatorLayout 系列之所以能做成联动,就是它把"谁消费多少"从二值决策变成了连续协商——拦截方案表达不了这个语义。
还有一个更隐蔽的坑:只在真机上验证,不看事件流。 滑动冲突的问题九成出在"到底谁收到了 DOWN、谁收到了 CANCEL、MOVE 断在了第几个"上。手感测试只能告诉你"有点别扭",事件流能告诉你哪一环断了。规范做法是调试期在父子两侧的 onInterceptTouchEvent 与 onTouchEvent 各打一行日志,标明 actionMasked、坐标、父类返回值,一次滑动就能把链路画出来。
代码里见真章
先看一个可复用的方向仲裁基类,它同时处理了延迟豁免、touchSlop、累计位移与 CANCEL 复位:
/**
* 手势方向仲裁:把"谁在本次手势中拥有处理权"的判定集中到一处。
* 关键点:DOWN 只记录基准;超过 touchSlop 或累计速度超阈值才锁定;
* 锁定后调用 disallow 通知父容器;CANCEL 时复位状态。
*/
open class DirectionAwareLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
protected enum class Axis {
NONE, HORIZONTAL, VERTICAL }
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private val minimumFlingVelocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity
private var downX = 0f
private var downY = 0f
private var accX = 0f
private var accY = 0f
private var velocityTracker: VelocityTracker? = null
/** 本次手势锁定的方向;NONE 表示尚未判定 */
protected var lockedAxis: Axis = Axis.NONE
private set
/** 期望的接管方向(例如只关心纵向),子类覆写 */
protected open val interestedAxis: Axis get() = Axis.VERTICAL
private fun resetGesture() {
lockedAxis = Axis.NONE
accX = 0f; accY = 0f
velocityTracker?.recycle()
velocityTracker = null
}
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downX = ev.x; downY = ev.y
accX = 0f; accY = 0f
lockedAxis = Axis.NONE
velocityTracker?.recycle()
velocityTracker = VelocityTracker.obtain().also {
it.addMovement(ev) }
}
MotionEvent.ACTION_MOVE -> {
velocityTracker?.addMovement(ev)
val dx = ev.x - downX
val dy = ev.y - downY
accX += dx; accY += dy
if (lockedAxis == Axis.NONE) {
val overSlop = kotlin.math.abs(dx) > touchSlop || kotlin.math.abs(dy) > touchSlop
val flingFast = velocityTracker?.let {
tr ->
tr.computeCurrentVelocity(1000)
maxOf(tr.xVelocity, tr.yVelocity) > minimumFlingVelocity &&
kotlin.math.abs(accX) > touchSlop
} ?: false
if (overSlop || flingFast) {
val axis = if (kotlin.math.abs(dx) > kotlin.math.abs(dy)) {
Axis.HORIZONTAL
} else {
Axis.VERTICAL
}
// 只关心自己擅长的方向时才接管
if (axis == interestedAxis) {
lockedAxis = axis
// 判定完成此刻才锁父容器,DOWN 阶段绝不调用
parent?.requestDisallowInterceptTouchEvent(true)
} else {
// 明确不是自己的方向,主动放弃整个手势
lockedAxis = Axis.NONE
return false
}
}
}
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> resetGesture()
}
return lockedAxis == interestedAxis
}
override fun onTouchEvent(ev: MotionEvent): Boolean {
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
resetGesture(); return true }
MotionEvent.ACTION_CANCEL -> {
resetGesture(); return true }
}
return super.onTouchEvent(ev)
}
}
这段代码值得盯三处:第一处,lockedAxis 判定为"不是自己的方向"时直接 return false 并保持 NONE,等于把整个手势让出去,避免反复摇摆;第二处,引入 VelocityTracker 与累计位移,让"快速甩动"也能触发方向锁定,而不只是靠位移;第三处,ACTION_CANCEL 与 ACTION_UP 都要 resetGesture(),否则下一次手势会带着上一次的状态,出现"第二次滑动失灵"。
再看嵌套滚动的正确接法,这是父子同类滚动的正解:
// 父容器:NestedScrollingParent —— 消费一部分滚动量并回传剩余
class CollapsingHeaderLayout : FrameLayout(), NestedScrollingParent {
private val headerHeight = 180.dp
private var scrollRange = 0
override fun onMeasure(w: Int, h: Int) {
super.onMeasure(w, h)
// 关键:把 height 补上 header 高度,让内容可以"滚过头"
val lp = MeasureSpec.makeMeasureSpec(measuredHeight + headerHeight, MeasureSpec.EXACTLY)
setMeasuredDimension(measuredWidth, measuredHeight + headerHeight)
scrollRange = maxOf(0, measuredHeight + headerHeight - h)
if (lp != null) scrollRange = scrollRange // 保持引用避免未使用告警
}
override fun onStartNestedScroll(
axes: Int, type: Int, direct: Boolean
): Boolean = axes and View.SCROLL_AXIS_VERTICAL != 0
override fun onNestedPreScroll(target: Int, consumed: Int, type: Int, consumedUncommitted: Int) {
val headerTop = minOf(0, scrollY - consumed) // consumed > 0 表示内容向上滚
val dy = -consumed
scrollBy(0, dy)
if (scrollY != headerTop) {
// 未到顶就自己消费完,子容器不滚动
consumeScroll(0, -(headerTop - scrollY))
}
}
override fun onNestedScroll(target: Int, consumed: Int, dxUnconsumed: Int, dyUnconsumed: Int,
type: Int, consumed: Int, consumedUnconsumed: Int) {
// 父容器滚到头之后,把剩余量交还给子容器继续滚
if (dyUnconsumed != 0) dispatchNestedScroll(0, dyUnconsumed, null, null)
}
}
// 子容器:NestedScrollingChild —— 声明能力并把未消费量上报
class NestedListView : RecyclerView(context, attrs) {
init {
isNestedScrollingEnabled = true // 开启后父容器才会收到回调
// 关键二:自身边界仍由自己的 LayoutManager 管,父容器只做"先滚"
}
override fun startNestedScroll(axes: Int, type: Int): Boolean =
super.startNestedScroll(axes, type) && isNestedScrollingEnabled
override fun dispatchNestedPreScroll(dx: Int, dy: Int, consumed: Int, type: Int) {
// 先让父容器有机会消费(onNestedPreScroll),再自己滚
super.dispatchNestedPreScroll(dx, dy, consumed, type)
}
}
这两段值得盯三处:第一处,父容器 onMeasure 里把高度补上 header,否则内容滚不出需要的那段距离,嵌套滚动就无从消费;第二处,isNestedScrollingEnabled = true 必须在子容器上打开,这是最常见的"接了接口却没反应"原因;第三处,父容器滚到头之后用 dispatchNestedScroll 把剩余量还回子容器,这正是"先滚父后滚子"的语义所在,也是拦截方案做不到的部分。
面试中的经典考点
问:拦截方案和嵌套滚动方案分别适合什么场景?为什么不统一用一种?
答:拦截适合手势语义不同的异类冲突(横 vs 竖),它的决策是二值的"这次归谁",实现简单、行为可预测;嵌套滚动适合同类滚动(都是纵向/都是横向),它的决策是连续的"父消费多少、剩下多少给子",能表达"滚到头再接力"。拦截表达不出接力语义,嵌套滚动表达不出"只关心横向"的强约束,所以二者互补而非替代。
问:NestedScrolling 的回调顺序是怎样的?
答:子容器 startNestedScroll → 父容器 onStartNestedScroll 返回是否接收 → 滚动中子容器先 dispatchNestedPreScroll → 父容器 onNestedPreScroll(处理 header 吸附这类"先于内容"的事)→ 子容器自己滚 → 未消费部分 dispatchNestedScroll → 父容器 onNestedScroll → 松手时 dispatchNestedPreFling / dispatchNestedFling,父容器可决定是否先吸附。"Pre"和"非 Pre"的区别是这题的高分点:Pre 是"子容器还没滚,父容器先处理",非 Pre 是"子容器已处理完,剩余量归父容器"。
问:onNestedPreScroll 里 consumed 参数怎么用对?
答:consumed[1] 是父容器本次消费的垂直距离,子容器必须在 super.dispatchNestedPreScroll 之前就带上它,这样 onNestedPreScroll 之后自己的滚动起点才是正确的剩余量。常见错误是"先自己滚再上报",导致父容器消费了已经滚过的距离,表现为一快速滑就多滚一段或直接跳到边界。
问:为什么很多项目里嵌套滑动"联不动"?
答:三类原因按出现频率排:① 子容器没开 isNestedScrollingEnabled(接口实现了但没启用);② 子容器用的是 ScrollView/NestedScrollView 之外的容器(如 HorizontalScrollView 套 RecyclerView 这类非轴向匹配);③ 父容器在 onStartNestedScroll 返回 false 但没有 onNestedPreScroll 的消费逻辑,看起来"生效了"其实只是子容器自己在滚。定位方法:先在父子两侧各打一行 onStartNestedScroll 返回值,看握手是否发生。
问:横向 RecyclerView 放进纵向 RecyclerView,怎么避免"横向卡片抢走纵向列表的手势"?
答:三条一起用——① 横向子 View 用 DirectionAwareLayout 的方向判定,超过 touchSlop 且判定为横向才 requestDisallowInterceptTouchEvent(true),纵向则完全不申请;② 横向容器实现 NestedScrollingChild 并只在 SCROLL_AXIS_HORIZONTAL 时 startNestedScroll,让父容器知道"仅在横向申请资源";③ 用累计速度而非瞬时位移做辅助判定,斜向慢滑时偏向让父容器处理。只写"在 ACTION_DOWN 里 disallow"是最常见的错误答案,它在斜滑场景必现。
落到项目里怎么做
一条能写进规范的红线:手势归属判定必须收敛到单一基类,禁止业务布局各自实现方向判定。 原因很直接——分散实现势必出现某处漏发 CANCEL、某处阈值不一致、某处忘记处理多指,排查成本远高于一次抽象。
配套实践三条:① 冲突类问题一律先画事件流日志(父子两侧的 actionMasked + 父类返回值 + 坐标),再动手改;② 凡是"先滚父后滚子"的需求优先用官方嵌套滚动契约,不要自己 overScrollBy 转发位移;③ 把 touchSlop、最小速度阈值、方向角阈值抽成可配置项,让产品在"更跟手"与"更少误触"之间有调节余地。
给正在准备面试的你
这题的答法要落到"冲突分类 → 方案选型 → 细节落地"这条主线。推荐这条线:先讲清三类冲突(父子同类、父子异类、兄弟重叠)→ 说明异类用方向判定 + 延迟豁免,同类用嵌套滚动 → 讲三个细节:豁免时机在判定之后、阈值取 touchSlop 而非硬编码、拦截后 CANCEL 必须送达 → 补 onNestedPreScroll 的 consumed 传参顺序。能被追问到"为什么不用拦截做父子同类滚动"并答出"表达不了接力语义",说明真做过联动;能讲清 consumed 传参导致的跳变,说明踩过快速滑动异常的坑。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:事件分发机制:从-Activity-到-View-的完整链路
下一篇预告:View-的滑动实现:scrollTo、Scroller-与属性动画
有任何问题欢迎在评论区留言交流。