美文网首页
Android滑动事件冲突

Android滑动事件冲突

作者: echoSuny | 来源:发表于2020-04-11 18:14 被阅读0次

    相信在Android开发中,基本都遇到过滑动冲突,可以说是比较常见的一类问题,也是比较让人头疼的一类问题。话不多说先根据一个小的例子开始引出整个事件分发机制的流程,以及如何解决事件冲突。


    界面

    界面很简单,就是一个按钮,然后分别设置OnClickListener和OnTouchListener并打印日志:

    Button button = (Button) findViewById(R.id.btn);
    
            button.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    Log.d(TAG, "onClick: ");
                }
            });
    
            button.setOnTouchListener(new View.OnTouchListener() {
                @Override
                public boolean onTouch(View v, MotionEvent event) {
                    Log.d(TAG, "onTouch: ");
                    return false;
                }
            });
    
    logcat1

    可以看到当OnTouchListener的返回值为false的时候,onTouch先打印(打印了两次是因为一次是down事件,一次是up事件),onClick后打印。那么修改一下onTouch的返回值为true,看一下打印结果是什么


    logcat2

    可以看到只输出了onTouch,onClick没有了。下面就要从源码的角度来了解为什么会出现此情况。

    View.java
    public boolean dispatchTouchEvent(MotionEvent event) {
            ......
            // 安全过滤
            if (onFilterTouchEventForSecurity(event)) {
                if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
                    result = true;
                }
    
                ListenerInfo li = mListenerInfo;
                // 4个同时满足才会进入if语句
                if (li != null && li.mOnTouchListener != null
                        && (mViewFlags & ENABLED_MASK) == ENABLED
                        && li.mOnTouchListener.onTouch(this, event)) {
                    result = true;
                }
    
                if (!result && onTouchEvent(event)) {
                    result = true;
                }
            }
            ......
    
            return result;
        }
    

    可以看到进入if语句的条件还是比较苛刻的,需要4个同时满足才可以。那么就逐一分析四个条件:
    (1) li != null
    可以看到li其实就是ListenerInfo,且是由mListenerInfo赋值的,那么只需要看mListenerInfo是不是为空就可以了。那么点击button.setOnTouchListener()这一行代码我们可以看到

    View.java
    
    public void setOnTouchListener(OnTouchListener l) {
            getListenerInfo().mOnTouchListener = l;
        }
    
    ListenerInfo getListenerInfo() {
            if (mListenerInfo != null) {
                return mListenerInfo;
            }
            mListenerInfo = new ListenerInfo();
            return mListenerInfo;
        }
    

    可以看到在我们给按钮设置OnTouchListener的时候mListenerInfo就已经初始化了,并且把我们传入的OnTouchListener给保存到ListenerInfo这个对象中了。那么li != null是成立的。
    (2)li.mOnTouchListener != null
    根据(1)可以知道OnTouchListener是由我们自己传入的,所以肯定不为空。因此这个条件也成立。
    (3)(mViewFlags & ENABLED_MASK) == ENABLED
    这个是检查控件是不是enable状态的,显而易见,我们的按钮是enable的,不然是不能点击的。
    (4)li.mOnTouchListener.onTouch(this, event)
    这个条件的值则是由我们onTouch()方法的返回值决定的。第一次我们是返回false的,第二次我们返回的是true。
    综上所述,最关键的条件就是setOnTouchListener时候onTouch()方法的返回值。当我们返回false的时候,是不会进if语句的,也就是说result的值是false。那下面的if (!result && onTouchEvent(event))中的!result就为true,那么是否进if就需要看onTouchEvent()的返回值了:

    View.java
    
    public boolean onTouchEvent(MotionEvent event) {
            ......
            if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
                switch (action) {
                    case MotionEvent.ACTION_UP:
                      ......
                                if (!focusTaken) {
                                    if (mPerformClick == null) {
                                        mPerformClick = new PerformClick();
                                    }
                                    if (!post(mPerformClick)) {
                                        performClickInternal();
                                    }
                                }
                            }
                     ......
                        break;
    }
    
     public boolean performClick() {
            notifyAutofillManagerOnClick();
            final boolean result;
            final ListenerInfo li = mListenerInfo;
            // 调用了OnClickListener
            if (li != null && li.mOnClickListener != null) {
                playSoundEffect(SoundEffectConstants.CLICK);
                li.mOnClickListener.onClick(this);
                result = true;
            } else {
                result = false;
            }
            sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
            notifyEnterOrExitForAutoFillIfNeeded(true);
            return result;
        }
    

    其实在测试的时候当我们按着不松手,你会发现只有一个onTouch的log出现,当你松手的时候,onClick的log才会打印。所以看源码的时候就可以直接在onTouchEvent的UP事件找就可以了。这是一个小技巧。回归正题,可以看到在performClick()方法中调用了onClick()方法,调用套路跟上面onTouch()一样,故不作重复分析。
    总结一下就是onTouch()返回false最终导致调用了onTouchEvent(),从而又调用了onClick()。反之就会进入if语句把result置为true,表示此次事件被消费了。也就是常说的onTouch(),onTouchEvent()和onClick()的调用顺序。


    调用顺序

    有了上面的基本了解之后,下面就可以更方便我们了解整个事件的分发流程。老规矩,先上代码:

    public class MyViewPager extends ViewPager {
        
        public MyViewPager(@NonNull Context context) {
            super(context);
        }
    
        public MyViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
        }
    
        @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            return true;
        }
    }
    
    public class MyListView extends ListView {
        
        public MyListView(Context context) {
            super(context);
        }
    
        public MyListView(Context context, AttributeSet attrs) {
            super(context, attrs);
        }
    
        public MyListView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
        }
    }
    

    就是继承了ViewPager并重写了onInterceptTouchEvent()并返回了true,代表我们要拦截所有的事件。MyListView则什么都没做。


    返回true

    可以看到返回true的时候,只能左右滑动,ListView不能滑动。
    下面看一下分别改为false和删除掉拦截方法之后效果:


    返回false
    删除拦截方法
    返回false的时候ViewPager就不能滑动了。不重写拦截方法之后就恢复正常了。

    其实本来ViewPager嵌套ListView是没有冲突的,也就是第三种情况。这是因为ViewPager内部做了处理。返回true的时候可以理解,那为什么返回false的时候也会出现冲突呢?这就需要从源码出发来了解一下到底是怎么回事。
    下面先看一张图:



    在分析事件分发之前首先要了解的是我们的分发顺序是Activity->ViewGroup->View,其实一个完整的点击是包括一个DOWN事件,n个MOVE事件以及UP事件。那么首先来分析一次正常的事件分发流程:
    DOWN事件:
    Activity.java
    public boolean dispatchTouchEvent(MotionEvent ev) {
            if (ev.getAction() == MotionEvent.ACTION_DOWN) {
                onUserInteraction();
            }
            // 调用window,也就是PhoneWindow的方法
            if (getWindow().superDispatchTouchEvent(ev)) {
                return true;
            }
            return onTouchEvent(ev);
        }
    
    PhoneWindow.java
    @Override
        public boolean superDispatchTouchEvent(MotionEvent event) {
           //又调用了DecorView   
            return mDecor.superDispatchTouchEvent(event);
        }
    
    DecorView.java
    public boolean superDispatchTouchEvent(MotionEvent event) {
              // DecorView又调用了父类的方法
            return super.dispatchTouchEvent(event);
        }
    

    经过层层调用最终会调到ViewGroup的dispatchTouchEvent()方法。这个方法很长,有两百多行,我们只选取必要代码来分析整个流程。

    ViewGroup.java
    public boolean dispatchTouchEvent(MotionEvent ev) {
            ......
           //  (1)标志是否处理了事件
            boolean handled = false;
            //  (2)和View.java一样 先进行安全过滤
            if (onFilterTouchEventForSecurity(ev)) {
                final int action = ev.getAction();
                final int actionMasked = action & MotionEvent.ACTION_MASK;
                // (3)如果是down事件 做重置动作
                if (actionMasked == MotionEvent.ACTION_DOWN) {
                    cancelAndClearTouchTargets(ev);
                    resetTouchState();
                }
                //  (4)标志是否拦截
                final boolean intercepted;
                if (actionMasked == MotionEvent.ACTION_DOWN
                        || mFirstTouchTarget != null) {
                    //  (5)子view不设置的话默认为false 
                    final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
                   // (6) 是否拦截
                    if (!disallowIntercept) {
                        intercepted = onInterceptTouchEvent(ev);
                        ev.setAction(action); // restore action in case it was changed
                    } else {
                        intercepted = false;
                    }
                } else {
                    intercepted = true;
                }
               // (7) 是否是cancel事件
                final boolean canceled = resetCancelNextUpFlag(this)
                        || actionMasked == MotionEvent.ACTION_CANCEL;
    
                TouchTarget newTouchTarget = null;
                //  (8) 是否分发给了newTouchTarget
                boolean alreadyDispatchedToNewTouchTarget = false;
                // (9) 往下层分发
                if (!canceled && !intercepted) {
                    View childWithAccessibilityFocus = ev.isTargetAccessibilityFocus()
                            ? findChildWithAccessibilityFocus() : null;
                      // (10)
                    if (actionMasked == MotionEvent.ACTION_DOWN
                            || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
                            || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
                        final int actionIndex = ev.getActionIndex(); // always 0 for down
                        final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
                                : TouchTarget.ALL_POINTER_IDS;
                        removePointersFromTouchTargets(idBitsToAssign);
    
                        final int childrenCount = mChildrenCount;
                        // (11)
                        if (newTouchTarget == null && childrenCount != 0) {
                            final float x = ev.getX(actionIndex);
                            final float y = ev.getY(actionIndex);
                            //  (12) 根据Z轴值的大小把所有的view存入list当中
                            final ArrayList<View> preorderedList = buildTouchDispatchChildList();
                            final boolean customOrder = preorderedList == null
                                    && isChildrenDrawingOrderEnabled();
                            final View[] children = mChildren;
                            for (int i = childrenCount - 1; i >= 0; i--) {
                                ......
                                // (13)view是否能接收事件(例如是否可见,是否在执行动画) 且触摸的点是否在view的范围内
                                if (!child.canReceivePointerEvents()
                                        || !isTransformedTouchPointInView(x, y, child, null)) {
                                    ev.setTargetAccessibilityFocus(false);
                                    continue;
                                }
                                ......
                                  // (14)
                                newTouchTarget = getTouchTarget(child);
                                if (newTouchTarget != null) {
                                    newTouchTarget.pointerIdBits |= idBitsToAssign;
                                    break;
                                }
                                // (15)分发事件给子view
                                if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                                    ......
                                    // (16)
                                    newTouchTarget = addTouchTarget(child, idBitsToAssign);
                                    // (17)
                                    alreadyDispatchedToNewTouchTarget = true;
                                    break;
                                }
                                ev.setTargetAccessibilityFocus(false);
                            }
                            if (preorderedList != null) preorderedList.clear();
                        }
                      ......
                    }
                }
                // (18)
                if (mFirstTouchTarget == null) {
                    handled = dispatchTransformedTouchEvent(ev, canceled, null,
                            TouchTarget.ALL_POINTER_IDS);
                } else {
                    // (19)
                    TouchTarget predecessor = null;
                    TouchTarget target = mFirstTouchTarget;
                    while (target != null) {
                        // next == null
                        final TouchTarget next = target.next;
                        // (20)
                        if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
                            handled = true;
                        } else {
                            // (21)
                            final boolean cancelChild = resetCancelNextUpFlag(target.child)
                                    || intercepted;
                            if (dispatchTransformedTouchEvent(ev, cancelChild,
                                    target.child, target.pointerIdBits)) {
                                handled = true;
                            }
                           ......
                        }
                        predecessor = target;
                        // target置空
                        target = next;
                    }
                }
            ......
            return handled;
        }
    

    可以看到由于注释5处disallowIntercept为false,那么必定会进入下面的if。那么就会调用自身的拦截方法onInterceptTouchEvent()。默认onInterceptTouchEvent()是返回false的,那么intercepted就是false。由于此次只分析DOWN事件,显然canceled也为false,那么就会进入注释9的if语句。同时会进入注释10的if语句。注释11处的newTouchTarget是在注释8的上面声明了一个空的临时变量,且还没有赋值。childrenCount代表你的布局有多少个子view,很显然我们平常写的布局都会有很多子view,那么就会进入注释11处的if语句。接着就要看是否满足注释13,反之就跳出这次for循环,继续找符合条件的view。那么看一下注释14处:

     private TouchTarget getTouchTarget(@NonNull View child) {
            for (TouchTarget target = mFirstTouchTarget; target != null; target = target.next) {
                if (target.child == child) {
                    return target;
                }
            }
            return null;
        }
    

    由于mFirstTouchTarget也为null,故最终注释14的地方newTouchTarget依然为null,那么跳过。接着就是注释15了,此方法需要重点分析:

    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
                View child, int desiredPointerIdBits) {
            final boolean handled;
            final int oldAction = event.getAction();
              // cancel传入的是false 并且是down事件 故不会进入
            if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
                event.setAction(MotionEvent.ACTION_CANCEL);
                if (child == null) {
                    handled = super.dispatchTouchEvent(event);
                } else {
                    handled = child.dispatchTouchEvent(event);
                }
                event.setAction(oldAction);
                return handled;
            }
    
            // 很明显child不为null 故会走else语句
            if (child == null) {
                handled = super.dispatchTouchEvent(transformedEvent);
            } else {
                final float offsetX = mScrollX - child.mLeft;
                final float offsetY = mScrollY - child.mTop;
                transformedEvent.offsetLocation(offsetX, offsetY);
                if (! child.hasIdentityMatrix()) {
                    transformedEvent.transform(child.getInverseMatrix());
                }
                // 调用子view的分发方法
                handled = child.dispatchTouchEvent(transformedEvent);
            }
            return handled;
        }
    

    可以看到最后一行注释,最终这行代码会调到View.java中。至此就跟最开始分析onTouch()以及onClick()接上了。也就是说只要Button消费了事件,那么handled就为true,那么就会进入注释15处的if语句。接下来注释16和17就会执行。17很好理解,那么来看一下16干了什么:

    private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
            // 根据响应了事件的child生成一个TouchTarget
            final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
             // next置为null
            target.next = mFirstTouchTarget;
            // mFirstTouchTarget赋值 
            mFirstTouchTarget = target;
            return target;
        }
    

    也就是说经过16的话newTouchTarget == mFirstTouchTarget且都不为null了。那么就下来就会进入19。很显然由于16和17的存在就直接会进入20的if语句且把handled置为true。最后又把next赋给target。由于在分析16的时候得知next为null,故target此时也为null,也就是说这个while循环只会循环一次。至此一次完整的DOWN事件就结束了。
    那接下来的MOVE和UP事件就简单了。由于mFirstTouchTarget在DOWN事件时被赋值,所以照样会进入注释4。接着就会往下走,不同的是直接走到21处的dispatchTransformedTouchEvent()方法。这个方法之前已经分析过了。这也就说明了如果一个View在响应了DOWN事件后,之后的MOVE和UP事件就会直接给到这个View处理。

    事件分发流程简图

    事件冲突的情况不外乎下面两种:


    内外方向不一致
    内外方向一致

    但是无论是哪种冲突都有两个解决办法:内部拦截和外部拦截。
    内部拦截法:
    需要注意的是内部拦截需要使用在子View中使用函数requestDisallowInterceptTouchEvent(boolean b),
    传入true表示父View不要对自己进行拦截,false则表示父View可以拦截,具体拦截与否则需要看onInterceptTouchEvent()的返回值。
    首先在MyListView内加入如下代码:

    private int lastX, lastY;
    
        @Override
        public boolean dispatchTouchEvent(MotionEvent ev) {
            int x = (int) ev.getX();
            int y = (int) ev.getY();
    
            switch (ev.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    getParent().requestDisallowInterceptTouchEvent(true);
                    break;
                case MotionEvent.ACTION_MOVE:
                    int dx = x - lastX;
                    int dy = y - lastY;
                    if (Math.abs(dx) > Math.abs(dy)) {
                        getParent().requestDisallowInterceptTouchEvent(false);
                    }
                    break;
                case MotionEvent.ACTION_UP:
    
                    break;
                case MotionEvent.ACTION_CANCEL:
                    Log.d("----->", "ACTION_CANCEL");
                    break;
            }
            lastX = x;
            lastY = y;
    
            return super.dispatchTouchEvent(ev);
        }
    

    MyViewPager的onInterceptTouchEvent()返回true

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

    可以看到在MyListView的down事件中我们调用了getParent().requestDisallowInterceptTouchEvent()并传入true,表示请求MyViewPager不要进行拦截,那么根据上面的流程分析则不会进入注释6处的 !disallowIntercept 就为false,那么就会进入else语句。下面就和分析正常流程一样了。运行效果如下:


    第一次解决冲突

    奇(sun)了怪(dog)了!!!还是没能能解决冲突。这是为什么呢?其实这里存在的一个坑是因为在down事件的时候会首先经过注释3处会把所有的标记都重置,导致了走到5的时候disallowIntercept依然是false,那么就会进入此处的if语句,而MyViewPager的拦截方法返回的是true,那么事件就无法向下传递了,后续的move和up则都不会传递。也就是说只要是down事件,那么必定会进入5的if。那么我们则需要在MyViewPager中在down事件的时候返回false就可以了。下面是修改之后代码:

    @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            if (ev.getAction() == MotionEvent.ACTION_DOWN){
                super.onInterceptTouchEvent(ev);
                return false;
            }
            return true;
        }
    
    修改之后的效果

    可以看到冲突已经解决了。这是因为在down的时候返回了false,那么intercepted在5的if中就会被置为false,那么剩下的就和正常的流程一样会走完整个down事件,直到手指上下竖直滑动则会重新进入ViewGroup的dispatchTouchEvent。接下来会直接走到6的else处。这是因为由于先前在MyListView的down事件把disallowIntercept设为了true,ViewGroup的dispatchTouchEvent是先于View的dispatchTouchEvent执行的。整个连续的流程是这样的:ViewGroup#dispatchTouchEvent#DOWN -> View. dispatchTouchEvent#DOWN -> View设置ViewGroup的disallowIntercept为true
    ->ViewGroup#dispatchTouchEvent#MOVE ->View. dispatchTouchEvent#MOVE,所以走else把intercepted设为了false,那么下面直接走到21处的if语句最终分发move事件给子View。当手指横向滑动的时候,我们设置了getParent().requestDisallowInterceptTouchEvent(false),就会重新的进入6的if当中,则会调用
    onInterceptTouchEvent()。由于move事件我们返回的true,那么就会拦截横向滑动。接着就会走到21处。因为intercepted为true,则cancled为true,那么就会进入dispatchTransformedTouchEvent()的这样一段代码:

    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
                View child, int desiredPointerIdBits) {
            final boolean handled;
            // 保存旧的action 此时oldAction = ACTION_MOVE
            final int oldAction = event.getAction();
            // cancel 为true 进入
            if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
               // 设置ACTION_CANCEL给event
                event.setAction(MotionEvent.ACTION_CANCEL);
                if (child == null) {
                    handled = super.dispatchTouchEvent(event);
                } else {
                    // child不等于null 会走这里 把action为ACTION_CANCEL的event传给子view
                    handled = child.dispatchTouchEvent(event);
                }
                // 重新设置ACTION_MOVE给event
                event.setAction(oldAction);
                return handled;
            }
    

    根据上面的方法的值这里会发生事件的转换。原本子View的move会变成cancel,也就是说如果你在子View也就是MyListView中增加了 MotionEvent.ACTION_CANCEL,则会被调用。而move事件则交给父View处理。


    ACTION_CANCEL被调用

    外部拦截法则主要是在父View的onInterceptEvent()方法中处理。修改后的代码如下:

    MyViewPager.java
    @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            int x = (int) ev.getX();
            int y = (int) ev.getY();
            switch (ev.getAction()) {
                case MotionEvent.ACTION_DOWN:
    
                    break;
                case MotionEvent.ACTION_MOVE:
                    int dx = x - lastX;
                    int dy = y - lastY;
                    if (Math.abs(dx) > Math.abs(dy)) {
                        return true;
                    }
                    break;
            }
                    lastX = x;
            lastY = y;
            return super.onInterceptTouchEvent(ev);
        }
    
    public class MyListView extends ListView {
    
        public MyListView(Context context) {
            super(context);
        }
    
        public MyListView(Context context, AttributeSet attrs) {
            super(context, attrs);
        }
    
        public MyListView(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
        }
    }
    

    效果就不进行展示了,和上面的内部拦截是一样的。

    相关文章

      网友评论

          本文标题:Android滑动事件冲突

          本文链接:https://www.haomeiwen.com/subject/vvlcmhtx.html