Android View滑动冲突处理

一 概述

上一篇文章我们讲述了 Android View事件分发机制。如果你对 View 的事件分发还不熟悉,建议先去看一下,它是我们今天滑动冲突解决的理论基础。

如果你已经对 View 的事件分发机制了然于胸,那么我们就根据 View 的事件分发机制,来给大家详细聊一下滑动冲突。

二 滑动冲突的常见场景与处理思路

当我们内外两层 View 都可以滑动时候,就会产生滑动冲突。

常见的滑动冲突场景:
在这里插入图片描述

  • 外层与内层滑动方向不一致,外层 ViewGroup 是可以横向滑动的,内层 View 是可以竖向滑动的(类似 ViewPager,每个页面里面是 ListView)
  • 外层与内层滑动方向一致,外层 ViewGroup 是可以竖向滑动的,内层 View 同样也是竖向滑动的(类似 ScrollView 包裹 ListView)
  • 当然还有上面两种组合起来,三层或者多层嵌套产生的冲突,然而不管是多么复杂,解决的思路都是一模一样。所以遇到多层嵌套的小伙伴也不用惊慌,一层一层处理即可。

有小伙伴肯定有疑问,ViewPager 带 ListView 并没有出现滑动冲突啊。那是因为 ViewPager 已经为我们处理了滑动冲突!如果我们自己定义一个水平滑动的 ViewGroup 内部再使用 ListView,那么是一定需要处理滑动冲突的。

针对上面的第一种场景,由于外部与内部的滑动方向不一致,那么我们可以根据当前滑动方向,水平还是垂直来判断这个事件到底该交给谁来处理。至于如何获得滑动方向,我们可以得到滑动过程中的两个点的坐标。一般情况下根据水平和竖直方向滑动的距离差就可以判断方向,当然也可以根据滑动路径形成的夹角(或者说是斜率如下图)、水平和竖直方向滑动速度差来判断。

在这里插入图片描述
针对第二种场景,由于外部与内部的滑动方向一致,那么不能根据滑动角度、距离差或者速度差来判断。这种情况下必需通过业务逻辑来进行判断。比较常见 ScrollView 嵌套了 ListView。虽然需求不同,业务逻辑自然也不同,但是解决滑动冲突的方式都是一样的。下面为大家截取了微博和天猫当中的同方向滑动冲突场景,方便大家更直观的感受这个场景。

在这里插入图片描述
微博的这个是同方向,竖向滑动冲突的场景,可以看到发现布局整体是可以滚动的,而且下方的微博列表也是可以滚动的。根据业务逻辑,当热门,榜单…这一行标签栏滑动到顶部的时候微博列表才可以滚动。否则就是布局的整体滚动。这个场景是不是在很多 app 里面都能够见到呢?

在这里插入图片描述
天猫的这个是同方向,横向滑动冲突的场景,内外两层都是可以横向滚动的。它的处理逻辑也很明显,根据用户滑动的位置来判断到底是那个 View 需要响应滑动。

上面两种滑动冲突的场景区别只是在于拦截的逻辑处理上。第一种是根据水平还是竖直滑动来判断谁来处理滑动,第二种是根据业务逻辑来判断谁来处理滑动,但是处理的套路都是一样的

三 滑动冲突解决套路

3.1 外部拦截法:

即父 View 根据需要对事件进行拦截。逻辑处理放在父 View 的 onInterceptTouchEvent 方法中。我们只需要重写父 View 的onInterceptTouchEvent 方法,并根据逻辑需要做相应的拦截即可。

    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;
    }

上面伪代码表示外部拦截法的处理思路,需要注意下面几点:

  • 根据业务逻辑需要,在 ACTION_MOVE 方法中进行判断,如果需要父 View 处理则返回 true,否则返回 false,事件分发给子 View 去处理。
  • ACTION_DOWN 一定返回 false,不要拦截它,否则根据 View 事件分发机制,后续 ACTION_MOVE 与 ACTION_UP 事件都将默认交给父 View 去处理
  • 原则上 ACTION_UP 也需要返回 false,如果返回 true,而当滑动事件是交给子 View 处理的时候,那么子 View 将接收不到 ACTION_UP 事件,子 View 的 onClick 事件也无法触发。而父 View 不一样,如果父 View 在 ACTION_MOVE 中开始拦截事件,那么后续 ACTION_UP 也将默认交给父 View 处理

3.2 内部拦截法:

即父 View 不拦截任何事件,所有事件都传递给子 View,子 View 根据需要,决定是自己消费事件还是给父 View 处理。这需要子 View 使用 requestDisallowInterceptTouchEvent 方法才能正常工作。下面是子 View 的 dispatchTouchEvent 方法的伪代码:

    public boolean dispatchTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                parent.requestDisallowInterceptTouchEvent(true);
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                if (父容器需要此类点击事件) {
                    parent.requestDisallowInterceptTouchEvent(false);
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                break;
            }
            default:
                break;
        }

        mLastX = x;
        mLastY = y;
        return super.dispatchTouchEvent(event);
    }

