【Android】常见滑动冲突场景及解决方案

【Android】常见滑动冲突场景及解决方案

前言

Android滑动冲突是Android开发中常见的问题,在同一个界面,可能存在多个View可以响应滑动事件。如果这些View滑动方向一致,则会导致滑动冲突。在上一篇文章中已经介绍了View的事件分发机制,以及源码实现。本篇文章将围绕常见的滑动冲突场景展开,并介绍对应的解决方案。

1. 滑动冲突的场景

常见的滑动冲突总共有3种:

  1. 同向滑动冲突:外部ViewGroup和内部子View的滑动方向一致,比如ViewGroup可以上下滑动,子View也可以上下滑动。
  2. 异向滑动冲突:外部滑动方向和内部滑动方向不一致。
  3. 混合滑动冲突:以上两种情况的嵌套。

滑动冲突产生的根本原因在于发生滑动时,不知道是让ViewGroup滑动,还是让ViewGroup内的子View滑动。因此解决滑动冲突也很简单,就是根据当前布局内容的状态,以及当前的滑动方向来判断到底是让哪个View滑动。

比如,一个经典的滑动冲突场景:

xml 复制代码
<ScrollView
  android:layout_width="match_parent"
  android:layout_height="match_parent">
  
  <androidx.recyclerview.widget.RecyclerView
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
    
</ScrollView>

ScrollView和RecyclerView在竖直方向上都可以滑动,所以当滑动产生时,系统无法知道用户到底是想让哪一层滑动,所以当手指滑动的时候就会出现问题,要么只有一层能滑动,要么就是内外两层都滑动地很卡顿。

要处理该滑动冲突场景,首先应该明确手指的滑动方向是水平还是竖直方向,如果是水平方向,那么事件应该交由ScrollView处理,因为RecyclerView是竖向布局,处理不了水平滑动事件。如果是竖直方向的滑动,则应先判断RecyclerView此时竖直方向是否还可以滑动,若能就交给RecyclerView来处理,否则依然交给ScrollView处理。

以上思路在解决剩下两种情况时依然适用,核心思想就是要根据滑动的方向以及业务的场景来判断此时应该由哪个View处理滑动事件

2. 滑动冲突的处理规则

对于异向滑动冲突(比如ViewPager嵌套RecyclerView),当用户左右滑动时,需要让外部的View拦截点击事件,当用户上下滑动时,需要让内部的View拦截点击事件。具体就是:根据滑动是水平滑动还是竖直滑动来判断到底由谁来拦截事件。

如图所示,根据滑动过程中两个点之间的坐标就可以得出到底是水平滑动还是竖直滑动。根据坐标判断滑动方向有很多方法,比如可以根据滑动路径和水平方向所形成的夹角,也可以依据水平方向和竖直方向的速度差来判断。不过一般都是采用水平方向和竖直方向之间的距离差来判断。比如竖直方向之间的滑动距离大就判断为竖直滑动,否则判断为水平滑动。

对于同向和混合滑动冲突,一般要根据具体的业务逻辑来处理规则,什么时候让外部ViewGroup拦截,什么时候要内部View拦截。

3. 滑动冲突的解决方式

3.1 外部拦截法

​ 外部拦截法是指事件都先经过父视图的拦截处理,如果父视图需要此事件就拦截,如果不需要此事件就分发给子视图。在上面的例子中,指的是事件都先经过ScrollView的拦截处理,如果ScrollView不需要拦截此事件,那么就正常分发给RecyclerView。外部拦截法需要重写父容器的onInterceptTouchEvent方法,在内部做相应的拦截即可。代码如下:

java 复制代码
public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted = false;
    int x = (int) event.getX();
    int y = (int) event.getY();
    
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            intercepted = false;
            break;
            
        case MotionEvent.ACTION_MOVE:
            if (父容器需要当前点击事件) {
                intercepted = true;
            } else {
                intercepted = false;
            }
            break;
            
        case MotionEvent.ACTION_UP:
            intercepted = false;
            break;
            
        default:
            break;
    }
    
    mLastXIntercept = x;
    mLastYIntercept = y;
    return intercepted;
}

以上是外部拦截法的典型逻辑,修改ViewGroup的onInterceptTouchEvent()方法,因此称为外部拦截法。需要注意的是,在ACTION_DOWN事件到来时,父容器必须返回false,即不拦截down事件,因为ViewGroup一旦拦截了down事件,那么事件传递就会在ViewGroup终止,无论ViewGroup是否拦截事件,接下来的事件都不会传递给子视图RecyclerView。在move事件到来时,就根据此ViewGroup是否需要事件来判断拦不拦截,如果拦截了,那么RecyclerView不会接收到后续的事件,由ScrollView自己处理事件。对于up事件,不需要做什么,默认不拦截,因为up事件本身没有太多意义。

3.2 内部拦截法

内部拦截法指的是父视图不拦截任何事件,所有的事件都传递给子视图,如果子视图需要此事件就直接消耗,否则交由父视图处理。这种方法先将事件交给子视图,然后再传给父视图,与Android中的事件默认分发顺序不一致,需要配合requestDisallowInterceptTouchEvent()方法才能工作。与外部拦截法相比,内部拦截法会更复杂一点。需要重写子元素的dispatchTouchEvent代码如下:

