Android 嵌套滑动冲突:内部拦截 vs 外部拦截实战

268 阅读4分钟

嵌套滑动冲突的解法可以归为两类:外部拦截(父 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 必须重置,否则下一次按下可能仍然被禁止拦截。