对Android之事件分发机制的理解及滑动冲突解决方案内外部拦截法

对Android之事件分发机制的理解

事件分发机制 中的三种方法dispatchTouchEvent()  onInterceptTouchEvent()  onTouchEvent()

dispatchTouchEvent(),事件的分发方法,一般由父布局调用,将点击事件传递到子View。返回true,则调用自身的onTouchEvent()消费事件;返回false,表示事件未被消费,事件会继续传递下去。

onInterceptTouchEvent(),是否拦截点击事件,如果返回true,表示拦截事件,调用自身onTouchEvent()处理点击事件;如果返回false,不拦截点击事件,则将点击事件传递到子View。

onTouchEvent(),处理点击事件的具体方法

顺序传递activity--viewgroup--view

点击activity调用activity.dispatchTouchEvent()方法,①返回true,代表事件被消费,不会往下传递,事件分发结束
②返回false表示事件未被消费,事件会继续传递下去;调用viewgroup.dispatchTouchEvent()方法,然后会调用viewgroup.oninterceptTouchEvent()方法,返回true,表示拦截事件,调用viewgroup自身onTouchEvent()处理点击事件;如果返回false,不拦截点击事件,则将点击事件传递到子View。调用子view.dispatchTouchEvent()方法

注意:

事件分发从外层向内层分发,事件处理从内层向外层处理

①View类和Activity类中仅仅有dispatchTouchEvent()和onTouchEvent()两个方法,并没有onInterceptTouchEvent()方法;上述三个方法在ViewGroup中都存在

②事件虽然是从Activity向底层View传递,在不考虑ViewGroup拦截事件的情况下,最先处理事件(onTouchEvent)的是底层View,如果事件未被底层View消费,事件将会回传给上层的ViewGroup处理(onTouchEvent),若所有的ViewGroup都未消费事件,事件最终会回传到Activity由它做最后的处理(onTouchEvent)。

③事件在传递过程中,如果被ViewGroup拦截(onInterceptTouchEvent),该ViewGroup会优先处理该事件。

④底层的View或者ViewGroup如果将事件消费了,上层的ViewGroup的OnTouchListener、OnTouchEvetn,OnClickListener都不会被调用。

⑤在同一个View或者ViewGroup的事件处理中,OnTouchListener优先级最高,OnTouchEvent其次,OnClickListener最低。

滑动冲突解决方案内外部拦截法及原理

一 滑动冲突的两种解决方式

1.1 外部拦截法 主要代码重写父容器的事件拦截方法

 private int mLastXIntercept;
    private int mLastYIntercept;
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
 
        int x = (int) event.getX();
        int y = (int) event.getY();
        final int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mLastXIntercept = (int) event.getX();
                mLastYIntercept = (int) event.getY();
                break;
            case MotionEvent.ACTION_MOVE:
      
                if (needIntercept) {//判断是否需要拦截的条件
                    return true;
                }
 
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
 
 
        return super.onInterceptTouchEvent(event);
    }

1.2 内部拦截法 主要代码重写子容器的事件分发方法和父容器的事件拦截方法

     1.父容器事件拦截

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


     2.子容器事件分发

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

二 何为滑动冲突,何时会产生滑动冲突?

系统不知将滑动事件交给谁来消费

1.不同方向的滑动冲突

 ViewPager里面嵌套了一个ListView,手指在屏幕上面滑动的时候,系统不知道把这个滑动给ViewPager还是ListView。当然ViewPager源码对滑动方向做了判断,在左右滑动的时候让ViewPager直接拦截事件自己消费,其它情况不拦截交给子View处理,所以在我们日常使用的时候才不会产生冲突。

2.同一方向的滑动冲突

比如ScrollView里面嵌套了一个ScrollView,都是竖直方向滑动。此时该交给哪个ScrollView来消费事件呢?

三 实际应用

利用ViewPager里面嵌套ListView来做解析。由于ViewPager本身做了滑动冲突的处理,这里为了演示,我们重写了ViewPager--》BadViewPager

 场景: activity里面放一个ViewPager,ViewPager每页里面为一个为ListView或者TexView

activity的代码,主要就是设置ViewPager每页的为一个一个ListView或者TextView(根据initData里面的参数决定)

   

