嵌套滑动冲突的解法可以归为两类:外部拦截 (父 View 在
onInterceptTouchEvent里决定要不要抢事件)和 内部拦截 (子 View 在onTouchEvent里调用requestDisallowInterceptTouchEvent决定是否上交事件)。
一、四种方案速查
| 场景 | 外层方向 | 内层方向 | 外部拦截 | 内部拦截 |
|---|---|---|---|---|
| 方向不一致 | 横向 | 竖向 | 父容器判断主导方向为横向时拦截 | 子 View 判断为竖向时请求父容器不要拦截 |
| 方向一致 | 竖向 | 竖向 | 父容器判断子 View 到边界后拦截 | 子 View 判断自己还能滚动时阻止父容器拦截 |
二、方向不一致:外层横向 + 内层竖向
典型结构:ViewPager 每一页里面是 ListView / RecyclerView。
2.1 外部拦截:父 View 判断主导方向
自定义 ViewPager,在 onInterceptTouchEvent 中比较 dx 和 dy,只有横向位移占优时才拦截。
ini
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);
}
}
使用方式
ini
<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 里先拿到事件,判断为竖向滑动时请求父容器不要拦截。
java
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 还能朝当前滑动方向滚动,就不拦截;否则拦截。
ini
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;
}
}
使用方式
ini
<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 自己决定什么时候阻止/放开外层拦截。
java
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必须重置,否则下一次按下可能仍然被禁止拦截。
四、调试:一个打印事件链的工具类
直接复制到自定义 View 里,先看清楚事件被谁消费了。
arduino
private void logEvent(String tag, MotionEvent ev) {
String action;
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN: action = "DOWN"; break;
case MotionEvent.ACTION_MOVE: action = "MOVE"; break;
case MotionEvent.ACTION_UP: action = "UP"; break;
case MotionEvent.ACTION_CANCEL:action = "CANCEL";break;
default: action = "OTHER"; break;
}
Log.d("TouchLog", tag + " -> " + action + " x=" + ev.getX() + " y=" + ev.getY());
}
在父 View 和子 View 的以下方法中调用:
dispatchTouchEventonInterceptTouchEvent(仅 ViewGroup)onTouchEvent
typescript
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
logEvent("ScrollView-dispatch", ev);
return super.dispatchTouchEvent(ev);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
logEvent("ScrollView-intercept", ev);
return super.onInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
logEvent("ScrollView-touch", ev);
return super.onTouchEvent(ev);
}
五、四种写法如何选择
| 方案 | 改动位置 | 适用场景 | 缺点 |
|---|---|---|---|
| 外部拦截-方向不一致 | 自定义父 ViewPager | 父组件统一控制,子 View 类型不固定 | 需要重写父容器 |
| 内部拦截-方向不一致 | 自定义子 ListView | 只改子 View,父容器不动 | 需要在 dispatchTouchEvent 里处理 |
| 外部拦截-方向一致 | 自定义父 ScrollView | 页面结构固定,父容器统一接管 | ScrollView 直接子 View 只能有一个 |
| 内部拦截-方向一致 | 自定义子 ListView | 只替换内部列表组件 | 需要实时判断边界 |
六、一个最小可运行示例
ini
<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="wrap_content"
android:text="页面头部" />
<com.example.BoundaryListView
android:id="@+id/list_view"
android:layout_width="match_parent"
android:layout_height="500dp" />
</LinearLayout>
</com.example.BoundaryScrollView>
scala
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ListView listView = findViewById(R.id.list_view);
ArrayAdapter<String> adapter = new ArrayAdapter<>(
this,
android.R.layout.simple_list_item_1,
new String[]{
"item 1", "item 2", "item 3", "item 4", "item 5",
"item 6", "item 7", "item 8", "item 9", "item 10",
"item 11", "item 12", "item 13", "item 14", "item 15",
"item 16", "item 17", "item 18", "item 19", "item 20"
}
);
listView.setAdapter(adapter);
}
}
运行后上下滑动 ListView,到底/到顶后继续滑动,外层 ScrollView 会自动接管。