Android View的事件体系(五)滑动冲突

滑动冲突是指,当手指在屏幕上进行滑动操作,而这个操作可能会同时作用在多个控件上,并且产生不可预测的结果,这个时候我们就认为这个滑动操作是有冲突的。

举个例子:有一个可以上下滑动的列表,同时这个列表的每个item又是可以左右滑动的,如果我们不进行处理,那么造成的后果就是只有列表能上下滑动或者只有item能左右滑动,这个时候我们就可以认为两者之间是有滑动冲突的。

一、滑动冲突的场景

滑动冲突的场景不外乎以下三种

  • 外部滑动方向与内部滑动方向不一致
  • 外部滑动方向与内部滑动方向一致
  • 上面两种情况的嵌套
滑动冲突场景

从本质上来说,这三种冲突产生的原因是一致的。尽管场景3看起来稍微复杂一些,其实也就是场景1和场景2嵌套造成的,我们只需要分别处理内层和中层、中层和外层的冲突就可以了。所以不管这些场景如何嵌套,我们只要掌握如何处理场景1和场景2的滑动冲突,问题都能迎刃而解。

二、滑动冲突的处理规则

  • 场景1处理规则

当用户上下滑动时,让内部View拦截事件;当用户左右滑动时,让外部View拦截事件,即根据滑动方向是竖直还是水平来决定由谁来拦截事件。我们可以根据坐标来判断滑动的方向,比如根据滑动路径与水平方向的夹角、水平方向与竖直方向滑动距离差、甚至还可以根据水平方向与竖直方向的速度差。
这里我们只讨论采用水平 方向和竖直方向滑动距离差来判断滑动方向的情况。如下图所示,当dx > dy我们认为这是一次水平方向的滑动;当dx < dy我们认为这是一次竖直方向的滑动。

滑动过程图
  • 场景2处理规则

场景2这种情况一般都是在业务逻辑上寻找解决方案。例如我们平时经常使用的SwipeRefreshLayout,假设我们在SwipeRefreshLayout内部嵌套一个竖直滑动的列表,理论上应该是会有滑动冲突的。但是我们发现SwipeRefreshLayout内部嵌套RecyclerView通常都表现得比较友好,这是因为google已经为我们解决了滑动冲突,我们来看一下google是如何解决SwipeRefreshLayout与它的子View的滑动冲突的。

    public boolean onInterceptTouchEvent(MotionEvent ev) {
        //确保SwipeRefreshLayout至少有一个child view,并且第一个child赋值给mTarget
        this.ensureTarget();    
        int action = ev.getActionMasked();
        if (this.mReturningToStart && action == 0) {
            this.mReturningToStart = false;
        }
        //判断当前SwipeRefreshLayout是否处于可以下拉刷新状态
        if (this.isEnabled() && !this.mReturningToStart && !this.canChildScrollUp() 
                && !this.mRefreshing && !this.mNestedScrollInProgress) {
            int pointerIndex;    //整个事件发生过程,pointerIndex小于0时不拦截事件
            switch(action) {
            case 0://ACTION_DOWN
                this.setTargetOffsetTopAndBottom(this.mOriginalOffsetTop - this.mCircleView.getTop());
                this.mActivePointerId = ev.getPointerId(0);
                //记录是否正在进行拖拽,这是决定事件拦截的关键
                this.mIsBeingDragged = false;
                pointerIndex = ev.findPointerIndex(this.mActivePointerId);
                if (pointerIndex < 0) {
                    return false;
                }
                //初始化ACTION_DOWN事件的Y坐标
                this.mInitialDownY = ev.getY(pointerIndex);
                break;
            case 1://ACTION_UP
            case 3://ACTION_CANCEL
                //取消拖拽
                this.mIsBeingDragged = false;
                this.mActivePointerId = -1;
                break;
            case 2://ACTION_MOVE
                //mActivePointerId 用于在整个手势中跟踪一个单独的pointer
                if (this.mActivePointerId == -1) {
                    Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
                    return false;
                }
                pointerIndex = ev.findPointerIndex(this.mActivePointerId);
                if (pointerIndex < 0) {
                    return false;
                }
                float y = ev.getY(pointerIndex);
                this.startDragging(y);  //拖拽并展示圆形进度条
            case 4:
            case 5:
            default:
                break;
            case 6:
                this.onSecondaryPointerUp(ev);
            }

            return this.mIsBeingDragged;
        } else {
            return false;
        }
    }

注释写得我自己都看不懂啊,简而言之就是:当SwipeRefreshLayout处于拖拽状态即mIsBeingDragged为true的时候,拦截事件,否则事件不会被拦截。