private BadViewPager mViewPager;
    private List<View> mViews;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_viewpager);
        initViews();
        initData(false);
    }
 
    protected void initViews() {
 
        mViewPager = findViewById(R.id.view_pager);
        mViews = new ArrayList<>();
    }
 
    protected void initData(final boolean isListView) {
   
        Flowable.just("view1", "view2", "view3", "view4").subscribe(new Consumer<String>() {
            @Override
            public void accept(String s) throws Exception {
                //当前View
                View view;
                if (isListView) {
                      MyListView listView = new MyListView(MainActivity.this);
                    final ArrayList<String> datas = new ArrayList<>();
                    Flowable.range(0, 70).subscribe(new Consumer<Integer>() {
                        @Override
                        public void accept(Integer integer) throws Exception {
                            datas.add("data" + integer);
                        }
                    });
                    ArrayAdapter<String> adapter = new ArrayAdapter<>
                            (MainActivity.this, android.R.layout.simple_list_item_1, datas);
                    listView.setAdapter(adapter);
                    view = listView;
                } else {
                    //初始化TextView
                    TextView textView = new TextView(MainActivity.this);
                    textView.setGravity(Gravity.CENTER);
                    textView.setText(s);
//                    textView.setOnClickListener(new View.OnClickListener() {
//                        @Override
//                        public void onClick(View v) {
//
//                        }
//                    });
//                    Button textView = new Button(MainActivity.this);
//                    textView.setText(s);
                    view = textView;
                }
                //将当前View添加到ViewPager的ViewList中去
                mViews.add(view);
            }
        });
        mViewPager.setAdapter(new BasePagerAdapter(mViews));
    }


这里用了RxJava

implementation 'com.squareup.retrofit2:adapter-rxjava2:2.4.0'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'

activity_viewpager很简单

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">
<com.example.learn_dispatch.BadViewPager
    android:id="@+id/view_pager"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>
 
</LinearLayout>

MyListView:

public class MyListView extends ListView {
 
    public MyListView(Context context) {
        super(context);
    }
 
    public MyListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
}

BasePagerAdapter:
 

import android.view.View;
 
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
 
import java.util.ArrayList;
import java.util.List;
 
public class BasePagerAdapter extends PagerAdapter {
 
    private List<View> views = new ArrayList<View>();
 
    public BasePagerAdapter(List<View> views) {
        this.views = views;
    }
 
    @Override
    public boolean isViewFromObject(View arg0,Object arg1){
        return arg0 == arg1;
    }
 
    @Override
    public int getCount(){
        return views.size ();
    }
 
    @Override
    public void destroyItem(View container,int position,Object object){
        ((ViewPager) container).removeView (views.get (position));
    }
 
    @Override
    public Object instantiateItem(View container,int position){
        ((ViewPager) container).addView (views.get (position));
        return views.get (position);
    }
}

3.1 场景一ViewPager的每页放的是TextView,其onInterceptTouchEvent事件拦截方法返回false不拦截

重写ViewPager的onInterceptTouchEvent事件拦截方法返回false让其不拦截

public class BadViewPager extends ViewPager {
 
 
    public BadViewPager(@NonNull Context context) {
        super(context);
    }
 
    public BadViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        return  false;
    }
}


这种情况ViewPager是否可正常滑动呢?答案是可以

在前面章节的事件分发当中,讲述了事件分发的整个流程。滑动事件是由上层ViewGroup一层层分发到最外面的View,如果没有消费再一层层返回给父级。显然这里,TextView没有消费事件将滑动事件交给其父ViewPager处理。

那么为何没有消费呢?我们在前面的讲述了最终View是否消费取决于其onTouchEvent,是否记得在Android事件分发流程源码解析二及总结分析View的onTouchEvent源码这样一段话呢,只要CLICKABLE和LONG_CLICKABLE其中一个为true就会消费事件。通过查看源码我们知道TextView的CLICKABLE和LONG_CLICKABLE都为false,所以没有消费事件。这也就是ViewPager里面嵌套TextView,在ViewPager事件拦截为false的时候,能够正常滑动的原因。

1.如果我们将TextView换为Button呢?大家可将上面activity里面代码注释掉的部分取消试试。
结果是ViewPager不能滑动,为何不能呢?显然是Button消费了事件,没有交给ViewPager处理。我们知道Button的CLICKABLE为true默认是可点击的,所以消费了事件。

2.比较有趣的是如果我们里面嵌套的还是一个TexView,但是给TexView添加了一个点击监听,是否可以正常滑动呢?
通过测试ViewPager此时又不能正常滑动了,其实我们知晓了上面的事件分发原理,很容易推断出TextView消费了事件。肯定是我们在给TextView设置点击监听的时候改变了其是否可点击的值,我们查看其设置监听的源码

 public void setOnClickListener(@Nullable OnClickListener l) {
        if (!isClickable()) {
            setClickable(true);
        }
        getListenerInfo().mOnClickListener = l;
    }

3.2 场景二ViewPager的每页放的是ListView

ViewPager的onInterceptTouchEvent返回false,能否正常左右滑动呢?

