此demo本质是一个viewgroup类型的控件,可以跟随手指的拖动而移动位置,内部可以包含一些子view来显示内容,同时子view可以响应点击事件。
由于外层viewgroup要响应移动事件,故自然而然的想到了要在外层viewgroup中拦截move事件。于是有了下面的写法。

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
return false;
case MotionEvent.ACTION_MOVE:
return true;
case MotionEvent.ACTION_UP:
return false;
}
return super.onInterceptTouchEvent(event);
}

在viewgroup的事件拦截方法中,仅仅对move事件进行了拦截,而down和up事件则不拦截。
因为我们知道,如果拦截了down事件,那么后续事件均会分发到该父控件,而不会分发到子view了(后面分析子view无法响应点击事件时在详解该逻辑)。
而onclick事件是在up事件中处理的,故也不能拦截up事件。

处理好了事件的拦截后,在该控件的onTouchEvent中加入拖动处理的代码,部分如下:

@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
mLastDownRawX = event.getRawX();
mLastDownRawY = event.getRawY();

        mLastTouchDownX = event.getX();
        mLastTouchDownY = event.getY();
    case MotionEvent.ACTION_MOVE:

        int deltax = (int) (event.getRawX() - mLastDownRawX);
        int deltay = (int) (event.getRawY() - mLastDownRawY);

        mDeltaX = deltax;
        mDeltaY = deltay;

        if (!mIsAnimation) {
            layout(getLeft() + deltax, getTop() + deltay, getRight() + deltax, getBottom() + deltay);
        }

        mLastDownRawX = event.getRawX();
        mLastDownRawY = event.getRawY();
        break;
}

}

在处理拖动的时候发现,如果使用event.getX() 和 event.getY()来计算累加的移动距离的话,控件在移动的过程中会产生抖动的现象。
因为event.getX()和event.getY()的值是相对于本控件而言的,也即相对于该控件的左上角而言的。在移动的过程中,由于控件的位置受event.getX()与event.getY()的影响而不断变化,而在变化后又会影响event.getX()与event.getY()的值,这互相影响的过程就导致了界面的不断抖动。

在处理这样的全局拖动控件的逻辑时,我们应该使用event.getRawX()与event.getRawY()来避免界面的抖动问题。
在google的开源库hovermenu中也是使用的event.getRawX()的方式处理移动逻辑的。参见地址。

开发好了demo后,运行一看,问题来了。 子view偶尔能响应点击事件偶尔不能响应点击事件。这是为啥呢?按照onInterceptTouchEvent方法中的拦截方式,up事件应该传递到子view中才对,不会时而响应时而不响应。这很奇怪。

于是,在onInterceptTouchEvent方法加入日志来分析,通过输出的日志发现,一般是没有move事件时子view的点击事件可以响应,而有move事件时子view的点击事件没有响应。
通过这个现象,可以猜想子view无法响应可能和move事件的处理有关。于是通过源码查看其中的处理逻辑。

源码如下:

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
// 省略…
// 接下来这部分判断是否拦截事件,是分发到子view还是控件自己消费。
// 这个if比较关键,有两个条件成立会走拦截判断。 一个是down事件,一个是mFristTouchTarget不为空的时候。mFirstTouchTarget不为空的时候说明已经找到了该事件的目标消费者。
// 从这里可以看到,如果down事件被拦截了,mFirstTouchTarget也没有被赋值的机会,故后续的move和up事件均不会走拦截。此时,拦截方法move和up事件的返回值也就没什么意义了。
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
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;
}