总结一下事件拦截过程中各变量的作用(个人理解可能有误,所以个别变量含义会附上文档内英文注释):

  • mIsBeingDragged,记录拖拽状态,它的值为true的时候SwipeRefreshLayout拦截事件,否则不拦截。
  • mActivePointerId,与此事件中的特定指针数据索引关联的指针标识符, 标识符告诉您与数据关联的实际指针编号,用来标识当前手势开始以来的各个指针(因为可能有多点触摸事件)。简单地说就是一个pointer的id,如果它等于-1就不拦截当前事件。
    英文描述:the pointer identifier associated with a particular pointer data index in this event. The identifier tells you the actual pointer number associated with the data, accounting for individual pointers going up and down since the start of the current gesture.
  • pointerIndex,指针数据索引,常用于getX(int)/getY(int)方法来计算当前事件的坐标值。当它的值小于0,就代表不拦截事件。
    英文描述:The index of the pointer (for use with {@link #getX(int)} et al.), value -1 if there is no data available for that pointer identifier.

已经有各路大神对这些概念进行过深度剖析,这里给大家推荐一篇博客安卓自定义View进阶 - MotionEvent详解,有兴趣的可以阅读一下。

  • 场景3处理规则

场景3通常是多个滑动冲突嵌套的情形,这种情况我们只能将复杂的滑动冲突拆分成多个简单的滑动冲突,然后再针对每个滑动冲突寻找相对应的解决方案。

三、滑动冲突的解决方法

不管多复杂的滑动冲突,它们之间的区别仅仅是滑动的规则不同而已,所以我们需要找到一种不依赖具体规则的通用解决方法。针对场景1的滑动冲突,我们直接给出两种解决滑动冲突的方法:外部拦截法和内部拦截法。

  1. 外部拦截法

这种方法比较符合事件的分发机制,因为事件的传递总是由外向内进行的,即事件总是先传递给父容器,再由父容器分发给子元素。我们可以直接从父容器入手,重写它的onInterceptTouchEvent方法,在内部做相应的拦截,即如果父容器需要这个事件就拦截,onInterceptTouchEvent返回true,否则不拦截事件。

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept = false;
        float x = ev.getX();
        float y = ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                intercept = false;
                break;
            case MotionEvent.ACTION_MOVE:
                if (/*需要当前点击事件*/) {
                    intercept = true;
                } else {
                    intercept = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercept = false;
                break;
        }
        return intercept;
    }

这是外部拦截法的典型逻辑,我们只需要判断需要当前点击事件这个条件是否成立,其他不需要修改也不能修改。代码中有几点需要注意:

  • ACTION_DOWN事件不能被父容器拦截,因为父容器一旦拦截了ACTION_DOWN,那么这个事件序列中的后续的事件都将交给它处理,事件就没法传递给子元素了
  • ACTION_MOVE事件根据需求来判断是否需要被父容器拦截,需要拦截返回true,否则返回false
  • ACTION_UP事件必须要返回false,因为该事件本身没有太多意义

考虑一种情况,假设事件交给子元素处理,如果父容器在ACTION_UP时返回true,那么ACTION_UP事件就无法传递给子元素,这个时候子元素的onClick事件就无法触发。父容器一旦拦截任何一个事件,那么后续的事件都会交给它来处理,ACTION_UP作为最后一个事件也必定可以传递给父容器,即使onInterceptTouchEvent方法在ACTION_UP时返回了false。

  1. 内部拦截法

采用内部拦截法,意味着所有事件都要交给子元素处理,如果子元素需要这些事件就直接消耗,否则就交给父容器处理。我们需要重写子元素的dispatchTouchEvent方法,并且需要配合requestDisallowInterceptTouchEvent方法才能正常工作,伪代码如下:

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    float x = ev.getX();
    float y = ev.getY();
    switch (ev.getAction()){
        case MotionEvent.ACTION_DOWN:
            //不允许父容器拦截事件
            getParent().requestDisallowInterceptTouchEvent(true);
            break;
        case MotionEvent.ACTION_MOVE:
            if (true/*父容器需要点击事件*/) {
                getParent().requestDisallowInterceptTouchEvent(false);
            }
            break;
        case MotionEvent.ACTION_UP:
            break;
    }
    return super.dispatchTouchEvent(ev);
}

除了子元素需要处理之外,父容器也要默认拦截除了ACTION_DOWN以外的其他事件,这样当子元素调用getParent().requestDisallowInterceptTouchEvent(false)时,父容器才能继续拦截所需事件。父容器之所以不能拦截ACTION_DOWN事件,是因为一旦拦截ACTION_DOWN事件,那么后续事件都会默认交给父容器处理,这样内部拦截就无法起作用了。我们开始重写父容器的onInterceptTouchEvent方法:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    int action = ev.getAction();
    if (action == MotionEvent.ACTION_DOWN) {
        //不能拦截ACTION_DOWN事件
        return false;
    } else {
        //默认拦截其他事件
        return true;
    }
}

参考

《Android开发艺术探索》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,026评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,655评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,726评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,204评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,558评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,731评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,944评论 2 314
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,698评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,438评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,633评论 2 247
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,125评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,444评论 3 255
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,137评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,103评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,888评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,772评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,669评论 2 271

推荐阅读更多精彩内容