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