嵌套滑动冲突的解法可以归为两类:外部拦截(父 View 在
onInterceptTouchEvent里决定要不要抢事件)和 内部拦截(子 View 在onTouchEvent里调用requestDisallowInterceptTouchEvent决定是否上交事件)。
一、四种方案速查
| 场景 | 外层方向 | 内层方向 | 外部拦截 | 内部拦截 |
|---|---|---|---|---|
| 方向不一致 | 横向 | 竖向 | 父容器判断主导方向为横向时拦截 | 子 View 判断为竖向时请求父容器不要拦截 |
| 方向一致 | 竖向 | 竖向 | 父容器判断子 View 到边界后拦截 | 子 View 判断自己还能滚动时阻止父容器拦截 |
二、方向不一致:外层横向 + 内层竖向
典型结构:ViewPager 每一页里面是 ListView / RecyclerView。
2.1 外部拦截:父 View 判断主导方向
自定义 ViewPager,在 onInterceptTouchEvent 中比较 dx 和 dy,只有横向位移占优时才拦截。
public class HorizontalViewPager extends ViewPager {
private float mStartX;
private float mStartY;
public HorizontalViewPager(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mStartX = ev.getX();
mStartY = ev.getY();
// DOWN 阶段不能拦截,否则子 View 接收不到后续事件
break;
case MotionEvent.ACTION_MOVE:
float dx = Math.abs(ev.getX() - mStartX);
float dy = Math.abs(ev.getY() - mStartY);
// 水平位移大于垂直位移,判定为翻页,外层拦截
if (dx > dy) {
intercept = true;
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mStartX = 0;
mStartY = 0;
break;
}
// 自己判定横向 || 父类原本就想拦截
return intercept || super.onInterceptTouchEvent(ev);
}
}
使用方式
<com.example.HorizontalViewPager
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.example.HorizontalViewPager>
关键点
ACTION_DOWN永远返回false,否则子View的点击/长按都会失效。- 用起点到当前点的累积位移判断,而不是相邻两点的差值,斜滑时更稳。
- 外层一旦拦截,
MOVE/UP会直接进入外层onTouchEvent。
2.2 内部拦截:子 View 判断主导方向
不改造 ViewPager,而是改造内部的 ListView。子 View 在 dispatchTouchEvent 里先拿到事件,判断为竖向滑动时请求父容器不要拦截。
public class VerticalListView extends ListView {
private float mStartX;
private float mStartY;
public VerticalListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mStartX = ev.getX();
mStartY = ev.getY();
// 按下时先阻止父 View 拦截
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
float dx = Math.abs(ev.getX() - mStartX);
float dy = Math.abs(ev.getY() - mStartY);
// 如果判定为横向滑动,就把事件交给外层 ViewPager
if (dx > dy) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
getParent().requestDisallowInterceptTouchEvent(false);
break;
}
return super.dispatchTouchEvent(ev);
}
}
关键点
- 子 View 必须在
dispatchTouchEvent里处理,因为onTouchEvent是在父 View 未拦截的前提下才会收到。 ACTION_DOWN时调用requestDisallowInterceptTouchEvent(true)可以阻止父 View 在后续MOVE中拦截。- 当检测到横向滑动时把控制权交还父 View。
三、方向一致:外层竖向 + 内层竖向
典型结构:ScrollView 包裹 ListView。
3.1 外部拦截:父 ScrollView 判断子 View 边界
父容器在 onInterceptTouchEvent 里判断:如果子 View 还能朝当前滑动方向滚动,就不拦截;否则拦截。
public class BoundaryScrollView extends ScrollView {
private float mLastY;
private View mTarget; // 内部可滚动的子 View
public BoundaryScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
// 假设内部只有一个可直接滚动的子 View
mTarget = getChildAt(0);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mLastY = ev.getY();
intercept = false;
break;
case MotionEvent.ACTION_MOVE:
float y = ev.getY();
float dy = y - mLastY;
if (mTarget != null) {
boolean canScrollUp = mTarget.canScrollVertically(-1);
boolean canScrollDown = mTarget.canScrollVertically(1);
// 子 View 还能继续滚动,不拦截;到边界了,外层拦截
if (dy > 0 && canScrollUp) {
intercept = false; // 下拉且子 View 还没到顶
} else if (dy < 0 && canScrollDown) {
intercept = false; // 上滑且子 View 还没到底
} else {
intercept = true; // 子 View 已经到边界,外层滚动
}
}
mLastY = y;
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mLastY = 0;
intercept = false;
break;
}
return intercept;
}
}
使用方式
<com.example.BoundaryScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="200dp" />
<com.example.BoundaryListView
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</com.example.BoundaryScrollView>
关键点
ScrollView内部只能有一个直接子 View,所以通常是LinearLayout再包ListView。canScrollVertically(direction)的语义:direction < 0表示内容上方是否还能滚动,direction > 0表示内容下方是否还能滚动。- 父 View 的
ACTION_DOWN不能拦截,否则子 View 永远收不到按下事件。
3.2 内部拦截:子 ListView 判断自身边界
保持 ScrollView 不动,让内部 ListView 自己决定什么时候阻止/放开外层拦截。
public class BoundaryListView extends ListView {
private float mLastY;
public BoundaryListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mLastY = ev.getY();
// 默认先自己处理
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
float y = ev.getY();
float dy = y - mLastY;
boolean canScrollUp = canScrollVertically(-1);
boolean canScrollDown = canScrollVertically(1);
// 到顶继续下拉 || 到底继续上滑 -> 交给外层
if ((dy > 0 && !canScrollUp) || (dy < 0 && !canScrollDown)) {
getParent().requestDisallowInterceptTouchEvent(false);
} else {
getParent().requestDisallowInterceptTouchEvent(true);
}
mLastY = y;
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
getParent().requestDisallowInterceptTouchEvent(false);
break;
}
return super.onTouchEvent(ev);
}
}
关键点
ACTION_DOWN时先阻止父 View 拦截,确保前几个MOVE能进入ListView自己的onTouchEvent。ACTION_MOVE时实时判断边界,决定是继续自己消费还是交还父容器。ACTION_UP/CANCEL必须重置,否则下一次按下可能仍然被禁止拦截。