Android滑动事件冲突

2020-04-11  本文已影响0人  echoSuny

相信在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);
    }
}

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

上一篇 下一篇

猜你喜欢

热点阅读