相信在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);
}
}
效果就不进行展示了,和上面的内部拦截是一样的。
网友评论