父 View 需要重写 onInterceptTouchEvent 方法:

    public boolean onInterceptTouchEvent(MotionEvent event) {

        int action = event.getAction();
        if (action == MotionEvent.ACTION_DOWN) {
            return false;
        } else {
            return true;
        }
    }

使用内部拦截法需要注意:

  • 内部拦截法要求父 View 不能拦截 ACTION_DOWN 事件,由于 ACTION_DOWN 不受 FLAG_DISALLOW_INTERCEPT 标志位控制,一旦父容器拦截 ACTION_DOWN 那么所有的事件都不会传递给子 View
  • 滑动策略的逻辑放在子 View 的 dispatchTouchEvent 方法的 ACTION_MOVE 中,如果父容器需要获取点击事件则调用 parent.requestDisallowInterceptTouchEvent(false) 方法,让父容器去拦截事件

四 滑动冲突解决示例

理论最终的目的是为了实践,下面我们通过一个例子来演示外部解决法和内部解决法,是如何解决滑动冲突的,大家只要 get 到了精髓,那么今后遇到滑动冲突问题都将迎刃而解,不再是开发拦路虎。

我们一开始说过 ViewPager 已经默认给我们处理了滑动冲突,而它作为 ViewGroup 使用的是外部拦截法解决的冲突,即在 onInterceptTouchEvent 方法中进行判断的。我们现在自定义一个 ViewPager,重写 onInterceptTouchEvent 方法并默认返回 false,这样就造成了滑动冲突的场景,如下所示:

public class BadViewPager extends ViewPager {
    public BadViewPager(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return false;
    }
}

接下来新建一个 ScrollConflicActivity 用来测试滑动冲突。

public class ScrollConflictActivity extends BaseActivity {
    private BadViewPager mViewPager;
    private List<View> mViews;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_scroll_conflict);
        initViews();
        initData(false);
    }

    protected void initViews() {
        mViewPager = findAviewById(R.id.viewpager);
        mViews = new ArrayList<>();
    }

    protected void initData(final boolean isListView) {
        //初始化mViews列表
        Flowable.just("view1", "view2", "view3", "view4").subscribe(new Consumer<String>() {
            @Override
            public void accept(String s) throws Exception {
                //当前View
                View view;
                if (isListView) {
                    //初始化ListView
                    ListView listView = new ListView(mContext);
                    final ArrayList<String> datas = new ArrayList<>();
                    //初始化数据,datas ,data0 ...data49
                    Flowable.range(0, 50).subscribe(new Consumer<Integer>() {
                        @Override
                        public void accept(Integer integer) throws Exception {
                            datas.add("data" + integer);
                        }
                    });
                    //初始化adapter
                    ArrayAdapter<String> adapter = new ArrayAdapter<>
                            (mContext, android.R.layout.simple_list_item_1, datas);
                    //设置adapter
                    listView.setAdapter(adapter);
                    //将ListView赋值给当前View
                    view = listView;
                } else {
                    //初始化TextView
                    TextView textView = new TextView(mContext);
                    textView.setGravity(Gravity.CENTER);
                    textView.setText(s);
                    //将TextView赋值给当前View
                    view = textView;
                }
                //将当前View添加到ViewPager的ViewList中去
                mViews.add(view);
            }
        });
        //设置ViewPager的Adapter
        mViewPager.setAdapter(new BasePagerAdapter<>(mViews));
    }
}

注:Flowable 是 RxJava2 的方法,这里其实用 for 循环也是一样的,BasePagerAdapter 是 BaseProject 里的方法

上面的代码我们使用了 BadViewPager,初始化了 BadViewPager 里面的子 View。

initData(false);方法传 false 表示里面的子 View 是一个 TextView,传 true 表示里面的子 View 是 ListView。

首先我们看 BadViewPager 里面子 View 是 TextView 是否可以滑动。

在这里插入图片描述
似乎对 BadViewPager 的滑动没有任何影响。

大家别急,我们来分析一下,BadViewPager 的 onInterceptTouchEvent 默认返回 false 则所有事件都会给子 View 去处理。大家是否还记得上一篇说到的 View 处理事件的原则?

View 的 onTouchEvent 方法默认都会消费掉事件(返回 true),除非它是不可点击的(clickable 和 longClickable 同时为 false),View 的 longClickable 默认为 false,clickable 需要区分情况,如 Button 的 clickable 默认为 true,而 TextView 的 clickable 默认为 false。

所以 TextView 默认并没有消费事件,因为他是不可点击的。事件会交由父 View 即 BadViewPager 的 onTouchEvent 方法去处理。所以它自然是可以滑动的。