// 省略不拦截时的逻辑,其中有 mFirstTouchTarget 被赋值的逻辑。 
// 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;
        // 事件被拦截时 alreadyDispatchedToNewTouchTarget 为false。(拦截时,不会走遍历子view找分发目标的流程)
        if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
            handled = true;
        } else {
        	// 拦截时cancelchild为true
            final boolean cancelChild = resetCancelNextUpFlag(target.child)
                    || intercepted;
            if (dispatchTransformedTouchEvent(ev, cancelChild,
                    target.child, target.pointerIdBits)) {
                handled = true;
            }
            if (cancelChild) {
            	// 拦截后遍历了mFirstTouchTarget后最后会被赋值为空。 
            	// 到此,可以知道如果在拦截方法中申明不拦截down和up,单独拦截move事件,在实际的分发中,up事件还是会被分发给父控件,而不是子控件。
            	// 因为当move事件到来时,由于mFristTarget是子view,不为空,会走拦截方法,而此时move事件被申明为拦截,系统会将此次事件包装成一个cancel事件发送到子view中,让子view有机会做一些view的重置操作。同时,mFristTouchTarget为会置为空。 
            	// 最后,当up事件来临时,由于mFirstTouchTarget对象为空,且不是down事件,故不会走父控件的拦截事件。 而是直接分发给了父控件了。 
                if (predecessor == null) {
                    mFirstTouchTarget = next;
                } else {
                    predecessor.next = next;
                }
                target.recycle();
                target = next;
                continue;
            }
        }
        predecessor = target;
        target = next;
    }
}

}

通过源码发现,在down事件发生时,父控件没有拦截,而是找到了处理该事件的子view,于是将该子view赋值给mFirstTouchTarget。
接着,当move事件到来时,由于mFirstTouchTarget不为空,还是可以走到是否拦截的逻辑判断中。此时父控件拦截了move事件,故将move事件交由父控件本身处理。
同时,由于接收down事件view和接收move事件的view不同,故mFirstTouchTarget会被置空,同时发送一个cancel事件给子view。
最后,当up事件到来时,由于mFirstTouchTarget为空,不会走拦截方法,所以即使当父控件明确申明不拦截up事件也不起作用。 该up事件还是会分发到父控件中。

在这个demo中,当没有move事件时,即只发生了down和up事件的的话,那么up事件就会走到拦截方法中,此时申明了不拦截。故子view可以收到up事件,所以子view的点击事件可以响应。 而有move事件时,up没有走拦截的方法,直接分发到了父控件中了。

至此,我明白了就是为什么子view时而可以收到时而收不到点击事件。

如果要子view可以稳定的响应点击事件,该如何修改呢?通过上面的分析我们知道拦截了所有的move事件也是不行的。我们需要拦截确定是拖动动作的move事件,而不是点击时所发生的轻微的move事件,也即是要将拖动的move事件和点击的move事件区分开来。
可以使用如下代码来区分,是点击还是拖动事件。

int slop = ViewConfiguration.get(context).getScaledTouchSlop();
private boolean isTouchWithinSlop(float dx, float dy) {
double distance = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
return distance < slop ;
}

其中,slop 使用的是系统提供的值。该方法中的dx与dy是相对与down事件的坐标差值。
改写一下onInterceptTouchEvent方法,如下:

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
mLastX = event.getX();
mLastY = event.getY();
return false;
case MotionEvent.ACTION_MOVE:
float x = event.getX();
float y = event.getY();

        if (!isTouchWithinSlop(x - mLastX, y - mLastY)) {
            return true;
        }
        return false;
}
return false;

}

修改后再次运行,可以发现子view已经可以稳定的相应点击事件了。同时,父控件也可以随意的拖动了。
在看源码的过程中也发现了,如果一个view是可点击的,那么该view的onTouchEvent的事件处理结果均返回的ture。
部分源码如下:

public boolean onTouchEvent(MotionEvent event) {
// 省略…
if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
// 省略 switch - case 的一些代码

    return true;
}

return false;

}

而这个clickable除了通过setClickable方法显示设置外,在设置onclicklistener时系统也会将其设置为可点击的。 源码如下:

public void setOnClickListener(@Nullable OnClickListener l) {
if (!isClickable()) {
setClickable(true);
}
getListenerInfo().mOnClickListener = l;
}

之前在网络博客上看事件分发知识时也有类似表述,即事件分发到某个view后,那么后续的事件也会分发到该view。当时只是死记了这个结论,却没有实际理解背后的原理。在理解了mFirstTouchTarget后结合源码分析就可以知道事件的分发的流程。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