其实通过上面场景一的推断很容易得出结论。由于ListView把事件消费了,此时ViewPager不能左右滑动,LisView可以正常上下滑动

那么如何来解决呢?此时就是需要我们的滑动冲突解决方案了

1.外部拦截法
举例中默认ViewPager的拦截onInterceptTouchEvent方法返回为false,不拦截。事件交给了ListView,ListView不消费才会返回给ViewPager。

解决思路:在ViewPager分发事件的时候,让ViewPager先根据自己的情况进行判断,如果是自己的滑动手势就将事件拦截了自己处理,不是才交给ListView去处理。实际上我们查看ViewPager的源码也是这么处理的。

我们重写ViewPager的拦截方法onInterceptTouchEvent,横向滑动时拦截事件,否则不做处理按正常流程分发。

public class BadViewPager extends ViewPager {
 
 
    public BadViewPager(@NonNull Context context) {
        super(context);
    }
 
    public BadViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }
 
 
    private int mLastXIntercept;
    private int mLastYIntercept;
//    外部拦截法
    @Override
 
    public boolean onInterceptTouchEvent(MotionEvent event) {
 
        int x = (int) event.getX();
        int y = (int) event.getY();
        final int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mLastXIntercept = (int) event.getX();
                mLastYIntercept = (int) event.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                //横坐标位移增量
                int deltaX = x - mLastXIntercept;
                //纵坐标位移增量
                int deltaY = y - mLastYIntercept;
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    //左后滑动 拦截
                    return true;
                }
                break;
            case MotionEvent.ACTION_UP:
 
                break;
        }
 
 
        return super.onInterceptTouchEvent(event);
    }
}

通过测试,这确实就解决了自己的ViewPager和LisView的滑动冲突,两者都可正常滑动

2.内部拦截法
思路:ViewPager默认拦截事件,ListView判断是自己的手势就做出请求父级不要拦截我的请求->正常分发,否则就请求拦截,此时是否拦截受到父级的onInterceptTouchEven方法t影响

重写ListView的事件分发dispatchTouchEvent方法,判断水平方向滑动时请求父级拦截我,其它情况请求不要拦截我。

public class MyListView extends ListView {
 
    public MyListView(Context context) {
        super(context);
    }
 
    public MyListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }
 
 
    private int mLastX;
    private int mLastY;
 
    @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);
    }
 
 
}

重写ViewPager的事件拦截方法,让其默认拦截

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

测试发现这样ListView并不能正常滑动,被拦截了。问题出在哪里?

我们先把正确的代码写出来,修改ViewPager的拦截事件方法在down的时候返回false不拦截,其它返回true。这样就ViewPager和ListView就能正常滑动了

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

通过代码发现外部拦截法相比内部拦截法要简单很多也容易理解。解决事件冲突,总结来说就是这两种方式,实际掌握事件分发机制后,相信大家能够灵活运用

内部拦截父ViewGroup的onInterceptTouchEvent拦截方法,为何要这么写?

四 ViewPager嵌套ListView的滑动冲突,内部拦截法为何ViewPager的onInterceptTouchEvent要做判断而不是直接返回true?
 

我们重温下Android事件分发流程源码解析一中ViewGroup事件分发方法的源码。注意这个ViewGroup对应到我们例子的ViewPager

class:ViewGroup:
 
   @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        
        ......//省略
        boolean handled = false;
        if (onFilterTouchEventForSecurity(ev)) {     //判断屏幕是否隐藏等
                
            final int action = ev.getAction();
            final int actionMasked = action & MotionEvent.ACTION_MASK;
 
            // Handle an initial down.
            //该方法在事件冲突的内部拦截法当中有重要作用,这里暂不解析
            if (actionMasked == MotionEvent.ACTION_DOWN) {
                cancelAndClearTouchTargets(ev);
                resetTouchState();
            }
            //**重点 intercepted就是判断该ViewGroup是否需要直接处理事件的标记
            // Check for interception.
            final boolean intercepted;
            if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
                final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
            
                if (!disallowIntercept) {
//    用内部拦截法时,down事件的时候,我们在子类的requestDisallowInterceptTouchEvent传入了true,就是希望父类不要拦截我,从而不进入这个判断。
//但是坑的是,在down的时候,在上面的if判断中对disallowIntercept进行了重置为false,不受子类requestDisallowInterceptTouchEvent的影响了,也就是说肯定会进入这个判断
                    intercepted = onInterceptTouchEvent(ev);
                    ev.setAction(action); // restore action in case it was changed
                } else {
                     
                    intercepted = false;
                }
            } else {
                // There are no touch targets and this action is not an initial down
                // so this view group continues to intercept touches.
                intercepted = true;
            }
}