我们将 textview 的 Clickable 设置成 true,即让它来消费事件。大家再看看呢
在这里插入图片描述
所以我们不难推测如果将 TextView 换成 Button,将是一样的无法滑动的效果。虽然这并不是常规的滑动冲突(子 View 不是滑动的),但是造成的原因其实是一样的,没有做滑动判断导致父 View 不能正确响应滑动事件。

接下来稍稍修改一下代码 initData(true);传入 true,即 BadViewPager 的子 View 使用 ListView,显然 ListView 是可以滑动的,BadViewPager 是不能滑动的。我们分别通过外部拦截和内部拦截方法来对 BadViewPager 进行修复。

在这里插入图片描述
1.外部拦截法修复 BadViewPager

public class BadViewPager extends ViewPager {
    private static final String TAG = "BadViewPager";

    private int mLastXIntercept;
    private int mLastYIntercept;

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

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        final int action = ev.getAction() & MotionEvent.ACTION_MASK;
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                intercepted = false;
                //调用 ViewPager 的 onInterceptTouchEvent 方法初始化 mActivePointerId
                super.onInterceptTouchEvent(ev);
                break;
            case MotionEvent.ACTION_MOVE:
                //横坐标位移增量
                int deltaX = x - mLastXIntercept;
                //纵坐标位移增量
                int deltaY = y - mLastYIntercept;
                if (Math.abs(deltaX)>Math.abs(deltaY)){
                    intercepted = true;
                }else{
                    intercepted = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercepted = false;
                break;
            default:
                break;
        }

        mLastXIntercept = x;
        mLastYIntercept = y;

        LogUtil.e(TAG,"intercepted = "+intercepted);
        return intercepted;
    }
}

根据我们的外部拦截法的套路,需要重写 BadViewPager 的 onInterceptTouchEvent 方法,并且 ACTION_DOWN 和 ACTION_UP 返回为false。处理逻辑在 ACTION_MOVE 中,Math.abs(deltaX)>Math.abs(deltaY) 表示横向位移增量大于竖向位移增量,即水平滑动,则 BadViewPager 拦截事件。

这里我们在 ACTION_DOWN 当中还调用了 super.onInterceptTouchEvent(ev);即 ViewPager 的 onInterceptTouchEvent 方法。主要是为了初始化 ViewPager 的成员变量 mActivePointerId。mActivePointerId 默认值为 -1,在 ViewPager 的 onTouchEvent 方法的 ACTION_MOVE 中有这么一段:

class ViewPager
    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        ......
        switch (action & MotionEventCompat.ACTION_MASK) {
            case MotionEvent.ACTION_MOVE:
                if (!mIsBeingDragged) {
                    final int pointerIndex = ev.findPointerIndex(mActivePointerId);
                    if (pointerIndex == -1) {
                        // A child has consumed some touch events and put us into an inconsistent
                        // state.
                        needsInvalidate = resetTouch();
                        break;
                    }
                    //具体的滑动操作...
                }
                ......
                break;
                ......
        }
        ......
    }

假如 mActivePointerId 不进行初始化,ViewPager 会认为这个事件已经被子 View 给消费了,然后 break 掉,接下来的滑动操作也就不执行了。
在这里插入图片描述
2.内部拦截法修复 BadViewPager
内部拦截法需要重写 ListView 的 dispatchTouchEvent 方法,所以我们自定义一个 ListView:

public class FixListView extends ListView {
    private static final String TAG = "FixListView";

    private int mLastX;
    private int mLastY;

    public FixListView(Context context) {
        super(context);
    }

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

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

        final int action = ev.getAction() & MotionEvent.ACTION_MASK;
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                //水平移动的增量
                int deltaX = x - mLastX;
                //竖直移动的增量
                int deltaY = y - mLastY;
                //当水平增量大于竖直增量时,表示水平滑动,此时需要父View去处理事件
                if (Math.abs(deltaX) > Math.abs(deltaY)){
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
            default:
                break;
        }
        mLastX = x;
        mLastY = y;
        return super.dispatchTouchEvent(ev);
    }
}

再看 BadViewPager,需要重写拦截方法

public class BadViewPager extends ViewPager {
    private static final String TAG = "BadViewPager";

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

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        final int action = ev.getAction() & MotionEvent.ACTION_MASK;
        if (action == MotionEvent.ACTION_DOWN){
            super.onInterceptTouchEvent(ev);
            return false;
        }
        return true;
    }
}

可以看到和我们的套路代码基本上一样,只是 ACTION_MOVE 中有我们自己的逻辑处理,处理的方式与外部拦截法也是一致的,并且效果也一样,在此不作赘述。

大家只要理解并掌握了上述滑动冲突的解决套路,不论场景是不同方向,还是同方向,还是乱七八糟的堆加在一起,就用套路去解决,万变不离其宗。根据上述的外部拦截和内部拦截法,可以看出外部拦截法实现起来更加简单,而且也符合 View 的正常事件分发机制,所以推荐使用外部拦截法(重写父 View 的 onInterceptTouchEvent,父 View 决定是否拦截)来处理滑动冲突。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值