最近在做项目的时候要做一个类似桌面的图标拖动到指定位置的功能,一开始以为挺简单的,但当开始实现之后遇到了一些小问题,而且之后发现了在事件分发机制中有一样东西容易被忽略,很少被注意到。这个东西就是ViewGroup里面的mFirstTouchTarget成员。下面就开始说说我是如何在项目中遇到问题,发现mFirstTouchTart的重要性,如何解决问题的。这是Demo地址https://github.com/huangwanjie/TableImitate/tree/develop
首先先要说一下原项目中这个图标拖动功能所在的布局的大致情况。
(黑色框)RelativeLayout为布局的最顶层,包含一个(绿色框)Bar,(深蓝色框)ViewPager和一个充满父控件(RelativeLayout)的(红色框)FrameLayout,FrameLayout位于布局的最顶层,同时ViewPager里包含了一个用于放置图标的(浅蓝色框)RecyclerView。
下面说一下实现思路:新建一个继承FrameLayout控件(替换布局中红框表示FrameLayout)CellDragLayout,重写onTouchEvent()方法。长按RecyclerView的item(称为A)时获取其中的图标,然后在CellDragLayout与item相对应的位置新建一个ImageView(称为B)控件并为其设置图标,同时将A设置为View.INVISIBLE。CellDragLayout的onTouchEvent()方法中,编写根据触摸时传进来的事件的坐标改变B的位置,来实现对控件的拖动。
在代码实现之前再简略地回顾一下事件分发的机制:
1.Activity首先接收到触摸事件,这时会调用到Activity的dispatchTouchEvent,具体的处理交给PhoneWindow去处理。
2.PhoneWindow会将事件传递给DecorView。
3.DecorView将事件传递给根ViewGroup。
4.根ViewGroup在dispatchTouchEvent若不拦截事件会将是事件从其包含的最顶层的View或者ViewGroup开始分发,若下一层是ViewGroup再如此分发传递下去。最终事件会传递到View的onTouchEvent方法或是设置了OnTouchListener的OnTouch方法中进行处理。若子控件的onTouchEvent方法或是设置了OnTouchListener的OnTouch方法都返回false,则会把事件交回其父控件,在父控件的ontouchEvent或者设置了OnTouchListener的OnTouch方法中处理。
以上我们最应该关注的就是ViewGroup开始的事件分发过程。用伪代码表示大概如下:
public boolean dispatchTouchEvent(MotionEvent ev){
boolean result = false;
if (onInterTouchEvent(ev)){
result = super.dispatchTouchEvent(ev);
}else {
result = child.dispatchTouchEvent(ev);
}
return result;
}
且最终的事件都会传递到View的dispatchTouchEvent方法,抽取其中部分代码:
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
result = true;
}
if (!result && onTouchEvent(event)) {
result = true;
}
需要注意有两点,OnTouchListener的OnTouch方法优先于onTouchEvent方法执行,且OnTouchListener的OnTouch返回false时还会继续往下执行onTouchEvent方法。
好了,回到实际的项目。在了解了以上知识之后,我认为整个操作流程应该没有问题,并且也大致了解了事件分发的整个流程,然后就出问题了。
由于CellDragLayout在最上层,所以一开始我在重写CellDragLayout的onTouchEvent的时候我总返回super.onTouchEvent(),因为不能消耗此事件,下层的RecyclerView的item需要去出发longclick事件。在长按item之后成功地把拿到的图标设置到CellDragLayout中新建的ImageView之后就开始出现问题了,ImageView不能随着触点移动。
然后我在CellDragLayout的onTouchEvent上调试发现,CellDragLayout只能拿到DOWN事件。然后我脑袋一转,是不是因为没有给CellDragLayout设置clickable为true才导致的呢?于是带着这一想法,在CellDragLayout新建了ImageView之后,我把该CellDragLayout的clickable设置为true,可是这生成新的ImageView之后依然不能移动,但是此时松开手再触摸屏幕才能进行触摸移动,而且在CellDragLayout的onTouchEvent上调试也发现了即使将clickable设置为true,Down之后的后续事件也接收不到,必须松开手再次触摸屏幕才能获取到。为什么就算将clickable设置为true,也只能在第二次进行触摸的时候获取到的事件才算正常,按照上面事件分发机制所说的,事件会由上往下分发一层层地进行分发吗?为什么第一次的时候CellDragLayout只能接受到Down事件?后续的事件都分发到哪里去了呢?
带着上面的疑问,我就在最上层的RelativeLayout中的dispatchTouchEvent开始进行调试,最后发现第一次触摸进行长按,Down之后的事件全部由RelativeLayout分发到ViewPager,再由PaperView分发到RecylerView,最后分发到RecyclerView中对应的item中了。为什么事件都分发到了item中呢,是什么样的规则最终把所有的事件都分发到了item中呢?带着这样的疑问,我在断点调试和仔细地阅读了ViewGroup下的dispatchTouchEvent()方法发现了ViewGroup中的mFirstTouchTarget成员的重要性。下面我就根据我理解到的解释一下有关这个mFirstTouchTarget的作用和讲解一下事件分发里的一些细节问题。
mFirstTouchTarget在ViewGroup是这样注释的:
// First touch target in the linked list of touch targets.
private TouchTarget mFirstTouchTarget;
直接翻译在触摸目标列表上的第一个触摸目标,简单点说就是在此ViewGroup里面,第一个消费了触摸时间的目标。TouchTarget是ViewGroup里的一个内部类。TouchTarget类包含一个child成员:
// The touched child view.
public View child;
child是在此ViewGroup中消费了触摸事件的View,这个child并不一定是真正消费事件的view,因为child可以是ViewGroup,真正消费事件的可能是里面的子View。然后再看看ViewGroup的dispatchTouchEvent()方法的部分代码。
public boolean dispatchTouchEvent(MotionEvent ev){
..............................................
..............................................
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
.....................................................
......................................................
newTouchTarget = getTouchTarget(child);//1
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);
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {//2
// 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);//3
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);
}
...................................................
...................................................
}
一个完整的事件是从Down事件开始的,当ViewGroup接收到DOWN事件之后会利用for循环,把DOWN事件分发到它的子View,每一个子View都能接收到DOWN事件。在注释1中调用了getTouchTarget()方法,将结果赋值给newTouchTarget,newTouchTarget是用于保存消费事件的目标View。接下来看看getTouchTarget()方法:
private TouchTarget getTouchTarget(@NonNull View child) {
for (TouchTarget target = mFirstTouchTarget; target != null; target = target.next) {
if (target.child == child) {
return target;
}
}
return null;
}
由于ViewGroup在接收到DOWN事件的一开始将mFirstTouchTarget置为null,所以这时该方法返回null。然后接着看上一段代码的注释2。注释2中调用了dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign),将DOWN事件分发到此时遍历到的子view,要是子View消费了DOWN事件则执行注释2中的if语句,并在注释3中,调用了addTouchTarget()方法将消费了DOWN事件的子View封装成TouchTarget赋值给newTouchTarget,同时在addTouchTarget中给mFirstTouchTarget赋值:
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;
mFirstTouchTarget = target;
return target;
}
经过上面的分析,可以知道在ViewGroup接收DOWN事件之后,会遍历子View分发事件,找出消费了该事件的子view的同时将该view封装成TouchTarget,赋值给mFirstTouchTarget。
分析了DOWN事件之后,再分析一下MOVE事件是怎么进行分发处理的,看以下部分源码:
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;//1
while (target != null) {//2
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {//3
handled = true;
}
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}
..........................
..........................
}
在MOVE事件分发的时候,要是在之前DOWN事件分发的时候,子view消费了DOWN事件之后,mFirstTouchTarget则不会为空,并且mFirstTouchTarget.child等于该子view。然后会在执行注释1中TouchTarget target = mFirstTouchTarget,所以会执行注释2中的while语句的代码。断点调试时会发现,由于alreadyDispatchedToNewTouchTarget此时为false,表示还没有将事件分发下去,所以执行注释3中的代码,把事件分发到target.child,也就是mFirstTouchTarget.child,也就是在一开始消费了DOWN事件的子view。之后的UP事件也会像MOVE事件一样分发给mFirstTouchTarget.child。
总结上面分析的我们就可以知道,只要有View在一开始消费了DOWN事件,之后得MOVE,UP事件都会直接分发到该view,而不会再次像Down事件一样遍历所有子view。下面伪代码表示上述过程:
public boolean dispatchTouchEvent(MotionEvent ev){
boolean handle = false;
boolean alreadyDispatchedToNewTouchTarget = false;
if (onInterTouchEvent(ev)){
handle = super.dispatchTouchEvent(ev);
}else {
if (ev.getAction() == MotionEvent.ACTION_DOWN){
mFirstTouchTarget = null;
for (int i = childrenCount; i <= 0; i--){ //childrenCount为该ViewGroup的子view数量
View child = childrens[i]; //childrens为储存所有子view的所有数组
if (child.dispatchTouchEvent(ev)){
mFirstTouchTarget = addTouchTarget(child);
alreadyDispatchedToNewTouchTarget = true;
}
}
}
if ( mFirstTouchTarget == null){
handle = super.dispatchTouchEvent(ev);
}else {
if (alreadyDispatchedToNewTouchTarget){
handle = true;
}
handle = child.dispatchTouchEvent(ev);
}
}
return handle;
}
读到这里应该就可以知道,为什么我在CellDragLayout新创建的ImageView不能进行拖动了,因为在事件的开头的DOWN时间被item消费了,于是事件分发形成了RelativeLayout-----ViewPager-----RecyclerView-----item这样一路径,后续的事件都通过这路径直接分发给了item。
好了,这一篇文章就分享到这里,下一遍将如何利用事件分发去完成我们的图标拖动的。