java 复制代码
public boolean dispatchTouchEvent(MotionEvent event) {
    int x = (int) event.getX();
    int y = (int) event.getY();
    
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            // 要求父容器不要拦截,确保子View能收到完整事件序列
            getParent().requestDisallowInterceptTouchEvent(true);
            break;
            
        case MotionEvent.ACTION_MOVE:
            int deltaX = x - mLastX;
            int deltaY = y - mLastY;
            
            // 当父容器需要此类点击事件时
            if (父容器需要此类点击事件) {  // 这个条件需要具体实现
                // 允许父容器拦截
                getParent().requestDisallowInterceptTouchEvent(false);
            }
            break;
            
        case MotionEvent.ACTION_UP:
            break;
            
        default:
            break;
    }
    
    mLastX = x;
    mLastY = y;
    return super.dispatchTouchEvent(event);
}

除了子元素需要做处理之外,父元素也要默认拦截除了ACTION_DOWN以外的其他事件,这样当子元素调用parent.requestDisallowInterceptTouchEvent(false)方法时,父元素才能继续拦截所需的事件。父元素所做的修改如下:

JAVA 复制代码
public boolean onInterceptTouchEvent(MotionEvent event) {
    int action = event.getAction();
    if (action == MotionEvent.ACTION_DOWN) {
        return false;
    } else {
        return true;
    }
}

为什么父容器不能拦截ACTION_DOWN事件呢?

在上一篇文章中已经分析过,ACTION_DOWN事件不受FLAG_DISALLOW__INTERCEPT这个标记位的控制,所以一旦父容器拦截ACTION_DOWN事件,那么所有的事件都无法传递到子元素中去,这样内部拦截法就无法起作用了。

下面将通过一个小demo来具体体验一下整个过程。

4. 解决滑动冲突的实例

这里采用ScrollView嵌套RecyclerView的方案,并分别使用外部拦截法和内部拦截法的解决滑动冲突。

首先,看看主要布局结构:

xml 复制代码
<!-- 水平滑动的分类容器 -->
    <HorizontalScrollView
        android:id="@+id/horizontalScrollView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:background="#FFFFFF"
        android:scrollbars="none">

        <LinearLayout
            android:id="@+id/categoryContainer"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:orientation="horizontal">

            <!-- 分类1:手机 -->
            <LinearLayout
                android:layout_width="360dp"
                android:layout_height="match_parent"
                android:orientation="vertical"
                android:background="#FAFAFA">

                <TextView
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:background="#FF5722"
                    android:gravity="center"
                    android:padding="15dp"
                    android:text="📱 手机分类"
                    android:textColor="#FFFFFF"
                    android:textSize="18sp"
                    android:textStyle="bold" />

                <!-- 垂直的RecyclerView -->
                <androidx.recyclerview.widget.RecyclerView
                    android:id="@+id/recyclerView1"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent" />

            </LinearLayout>

            <!-- 分类2:电脑 -->
            <LinearLayout
                android:layout_width="360dp"
                android:layout_height="match_parent"
                android:orientation="vertical"
                android:background="#F5F5F5">

                <TextView
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:background="#3F51B5"
                    android:gravity="center"
                    android:padding="15dp"
                    android:text="💻 电脑分类"
                    android:textColor="#FFFFFF"
                    android:textSize="18sp"
                    android:textStyle="bold" />

                <androidx.recyclerview.widget.RecyclerView
                    android:id="@+id/recyclerView2"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent" />

            </LinearLayout>

            <!-- 分类3:配件 -->
            <LinearLayout
                android:layout_width="360dp"
                android:layout_height="match_parent"
                android:orientation="vertical"
                android:background="#FAFAFA">

                <TextView
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:background="#009688"
                    android:gravity="center"
                    android:padding="15dp"
                    android:text="🎧 配件分类"
                    android:textColor="#FFFFFF"
                    android:textSize="18sp"
                    android:textStyle="bold" />

                <androidx.recyclerview.widget.RecyclerView
                    android:id="@+id/recyclerView3"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent" />

            </LinearLayout>

        </LinearLayout>

    </HorizontalScrollView>

这个布局中主要就是一个水平滑动的ScrollView和三个RecyclerView。这个RecyclerView是竖直滑动的,而外部的ScrollView是水平滑动的,这就产生了异向滑动冲突,这种滑动冲突也是最常见的滑动冲突。这里没有采用ViewPager作为外部布局是因为ViewPager已经帮我们处理了滑动冲突,我们不需要自己手动去处理。

先看看没有解决滑动冲突时的滑动效果:

可以看到,滑动非常卡顿,有时候竖直方向划不动,慢慢滑才能勉强竖直滑动。下面分别介绍外部拦截法和内部拦截法处理滑动冲突。

  1. 外部拦截法