ViewGroup的事件拦截方法第一步就是给intercpted赋值,当intercepted为true时,就不会通过for循环去找子View分发事件。我们看看在上述源码里面发现在ACTION_DOWN的时候,对disallowIntercept进行了赋值->true,不受子类方法requestDisallowInterceptTouchEvent(请求父类不要拦截我)的影响。

那么本来我们希望的是在action_down的时候希望父类不要拦截我,不进入if (!disallowIntercept) {}这个判断从而让intercepted为false。现在进入if (!disallowIntercept) {}了这个判断-> intercepted = onInterceptTouchEvent(ev),所以我们正确的方法是应该在父类ViewPager的onInterceptTouchEvent加一个判断,DOWN时返回false,其它true。

4.1 ACTION_CANCEL什么时候调用?

我们在事件分发Android事件分发流程源码解析一当中讲述了DOWN、MOVE、UP、CANCEL四个方法的调用时机。其中CANCEL在被上层事件拦截的时候调用,看看源码分析其原因

ViewGroup:
 
 @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {  
 
//...省略
 // Dispatch to touch targets.
            if (mFirstTouchTarget == null) {
                // No touch targets so treat this as an ordinary view.
                handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
            } else {
                // Dispatch to touch targets, excluding the new touch target if we already
                // dispatched to it.  Cancel touch targets if necessary.
                TouchTarget predecessor = null;
                TouchTarget target = mFirstTouchTarget;
                while (target != null) {
                    final TouchTarget next = target.next;
                    if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
                        handled = true;
                    } else {
                        
//    move时进入这里
                        final boolean cancelChild = resetCancelNextUpFlag(target.child)
                                || intercepted;
                        if (dispatchTransformedTouchEvent(ev, cancelChild,
                                target.child, target.pointerIdBits)) {
                            handled = true;
                        }
                        if (cancelChild) {
                            if (predecessor == null) {
                                mFirstTouchTarget = next;
                            } else {
                                predecessor.next = next;
                            }
                            target.recycle();
                            target = next;
                            continue;
                        }
                    }
                    predecessor = target;
                    target = next;
                }
            }
}
//...省略


以上是ViewGroup事件分发方法的部分源码,我们在Android事件分发流程源码解析一,总结了手指从第一次触摸DOWN到滑动MOVE时如何命中目标。此刻的场景是命中目标后手指在屏幕上面MOVE,mFirstTouchTarget此时不为空。会调用这个 if (dispatchTransformedTouchEvent(ev, cancelChild,target.child, target.pointerIdBits)) {}判断,并且其中的cancelChild此时为true(因为我们的ListView在MOVE时,根据滑动手势左右滑动时要求父类拦截我->intercepted为true)。那么我们来看看dispatchTransformedTouchEvent方法cancelChild传true时候的源码:

private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
            View child, int desiredPointerIdBits) {
        final boolean handled;
 
        // Canceling motions is a special case.  We don't need to perform any transformations
        // or filtering.  The important part is the action, not the contents.
        final int oldAction = event.getAction();
        if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
            //调用ACTION_CANCEL方法
            event.setAction(MotionEvent.ACTION_CANCEL);
            if (child == null) {
                handled = super.dispatchTouchEvent(event);
            } else {
                handled = child.dispatchTouchEvent(event);
            }
            event.setAction(oldAction);
            return handled;
        }
//。。省略
}


很容易看出,在cancel传入为true的时候,进入判断调用ACTION_CANCEL方法。这也就是为何在ACTION_CANCEL方法在被上级拦截时调用的原因了。

4.2 ListView被上层拦截了怎么将事件交还给ViewPager

我们接着刚刚的分析步骤dispatchTransformedTouchEvent方法会进入handled = child.dispatchTouchEvent(event),其child为ListView将事件是否消费交给了ListView处理。(记住此时条件在用户down并且move后触发了ViewPager的拦截方法导致intercepted为true,此时命中了消费事件的目标ListView,即mFirstTouchTarget不为空)我们接着ViewGroup的事件方法走,此时走到了 if (cancelChild) {}我们注意进入该判断后会调用 mFirstTouchTarget = next方法,会将mFirstTouchTarget置为空。

此时ViewGroup的dispatchTouchEvent事件分发方法走完了一次,随着手指的滑动再次进入dispatchTouchEvent(一定要记住MOVE事件会多次调用),很容易看出最终会进入

 调用dispatchTransformedTouchEvent方法,并且child传null。我们通过查看dispatchTransformedTouchEvent的源码,得知当child为null时,会调用其父级的dispatchTouchEvent方法。这样就将事件交回了ViewPager

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值