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

 

Android事件分发流程源码解析一

Android事件分发流程源码解析二及总结

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

在前面阐述了事件分发的整个流程,那么如何来解决常见的滑动冲突呢,本文首先总结解决滑动冲突的方式,再结合实际例子阐述如何运用。

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

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 内部拦截法 主要代码重写子容器的事件分发方法和父容器的事件拦截方法

  • 父容器事件拦截
  @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;

    }
  • 子容器事件分发
  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);
    }

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

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

不同方向的滑动冲突

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

同一方向的滑动冲突

比如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的时候,能够正常滑动的原因。

  • 如果我们将TextView换为Button呢?大家可将上面activity里面代码注释掉的部分取消试试。

结果是ViewPager不能滑动,为何不能呢?显然是Button消费了事件,没有交给ViewPager处理。我们知道Button的CLICKABLE为true默认是可点击的,所以消费了事件。

  • 比较有趣的是如果我们里面嵌套的还是一个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可以正常上下滑动

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

  • 外部拦截法

举例中默认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的滑动冲突,两者都可正常滑动

  • 内部拦截法

思路: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

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值