声明:
本文主要涉及VIew的事件分发
与滑动冲突的解决
,关于View的事件分发流程的部分内容参考自:
Android事件分发机制详解:史上最全面、最易懂
个人感觉该文较全面,总结也很好,但略显冗长,且源码基于Android5.0以前的版本。本文中简化了核心原理,再补充了一些必要知识点,并使用最新的源码重新做了分析。
MontionEvent
当用户触摸屏幕时(View 或 ViewGroup派生的控件),将产生点击事件(Touch事件)。而Touch事件的相关细节(发生触摸的位置、时间等)被封装成MotionEvent对象。事件分发其实就是对MontionEvent对象的分发。
MotionEvent.ACTION_DOWN:按下View(所有事件的开始)
MotionEvent.ACTION_UP:抬起View(与DOWN对应)
MotionEvent.ACTION_MOVE:滑动View
MotionEvent.ACTION_CANCEL:结束事件(非人为原因)
当控件收到前驱事件
之后,后面的事件如果被父控件拦截,那么当前控件就会收到一个CANCEL事件,并会把此CANCEL事件传递给它的子控件。
前驱事件:一个从DOWN一直到UP的所有事件组合称为完整的手势,中间的任意一次事件对于下一个事件而言就是它的前驱事件。
场景一:ScrollView中有一个Button,手指触下Button区域进行滑动的过程中,Button会收到ScrollView传给它的CANCEL事件。这个过程是这样的,当ScrollView收到DOWN事件时,无法确定这是个点击事件还是滑动事件,并且发现该次点击正好落在Button的区域内,于是它把DOWN事件传给Button,然后后续到来的事件是一个MOVE事件,这时ScrollView能确定本次是一个滑动事件,于是它决定要拦截MOVE事件,此时Button就会收到一个CANCEL事件了,并且这个事件序列中的后续事件都不再会交给BUutton处理。在整个过程中,ScrollView收到的事件序列是:DOWN → 多个MOVE → UP,而Button收到的事件序列是:DOWN → CANCEL。
场景二:ScrollView包含ViewPager时,对ViewPager做左右滑动,滑到一页的一半时改为上下滑动,此时ViewPager就会收到CANCEL事件,而ViewPager在CANCEL中就可以做一些恢复状态的处理,回到先前那一页,而不是停在中间。这个过程中,ScrollView收到的事件序列是:DOWN → 多个MOVE → UP,而ViewPager收到的事件序列是:DOWN → 多个MOVE → CANCEL。
从手指接触屏幕 至 手指离开屏幕,这个过程产生的一系列事件。
一般情况下,事件列都是以DOWN事件开始、UP事件结束:
Activity、Window(ViewGroup)、View
从上而下看,Android UI界面包含以下组件:
Activity:一个UI界面就是一个Activity
Window:抽象类,其唯一实现是android.policy.PhoneWindow,可以通过getWindow()获得。
DecorView:PhoneWindow的一个内部类,父View是FrameLayout,是界面的顶级View,setContentView所设置的View就是作为DecorView的子View,可以通过getWindow().getDecorView()获得。而要获取Activity所设置的View,则可以通过((ViewGroup)getWindow().getDecorView().findViewById(android.R.id.content)).getChildAt(0)。
ViewGroup:View的子类,可以包含其他View
View:不能包含其他View
当一个点击事件产生后,它的传递过程遵循如下顺序:Activity -> Window(ViewGroup) -> View,即事件总是先传递给Activity,Activity会交给其内部的Window进行派发,Window会将事件传递给decor view,decor view一般就是当前界面的底层容器(即setContentView所设置的View的父容器),通过Activiyt.getWindow().getDecorView()可以获得。而DecorView的父类是FrameLayout,所以DecorView的间接父类就是ViewGroup了。
从以上分析可知,要想充分理解Android的事件分发机制,本质上是要理解:
事件的分发过程由3个很重要的方法来共同完成:
事件传递的开始,用来分发点击事件
。返回结果受当前View的onTouchEvent和下级View的dispatchTouchEvent影响,表示是否消耗了事件
。
在上述方法内部调用,用来判断是否拦截事件,如果当前View拦截了某个事件,那么在同一个事件序列当中,此方法不会被再次调用,返回结果表示是否拦截当前事件
。
在dispatchTouchEvent方法中调用,用来处理点击事件,返回结果表示是否消耗当前事件
,如果不消耗 ,则在同一个事件序列中,当前View无法再次收到事件。
考虑一种情况,如果一个View的onTouchEvent返回false,那么它的父容器的onTouchEvent将会被调用,以此类推。如果所有的元素都不处理这个事件,那么这个事件将会最终被抛给Activity处理,即Activity的onTouchEvent将会被调用。
上述三个方法的关系可以用如下伪代码表示:
// 步骤1:调用dispatchTouchEvent()
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false; //代表 是否会消费事件
// 步骤2:判断是否拦截事件
if (onInterceptTouchEvent(ev)) {
// a. 若拦截,则将该事件交给当前View进行处理
// 即调用onTouchEvent ()方法去处理点击事件
consume = onTouchEvent (ev) ;
} else {
// b. 若不拦截,则将该事件传递到下层
// 即 下层元素的dispatchTouchEvent()就会被调用,重复上述过程
// 直到点击事件被最终处理为止
consume = child.dispatchTouchEvent (ev) ;
}
// 步骤3:最终返回通知 该事件是否被消费(接收 & 处理)
return consume;
}
当一个点击事件发生时,事件最先传到Activity
的dispatchTouchEvent()
进行事件分发。
/**
* Activity.dispatchTouchEvent()
*/
public boolean dispatchTouchEvent(MotionEvent ev) {
// 一般事件列开始都是DOWN事件 = 按下事件,故此处基本是true
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
// ->>分析1
}
// ->>分析2
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
// 若getWindow().superDispatchTouchEvent(ev)返回true
// 则Activity.dispatchTouchEvent()就返回true,则方法结束。即 :该点击事件停止往下传递 & 事件传递过程结束
// 否则:继续往下调用Activity.onTouchEvent
}
return onTouchEvent(ev);
}
先整体上分析一下上面的代码。首先事件开始交给Activity所附属的Window进行分发,如果返回true,整个事件循环就结束了,返回false意味着事件没人处理,所有View的onTouchEvent都返回了false,那么Activity的onTouchEvent就会被调用。
/**
* 分析1:onUserInteraction()
* 注:
* a. 该方法为空方法
* b. 用户交互事件,包括触屏事件、按键事件(home,back,menu键)、以及轨迹球事件等都会触发此方法
* 以下是官方文档的说明:
* Called whenever a key, touch, or trackball event is dispatched to the
* activity. Implement this method if you wish to know that the user has
* interacted with the device in some way while your activity is running.
*
* All calls to your activity's {@link #onUserLeaveHint} callback will
* be accompanied by calls to {@link #onUserInteraction}.
*
* 无论是按键事件、触摸事件或者轨迹球事件被分发给activity,都会调用Activity#onUserInteraction()。
* 如果你想知道用户通过某种方式和你正在运行的activity进行了交互,可以重写Activity#onUserInteraction()。
* 所有调用Activity#onUserLeaveHint()的回调都会首先回调Activity#onUserInteraction()。
*/
public void onUserInteraction() {
}
和onUserInteraction()对应的还有一个onUserLeaveHint()方法。当你想知道是否是因为用户操作导致你的activity将要进入后台,则可以重写该方法。
/**
* Called as part of the activity lifecycle when an activity is about to go into the background as the result of user choice.
* For example, when the user presses the Home key, {@link #onUserLeaveHint} will be called, but
* when an incoming phone call causes the in-call Activity to be automatically brought to the foreground,
*{@link #onUserLeaveHint} will not be called on the activity being interrupted.
*
* 当用户的操作使一个activity准备进入后台时,此方法会像activity的生命周期的一部分被调用。例如,当用户按下Home键,
* Activity#onUserLeaveHint()将会被回调。但是当来电导致来电activity自动占据前台,Activity#onUserLeaveHint()将不会被回调。
*/
public void onUserLeaveHint() {
}
/**
* 分析2:getWindow().superDispatchTouchEvent(ev)
* 说明:
* a. getWindow() = 获取Window类的对象
* b. Window类是抽象类,其唯一实现类 = PhoneWindow类;即此处的Window类对象 = PhoneWindow类对象
* c. Window类的superDispatchTouchEvent() = 1个抽象方法,由子类PhoneWindow类实现
*/
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
// mDecor = 顶层View(DecorView)的实例对象
// ->> 分析3
}
/**
* 分析3:mDecor.superDispatchTouchEvent(event)
* 定义:属于顶层View(DecorView)
* 说明:
* a. DecorView类是PhoneWindow类的一个内部类
* b. DecorView继承自FrameLayout,是所有界面的父类
* c. FrameLayout是ViewGroup的子类,故DecorView的间接父类 = ViewGroup
*/
public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
// 调用父类的方法 = ViewGroup的dispatchTouchEvent()
// 即 将事件传递到ViewGroup去处理,详细请看ViewGroup的事件分发机制
}
从Activity的事件分发可知,ViewGroup的事件分发也是从dispatchTouchEvent开始。这个方法比较长,我们分段来说明。先看下面这一段,很显然它描述的是当前ViewGroup是否拦截点击事件的逻辑。
// Check for interception.
final boolean intercepted;
// 条件1:当前事件是DOWN事件时
// 条件2:mFirstTouchTarget != null,当事件被子元素成功处理时,mFirstTouchTarget 就会被赋值并指向子元素,也就是说ViewGroup不拦截事件并将事件交由子元素处理时该条件就会成立,反过来,一旦事件由当前ViewGroup拦截时,该条件就不成立。于是,当MOVE和UP到来时,整个条件为false,将导致ViewGroup的onInterceptTouchEvent不会被执行。
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
// disallowIntercept很重要,相当于一个开关,可以关闭ViewGroup对事件的拦截,但仅限于DOWN事件以外的其他事件。
if (!disallowIntercept) {
// ->>分析1
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;
}
/**
* 分析1:ViewGroup.onInterceptTouchEvent()
* 作用:是否拦截事件
* 说明:
* a. 返回true = 拦截,即事件停止往下传递(需手动设置,即复写onInterceptTouchEvent(),从而让其返回true)
* b. 返回false = 不拦截(默认)
*/
public boolean onInterceptTouchEvent(MotionEvent ev) {
return false;
}
这里还有一个FLAG_DISALLOW_INTERCEPT标记位,很显然它也会影响onInterceptTouchEvent的执行,这个标记位是通过requestDisallowInterceptTouchEvent方法来设置的,一般用于子View中。该标记位一旦设置后,ViewGroup将无法拦截除了ACTION_DOWN以外
的其他事件。为什么说是除了ACTION_DOWN以外的其他事件呢?这是因为ViewGroup在分发事件时,如果是ACTION_DOWN事件,就会重置FLAG_DISALLOW_INTERCEPT这个标记位,将导致子View中设置的这个标记位无效。换言之,当遇到ACTION_DOWN事件时,ViewGroup总是会调用自己的onInterceptTouchEvent方法来询问自己是否要拦截事件。这可以从源码中看出来。
// Handle an initial down.
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Throw away all previous state when starting a new touch gesture.
// The framework may have dropped the up or cancel event for the previous gesture
// due to an app switch, ANR, or some other state change.
cancelAndClearTouchTargets(ev);
resetTouchState();
}
上面的代码是在检查是否要拦截
之前的一段,很显然它是处理一些初始化的工作,由于任何一个事件序列都是开始于DOWN事件,也就是说,一旦遇到DOWN事件,就可以认为是一个新的事件序列的开始,所以此时要首先做一系列重置状态的动作,而FLAG_DISALLOW_INTERCEPT就是在resetTouchState方法中被重置的,因此子View调用requestDisallowInterceptTouchEvent并不能影响ViewGroup对DOWN事件的处理。
接下来看ViewGroup不拦截事件时,事件会向下分发给子View进行处理,源码如下:
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
// If there is a view that has accessibility focus we want it
// to get the event first and if not handled we will perform a
// normal dispatch. We may do a double iteration but this is
// safer given the timeframe.
if (childWithAccessibilityFocus != null) {
if (childWithAccessibilityFocus != child) {
continue;
}
childWithAccessibilityFocus = null;
i = childrenCount - 1;
}
// 条件1:canViewReceivePointerEvents,当前child是否能够接收到点击事件
// 条件2:isTransformedTouchPointInView,点击事件的坐标是否落在当前child的区域内
// 因此,当前child无法接收到事件或者点击事件不在当前child的区域内,就跳过,继续遍历下一个child
if (!canViewReceivePointerEvents(child)
|| !isTransformedTouchPointInView(x, y, child, null)) {
ev.setTargetAccessibilityFocus(false);
continue;
}
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// Child is already receiving touch within its bounds.
// Give it the new pointer in addition to the ones it is handling.
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
resetCancelNextUpFlag(child);
// 如果当前child是被点击的child,代码就会执行到这里,调用dispatchTransformedTouchEvent方法进行事件的分发
// ->>分析2
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// Child wants to receive touch within its bounds.
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
// childIndex points into presorted list, find original index
for (int j = 0; j < childrenCount; j++) {
if (children[childIndex] == mChildren[j]) {
mLastTouchDownIndex = j;
break;
}
}
} else {
mLastTouchDownIndex = childIndex;
}
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
// The accessibility focus didn't handle the event, so clear
// the flag and do a normal dispatch to all children.
ev.setTargetAccessibilityFocus(false);
}
/**
* 分析2:ViewGroup.dispatchTransformedTouchEvent()
* 作用:实现ViewGroup到View的事件分发
* 若ViewGroup不拦截事件,那么参数中的child就不是null,因此会执行child.dispatchTouchEvent(event),实现了一轮分发
* 若ViewGroup拦截事件,那么参数中的child是null,因此会执行super.dispatchTouchEvent(event),意思就是把当前的ViewGroup当做View来对待,执行View的dispatchTouchEvent,由它自己来处理这个事件。
*/
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
final boolean handled;
...
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
}
...
}
从ViewGroup的事件分发可以看出,View的事件分发也是从dispatchTouchEvent开始。
/**
* View.dispatchTouchEvent()
*/
public boolean dispatchTouchEvent(MotionEvent event) {
if (mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED &&
mOnTouchListener.onTouch(this, event)) {
return true;
}
return onTouchEvent(event);
}
// 说明:只有以下3个条件都为真,dispatchTouchEvent()才返回true;否则执行onTouchEvent()
// 1. mOnTouchListener != null
// 2. (mViewFlags & ENABLED_MASK) == ENABLED
// 3. mOnTouchListener.onTouch(this, event)
// 下面对这3个条件逐个分析
/**
* 条件1:mOnTouchListener != null
* 说明:mOnTouchListener变量在View.setOnTouchListener()方法里赋值
*/
public void setOnTouchListener(OnTouchListener l) {
mOnTouchListener = l;
// 即只要我们给控件注册了Touch事件,mOnTouchListener就一定被赋值(不为空)
}
/**
* 条件2:(mViewFlags & ENABLED_MASK) == ENABLED
* 说明:
* a. 该条件是判断当前点击的控件是否enable
* b. 由于很多View默认enable,故该条件恒定为true
*/
/**
* 条件3:mOnTouchListener.onTouch(this, event)
* 说明:即 回调控件注册Touch事件时的onTouch();需手动复写设置,具体如下(以按钮Button为例)
*/
button.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
return false;
}
});
// 若在onTouch()返回true,就会让上述三个条件全部成立,从而使得View.dispatchTouchEvent()直接返回true,事件分发结束
// 若在onTouch()返回false,就会使得上述三个条件不全部成立,从而使得View.dispatchTouchEvent()中跳出If,执行onTouchEvent(event)
接下来,我们看看onTouchEvent的源码。Android 5.0后 View.onTouchEvent()源码发生了变化(更加复杂),但原理相同;为了更容易理解,这里采用Android 5.0前的版本。
/**
* View.onTouchEvent()
*/
public boolean onTouchEvent(MotionEvent event) {
final int viewFlags = mViewFlags;
// 若该控件被disable了,返回值取决于是否可点击或长按,是则一定返回true,消耗事件,否则一定返回false,事件被抛给上级的ViewGroup处理
// 结论(9)
if ((viewFlags & ENABLED_MASK) == DISABLED) {
return (((viewFlags & CLICKABLE) == CLICKABLE ||
(viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE));
}
if (mTouchDelegate != null) {
return true;
}
}
// 若该控件可点击,则进入switch判断中
if (((viewFlags & CLICKABLE) == CLICKABLE ||
(viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)) {
switch (event.getAction()) {
// a. 若当前的事件 = 抬起View(主要分析)
// 结论(10)
case MotionEvent.ACTION_UP:
boolean prepressed = (mPrivateFlags & PREPRESSED) != 0;
...// 经过种种判断,此处省略
// 执行performClick() ->>分析1
performClick();
break;
// b. 若当前的事件 = 按下View
case MotionEvent.ACTION_DOWN:
if (mPendingCheckForTap == null) {
mPendingCheckForTap = new CheckForTap();
}
mPrivateFlags |= PREPRESSED;
mHasPerformedLongPress = false;
postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
break;
// c. 若当前的事件 = 结束事件(非人为原因)
case MotionEvent.ACTION_CANCEL:
mPrivateFlags &= ~PRESSED;
refreshDrawableState();
removeTapCallback();
break;
// d. 若当前的事件 = 滑动View
case MotionEvent.ACTION_MOVE:
final int x = (int) event.getX();
final int y = (int) event.getY();
int slop = mTouchSlop;
if ((x < 0 - slop) || (x >= getWidth() + slop) ||
(y < 0 - slop) || (y >= getHeight() + slop)) {
// Outside button
removeTapCallback();
if ((mPrivateFlags & PRESSED) != 0) {
// Remove any future long press/tap checks
removeLongPressCallback();
// Need to switch from pressed to not pressed
mPrivateFlags &= ~PRESSED;
refreshDrawableState();
}
}
break;
} // switch语句结束
// 若该控件可点击,就一定返回true,消耗事件,不再往上抛
return true;
} // switch外层的if条件句结束
// 若该控件不可点击,就一定返回false,表示事件未消耗,抛给上级的ViewGroup处理
return false;
}
/**
* 分析1:performClick()
*/
public boolean performClick() {
if (mOnClickListener != null) {
playSoundEffect(SoundEffectConstants.CLICK);
mOnClickListener.onClick(this);
return true;
// 只要我们通过setOnClickListener()为控件View注册1个点击事件
// 那么就会给mOnClickListener变量赋值(即不为空)
// 则会往下回调onClick() & performClick()返回true
}
return false;
}
重要结论:
View的事件监听器响应先后顺序:OnTouchListener > OnClickListener,即onTouch先于onClick响应,同时onTouch的返回值决定了onClick是否被响应。onTouch返回true,则事件传递终止,onClick不响应;onTouch返回false,则事件继续传递,onClick响应。
滑动冲突是APP开发中很常见的一个问题,也是View体系中一个深入的话题。只要界面中内外两层同时可以滑动,就会产生滑动冲突。那么如何解决滑动冲突呢?这既是一件困难的事又是一件简单的事,说困难是因为许多开发者面对滑动冲突都会显得束手无策,说简单是因为滑动冲突的解决是有固定套路的,只要知道了这个套路问题就迎刃而解了。
前面讲View的事件分发机制,其实也是为本节内容做准备。
这种情况比较常见,主要是将ViewPager和Fragment配合使用所组成的页面滑动效果,很多主流应用都会使用这个效果,比如微信。在这种效果中,可以通过左右滑动来切换页面,而每个页面内部往往又是一个ListView。这里有些同学可能要反驳了:我用ViewPager和ListView嵌套时,从来没遇到过滑动冲突!其实这种情况下滑动冲突是确实存在的,只不过ViewPager内部已经帮我们处理了滑动冲突,因此我们在使用ViewPager时无需关注这个问题,如果我们不是用ViewPager而是ScrollView等,那你就会真真切切的感受到滑动冲突带来的烦恼了,这时候就必须手动处理滑动冲突了,否则就会发现内外两层只有一层能够滑动。
这种情况稍微复杂一些,当内外两层都在同一个方向可以滑动的时候,显然存在逻辑问题。因为系统无法知道用户到底是想让哪一层滑动,要么只有一层能滑动,要么就是内外两层都滑动地很卡顿。比如某个界面同时集成了GoogleMap和SlideMenu,就会遇到此种冲突。
这种场景的滑动冲突看起来就更复杂了。比如外部有一个SlideMenu效果,然后内部有一个ViewPager,ViewPager的每一个页面中又是一个ListView。虽然这种情况看起来更复杂,但它是几个单一的滑动冲突的叠加,因此只需要分别处理内层和中层、中层和外层之间的滑动冲突即可。
概括来讲,解决滑动冲突只需要两步:
从本质上说,以上3种场景的复杂度其实是相同的,因为它们的区别仅仅是滑动策略的不同,至于解决冲突的方法,它们几个是通用的。
对于场景1,它的处理规则是:当用户左右滑动时,让外部View拦截点击事件,当用户上下滑动时,让内部View拦截点击事件。那么如何判断是水平滑动还是竖直滑动呢?方法有很多,比如可以依据滑动路径和水平方向形成的夹角,也可以依据水平方向和竖直方向上的距离差来判断,某些特殊的时候还可以依据水平和竖直方向的速度差来做判断。
方便起见,我们可以依据水平和垂直方向的距离差来判断滑动方向,比如竖直方向的距离差大,就当做竖直滑动处理,否则判断为水平滑动。当然这个规则有点粗糙,有些用户就是喜欢挑战开发者的智商,不竖不横,偏偏要斜着滑,这时单纯依据距离差判断就显得有点不够友好了。此时需要重新制定滑动策略:比如使用夹角做判断依据,当水平夹角小于30度时认定为水平滑动,大于60度时认定为竖直滑动,30-60度之间的丢弃不处理。说到底滑动策略的制定完全是由开发者来决定的,你也可以根据具体的业务需求来完善你的滑动策略。
对于场景2,它比较特殊,无法根据滑动的角度、距离差、速度差来判断,但这种时候一般都能在业务上找到突破点,比如业务上有规定:当处于某种状态时需要外部View响应用户的滑动,而处于另一种状态时需要内部View来响应用户的滑动。针对同时集成GoogleMap和SlideMenu的情况,从业务上可能不一定能找到突破点,但仍然可以制定相应的滑动策略,比如将手机屏幕边缘(占屏幕1/10处)的滑动进行拦截,交由SlideMenu处理,而屏幕其他范围内的滑动则交由Map处理。
对于场景3,它的滑动规则就更复杂了,和场景2一样,也无法单纯的根据滑动的角度、距离差、速度差进行判断,但同样还是可以从业务上找到突破点。
其实无论哪个场景,处理原则都是从业务上找突破点,制定合适的滑动策略。
上面说过,3种场景的滑动冲突解决,唯一的区别只在滑动策略的不同,有了滑动策略后,具体又该如何解决滑动冲突呢?我们以较为简单的场景1为例来得出通用的解决方案,至于场景2和3只需要修改有关滑动规则的逻辑即可。场景1的距离差其实就是滑动规则,而针对滑动冲突的解决,这里给出两种方式:外部拦截法和内部拦截法。
所谓外部拦截法,是指点击事件都先经过父容器的拦截处理,如果父容器需要就拦截,否则就不拦截,这种方法比较符合点击事件的分发机制。外部拦截法需要重写父容器
的onInterceptTouchEvent方法,在内部做相应的拦截即可,伪代码如下所示:
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
// 必须false,否则后续的MOVE和UP将全部交由该父容器处理,不再传递给子View
intercepted = false;
break;
}
case MotionEvent.ACTION_MOVE: {
if (父容器需要当前点击事件) {
intercepted = true;
} else {
intercepted = false;
}
break;
}
case MotionEvent.ACTION_UP: {
// 必须false,对于ViewGroup来说UP本身没有太多意义,但却会影响子View的onClick是否被触发
intercepted = false;
break;
}
default:
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
上述代码就是外部拦截法的典型逻辑,针对不同的滑动冲突,只要修改“父容器需要当前点击事件”这个条件即可,其他均不需也不能
修改。这里再做一些说明,首先是ACTION_DOWN事件,父容器必须返回false,即不拦截,这是因为一旦父容器决定拦截ACTION_DOWN事件了,那么后续的ACTION_MOVE和ACTION_UP事件都会直接交由它来处理(系统不再会调用onInterceptTouchEvent进行询问),没法再传递给子元素了。其次是ACTION_MOVE事件,这个事件可以根据需要决定是否拦截;最后是ACTION_UP事件,这里必须返回false,对于作为父容器的ViewGroup来说UP本身没有太多意义,但却会影响子View的onClick是否被触发,如果这里返回true,子元素就无法收到这个UP事件,这时候子元素的onClick就无法触发(结论10:onClick会发生的前提是当前View是可点击的,并且它收到了down和up事件)。
内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交给父容器进行处理,这种方法和Android的事件分发机制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作,较外部拦截法稍显复杂。内部拦截法需要重写子元素
的dispatchTouchEvent方法,伪代码如下:
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;
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
上述代码是内部拦截法的典型代码,针对不同的滑动冲突,只要修改“父容器需要此类点击事件”这个条件即可,其他均不需也不能
修改。除了子元素要做处理外 ,父元素也要默认拦截除了ACTION_DOWN以外的其他事件
,这样当子元素调用parent.requestDisallowInterceptTouchEvent(false)方法时,父元素才能继续拦截所需要的事件。
为什么父元素不能拦截ACTION_DOWN事件呢?那是因为ACTION_DOWN事件并不受FLAG_DISALLOW_INTERCEPT标记位的控制,一旦父元素拦截了ACTION_DOWN事件,那么所有的事件都无法传递到子元素中去,内部拦截法就失效了。父元素所做的修改如下所示:
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
// ACTION_DOWN事件不能拦截
return false;
} else {
// 其余事件默认拦截
return true;
}
}
Android事件分发机制详解:史上最全面、最易懂
Android开发艺术探索 (作者:任玉刚)