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

嵌套滑动冲突的解法可以归为两类:外部拦截 (父 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 的以下方法中调用:

  • dispatchTouchEvent
  • onInterceptTouchEvent(仅 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 会自动接管。

相关推荐
木子欢儿3 小时前
Zabbix 7.4 Alpine 版 Docker Compose 部署
android·运维·docker·容器·zabbix
素师良码4 小时前
第9篇:Android驱动踩坑实录:DSI RX FIFO前置脏0、数据错位复盘
android
夜雪一千4 小时前
如何在MySQL中实现字段级加密
android·数据库·mysql
夜雪一千4 小时前
MySQL 如何存储经纬度坐标:选型、建表、空间查询实战
android·mysql·adb
JMchen4 小时前
集成第三方 C/C++ 库到 Android NDK 项目:OpenCV 与 FFmpeg 实战指南
android·opencv·cmake
有毒的教程6 小时前
Dev-C++ 使用扩展库两种方式:DevPak一键安装 / 手动配置(重点)
android·c++·adb
mmsx6 小时前
Android 测绘数值基础:测量单位系统与七四参数、高程拟合
android
郑州光合科技余经理7 小时前
出海外卖平台:支付对接为什么要和下单收款分开
android·java·开发语言·前端·后端·php·ai编程
夜雪一千7 小时前
MySQL 如何提高查询速度:从索引、SQL、表结构到实战调优
android·sql·mysql