​ 新建CustomHorizontalScrollView继承HorizontalScrollView,重写onInterceptTouchEvent方法,在down事件时记录手指点击的坐标,move事件时计算滑动的距离,并分别计算水平方向和竖直方向的距离差并进行条件判断,如果是横向滑动,则自己处理事件,否则就交给子RecyclerView处理。

java 复制代码
public class CustomHorizontalScrollView extends HorizontalScrollView {
    private float lastX, lastY;

    public CustomHorizontalScrollView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        float x = ev.getX();
        float y = ev.getY();

        switch (ev.getAction()) {

            case MotionEvent.ACTION_DOWN:
                lastX = x;
                lastY = y;
                // 必须让父类处理,否则不能接收后续 MOVE
                super.onInterceptTouchEvent(ev);
                return false;

            case MotionEvent.ACTION_MOVE:
                float dx = Math.abs(x - lastX);
                float dy = Math.abs(y - lastY);

                // 横向滑动,外部拦截
                if (dx > dy && dx > 6) {
                    return true;
                }

                // 纵向滑动,交给内部 RecyclerView
                return false;
        }

        return super.onInterceptTouchEvent(ev);
    }
}

需要注意的是,down事件的处理必须要交给父容器处理,因为这个HorizontalScrollView不是顶级View,事件是从父视图分发下来的,所以down事件要交给父视图来处理,调用super.onInterceptTouchEvent(ev);方法即可。

在布局中将HorizontalScrollView替换成我们自定义的CustomHorizontalScrollView,然后看看滑动效果:

能够水平滑动,同时也不影响RecyclerView的竖直滑动,说明左右滑动和上下滑动都能被正确的对象消费,这样异向的滑动冲突就解决了。

  1. 内部拦截法

​ 首先把外部的CustomHorizontalScrollView改回HorizontalScrollView,新建HorizontalConflictRecyclerView继承自RecyclerView,重写它的dispatchTouchEvent方法:

java 复制代码
public class HorizontalConflictRecyclerView extends RecyclerView {
    private float startX, startY;

    public HorizontalConflictRecyclerView(@NonNull Context context) {
        super(context);
    }

    public HorizontalConflictRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public HorizontalConflictRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        float x = ev.getX();
        float y = ev.getY();

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                startX = x;
                startY = y;
                // 告诉父View(HorizontalScrollView)不要拦截,我来处理
                getParent().requestDisallowInterceptTouchEvent(true);
                break;

            case MotionEvent.ACTION_MOVE:
                float dx = Math.abs(x - startX);
                float dy = Math.abs(y - startY);

                if (dx > dy && dx > 10) {
                    // 水平滑动距离大于垂直滑动,让父View(HorizontalScrollView)处理
                    getParent().requestDisallowInterceptTouchEvent(false);
                } else if (dy > dx && dy > 10) {
                    // 垂直滑动距离大于水平滑动,自己处理(滚动RecyclerView)
                    getParent().requestDisallowInterceptTouchEvent(true);
                }
                break;

            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                getParent().requestDisallowInterceptTouchEvent(false);
                break;
        }

        return super.dispatchTouchEvent(ev);
    }
}

还是一样,先获取手指点击屏幕的坐标,在down事件记录这个初始坐标,同时调用requestDisallowInterceptTouchEvent(true);方法告诉父视图不要拦截事件,由当前View来处理。接着当move事件发生时,获取水平方向和竖直方向的距离差并进行条件判断,水平方向的滑动就交给父视图(HorizontalScrollView)处理,竖直方向的滑动就自己处理,同样也是调用父视图的requestDisallowInterceptTouchEvent();方法。最后放回super.dispatchTouchEvent(ev)让RecyclerView继续执行自己的事件分发逻辑。

然后在布局文件里把普通的RecyclerView改成我们自定义的HorizontalConflictRecyclerView,运行看看效果:

和外部拦截的效果一致,各个方向滑动都正常。

内容参考:

《安卓开发艺术探索》- 任玉刚

安卓基础知识之View篇(四):View 事件滑动冲突解决方案

相关推荐
间彧2 小时前
GraalVM 深度解析:下一代 Java 技术平台
java
angushine2 小时前
解决MySQL慢日志输出问题
android·数据库·mysql
合作小小程序员小小店2 小时前
网页开发,在线%旧版本旅游管理%系统,基于eclipse,html,css,jquery,servlet,jsp,mysql数据库
java·数据库·servlet·eclipse·jdk·旅游·jsp
fouryears_234172 小时前
Android 与 Flutter 通信最佳实践 - 以分享功能为例
android·flutter·客户端·dart
20岁30年经验的码农2 小时前
Java Sentinel流量控制与熔断降级框架详解
java·开发语言·sentinel
程序员西西2 小时前
SpringBoot轻松整合Sentinel限流
java·spring boot·后端·计算机·程序员
q***46522 小时前
Spring Boot 实战:轻松实现文件上传与下载功能
java·数据库·spring boot
Li_7695322 小时前
10分钟快速入手Spring Cloud Config
java·spring·spring cloud
源码技术栈3 小时前
Java基于云计算的社区门诊系统源码 医院门诊系统源码 已实现医保结算 SaaS模式
java·云计算·源码·诊所·门诊·预约挂号·云门诊