您的位置:首页 > 移动开发 > Android开发

Android自定义控件系列 六:利用添加自定义布局来搞定触摸事件的分发,解决组合界面中特定控件响应特定方向的事件

2015-04-30 12:39 991 查看
转自:/article/1329481.html

在写Android应用的过程之中,经常会遇到这样的情况:界面包含了多个控件,我们希望触摸在界面上的不同滑动动作能被不同的控件所接收,或者在界面不同位置滑动的动作能被不同的控件所接收,换句话说,能否让特定子view响应特定方向的触摸事件?一个典型的例子就是ListViewHeader的组合:



遇到的问题:

在上图的例子中,会发现一个问题,就是当手指在顶部轮播图上滑动的时候,如果我们想滑动轮播图,只能在手指非常水平的时候才能让轮播图翻动,而在手指滑动轨迹稍微有一点倾斜的时候,就发现触摸事件被ListView给响应了,变成了上下滑动ListView,这种体验显然不是很好。

假如说我们现在想要一种简单的实现:可能整个应用有很多页面,现在想在当前这个特定的界面,使得当手指在轮播图范围内滑动的时候,当手指轨迹角度<45度的时候(方向上较水平),那么让轮播图响应触摸事件,使得顶部图片能够水平滑动;让当手指手势轨迹角度>45度的时候(方向上较竖直),能够ListView来响应触摸事件,使得整个ListView能够上下滑动,这种效果要如何实现呢?

解决办法:

专栏的上一篇文章中,详细分析了Android的触摸事件的分发流程和ViewGroup的源代码(不熟悉的朋友可以看看:Android自定义控件系列九:从源码看Android触摸事件分发机制)。看过上一篇文章之后,应该了解到,Andrioid事件的分发是一层一层的进行的,最开始分发的时候总是从上层到下层,从活动的Activity开始,到DecorView,然后到我们写的布局,然后再是布局中的其他组件,那么本文的解决办法就是自定义一个ViewGroup,包裹在原来的ListView之外,放在这个特定的界面上。由于事件分发是一层层的进行的,所以我们重写这个外层的自定义ViewGroupdispatchTouchEvent方法就可以实现控制所有子view的事件分发机制,从而在这个特定的界面实现我们想要的触摸事件的响应机制。

写一个自定的FrameLayoutInterceptorFrameLayout,重写dispatchTouchEvent(MotionEvent ev)方法,主要解决几个问题:

1、在事件分发的时候,我们得到的是MotionEvent 事件,如何判断这个事件是否落在我们想要的控件区域上呢?

思路:可以在InterceptorFrameLayout中,使用一个Map集合,来存放我们想要控制触摸事件的View和对应的代表方向的参数,对外界暴露addremove方法,来添加和移除拦截的view对象。然后拿到event事件之后,调用event.getRawXevent.getRawY可以拿到相对屏幕左上角的绝对坐标,然后遍历view的map集合对所有的判断触摸的绝对坐标是不是在View的范围内,且要拦截的方向参数是否符合。判断触摸是否在view上,可以使用view.getLocationOnScreen(int[])方法,得到的int数组,第一个元素表示view的左上角的x坐标,第二个元素表示view的右上角坐标,具体判断方法如下:

[java] view
plaincopy

public static boolean isTouchInView(MotionEvent ev, View view) {//判断ev是否发生在view的范围内

static int[] touchLocation = new int[2];

view.getLocationOnScreen(touchLocation);//通过getLocationOnScreen方法,获取当前子view左上角的坐标

float motionX = ev.getRawX();

float motionY = ev.getRawY();

// 返回是否在范围内,通过触摸事件的坐标和本子view的左上右下四边的坐标比较,来判断是不是落在view内

return motionX >= touchLocation[0]

&& motionX <= (touchLocation[0] + view.getWidth())

&& motionY >= touchLocation[1]

&& motionY <= (touchLocation[1] + view.getHeight());

}

[java] view
plaincopy

/** 在集合中查找对应event和方向参数的view,找到了则返回,没找到返回null */

private View findTargetView(MotionEvent ev, int orientation) {

// mViewAndOrientation为存放要监测触摸事件的子view和对应方向参数的集合

Set<View> keySet = mViewAndOrientation.keySet();

for (View view : keySet) {

Integer ori = mViewAndOrientation.get(view);

// 由于所有的方向参数都是二进制相互与运算为0的

// 所以这里使用与运算来判断方向是否符合

// 这里所有的判断条件是:

// ①该子view在mViewAndOrientation集合内

// ②方向一致

// ③触摸事件落在该子view的范围内

// ④该子view可以消费掉本次事件

// 同时满足上面四个条件,则代表该子view是我们要找的子view,于是返回

if ((ori & orientation) == orientation && isTouchInView(ev, view)

&& view.dispatchTouchEvent(ev)) {

return view;

}

}

return null;

}

2、重写dispatchTouchEvent方法:

①如何处理Down事件和Move以及Cancel和Up事件的关系。

这个关系的纽带实际上就是mFirstTouchTarget,如果看完上一篇博文:Android自定义控件系列九:从源码看Android触摸事件分发机制还有印象的话,源码中mFirstTouchTarget会记录能够在Down事件时能够消费事件的子view,然后在Down事件之后的其他事件响应,都可以根据mFirstTouchTarget的状态来做进一步的判断后续动作。在这里我们也仿照源码的方式,定义一个mFirstTarget。在每一次进入到dispatchTouchEvent的时候,先需要判断一下mFirstTarget是否为空,如果mFirstTarget不为空,则代表之前有Down事件能够被某一个监测集合中的子view消费,于是我们可以继续调用boolean
flag = mFirstTarget.dispatchTouchEvent()
方法,将后续的事件(Move,Cancel,UP等)通过dispatchTouchEvent传递到这个对应的子view--即mFirstTarget上去;这个时候,如果flag返回true,则表示该子view(mFirstTarget)已经完全消费掉了事件,那么就应该将mFirstTarget重新置为空,方便下一次事件的分发;或者这个touch事件是Cancel或者Up,那么也表示本次事件的终止,于是也要将mFirstTarget置空。然后再将flag的值返回。

注意一点:这里我们的方向值定义如下:

[java] view
plaincopy

/** 代表滑动方向向上 */

public static final int ORIENTATION_UP = 0x1;// 0000 0001

/** 代表滑动方向向下 */

public static final int ORIENTATION_DOWN = 0x2;// 0000 0010

/** 代表滑动方向向左 */

public static final int ORIENTATION_LEFT = 0x4;// 0000 0100

/** 代表滑动方向向右 */

public static final int ORIENTATION_RIGHT = 0x8;// 0000 1000

/** 代表滑动方向的所有方向 */

public static final int ORIENTATION_ALL = 0x10;// 0001 0000

需要明确的一点是:我们通过public void addInterceptorView(final View view, final int orientation)传进来的view和对应的方向,表示当该方向上的move事件发生在这个view上时,且这个view能够消费掉这个事件的时候,让这个view去响应这个方向上的触摸事件,否则交给InterceptorFrameLayout的super.dispatchTouchEvent去处理;这里的这个否则的判断依据就是mFirstTarget
= findTargetView(ev, ORIENTATION_ALL);的值是否为null。

也就是说addInterceptorView进来的view和方向,就是让这个view响应该方向上的动作,不是这个方向上的动作,让别的集合中的view去响应,如果找不到集合中任何一个view响应的话,则让viewGroup去响应,调用默认的super.dispatchTouchEvent。

而在dispatchTouchEvent刚开始执行的时候,我们需要知道mFirstTarget 是否为空,来判断是否之前有Down事件被集合中的某个子view响应了,如果mFirstTarget确实不为null,则代表这一次的事件是上一次事件的继续,而且目标view都是mFirstTarget,于是我们只需要简单的调用 boolean flag = mFirstTarget.dispatchTouchEvent(ev);即可。然后再根据状态值,确定是否要将mFirstTarget置空。

[java] view
plaincopy

@Override

public boolean dispatchTouchEvent(MotionEvent ev) {

int action = ev.getAction();

// 意思应该是触发移动事件的最短距离,如果小于这个距离就不触发移动控件,

// 如viewpager就是用这个距离来判断用户是否翻页

mTouchSlop = configuration.getScaledTouchSlop();

if (mFirstTarget != null) {

// mFirstTarget不为空,表示最近的一次DOWN事件已经被mViewAndOrientation集合中的某个子view响应

// 于是将后续的事件继续分发给这个子view

boolean flag = mFirstTarget.dispatchTouchEvent(ev);

// 如果flag=true,表示本次事件被子view消耗,如果事件是ACTION_CANCEL或者ACTION_UP,

// 也代表事件的结束,于是将mFirstTarget置空,便于下一次DOWN事件的响应

if (flag

&& (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP)) {

mFirstTarget = null;

}

// 返回flag

return flag;

}

...

}

②处理Down事件:

Down事件发生的时候,我们并不知道接下来的Move的方向,所以在这个时候,我们只能把事件传递下去,并返回符合条件的子viewview.dispatchTouchEvent()方法的结果,如果能够找到符合条件的集合中的子
view,且这个子view.dispatchTouchEvent能够返回true,代表找到了符合条件的子view,所以将其值赋值给mFirstTarget。在Down事件的过程中,需要记录本次Down事件的x,y坐标,以供随后的MOVE事件做判断使用。

[java] view
plaincopy

// 拿到本次事件的坐标,由于只需要计算差值,所以getX也可以

final float currentX = ev.getX();

final float currentY = ev.getY();

switch (ev.getAction()) {

case MotionEvent.ACTION_DOWN:

mFirstTarget = findTargetView(ev, ORIENTATION_ALL);//这里是ORIENTATION_ALL的原因是,只有想截断所有方向的MOVE,才在DOWN的时候就拦截掉,这里mFirstTarget将不为null,否则这里一直都会是null

downX = currentX;

downY = currentY;

break;

③MOVE事件:

MOVE事件发生的时候,我们再次获取一下当前的x,y坐标,然后跟DOWN事件的时候做一下对比,即可得出当前滑动方向是朝哪个方向,然后就可以根据这个方向和触摸事件,查找是否具有符合要求的子view,有则赋值给mFirstTarget:

[java] view
plaincopy

case MotionEvent.ACTION_MOVE:

if (Math.abs(currentX - downX) > Math.abs(currentY - downY)

&& Math.abs(currentX - downX) > mTouchSlop) {

System.out.println("左右滑动");

// 左右滑动

if (currentX - downX > 0) {

// 右滑

mFirstTarget = findTargetView(ev, ORIENTATION_RIGHT);

} else {

// 左滑

mFirstTarget = findTargetView(ev, ORIENTATION_LEFT);

}

} else if (Math.abs(currentY - downY) > Math.abs(currentX - downX)

&& Math.abs(currentY - downY) > mTouchSlop) {

System.out.println("上下滑动");

// 上下滑动

if (currentY - downY > 0) {

// 向下

mFirstTarget = findTargetView(ev, ORIENTATION_DOWN);

} else {

// 向上

mFirstTarget = findTargetView(ev, ORIENTATION_UP);

}

mFirstTarget = null;

}

break;

④处理CANCEL或者UP事件:

如果事件是Cancel或者Up,则表示本次触摸事件结束了,那么将mFirstTarget置空,方便接收下一次DOWN事件:

[java] view
plaincopy

case MotionEvent.ACTION_CANCEL:

case MotionEvent.ACTION_UP:

mFirstTarget = null;

break;

}

随后,如果mFirstTarget不为空,则表示找到了对应的子view来接收,不需要继续分发事件,则返回true;如果此时mFirstTarget为空,则表示集合中没有能响应本次事件的子view,那么交给super.dispatchTouchEvent(ev)处理:

[java] view
plaincopy

// 走到这里,只要mFirstTarget不为空,则在集合中找到了对应的子view,

// 则返回true,表示本次事件被消耗,不继续分发

if (mFirstTarget != null) {

return true;

} else {

return super.dispatchTouchEvent(ev);

}

重写完了之后,就可以将原本添加ListView的地方用我们写的这个InterceptorFrameLayout添加进去,然后将ListView通过addview添加成InterceptorFrameLayout的孩子。这样就可以达到目的啦,来看看效果:



下面是InterceptorFrameLayout完整代码:

[java] view
plaincopy

package com.example.viewpagerlistview.view;

import java.util.HashMap;

import java.util.Set;

import com.example.viewpagerlistview.application.BaseApplication;

import android.content.Context;

import android.util.AttributeSet;

import android.view.MotionEvent;

import android.view.View;

import android.view.ViewConfiguration;

import android.widget.FrameLayout;

/**

* @author : 苦咖啡

*

* @version : 1.0

*

* @date :2015年4月19日

*

* @blog : http://blog.csdn.net/cyp331203
*

* @desc :

*/

public class InterceptorFrameLayout extends FrameLayout {

/** 代表滑动方向向上 */

public static final int ORIENTATION_UP = 0x1;// 0000 0001

/** 代表滑动方向向下 */

public static final int ORIENTATION_DOWN = 0x2;// 0000 0010

/** 代表滑动方向向左 */

public static final int ORIENTATION_LEFT = 0x4;// 0000 0100

/** 代表滑动方向向右 */

public static final int ORIENTATION_RIGHT = 0x8;// 0000 1000

/** 代表滑动方向的所有方向 */

public static final int ORIENTATION_ALL = 0x10;// 0001 0000

/** 存放view的左上角的x和y坐标 */

static int[] touchLocation = new int[2];

/** 用来代表触发移动事件的最短距离,如果小于这个距离就不触发移动控件,如viewpager就是用这个距离来判断用户是否翻页 */

private int mTouchSlop;

/** 用来记录Down事件发生时的x坐标 */

private float downX;

/** 用来记录Down事件发生时的y坐标 */

private float downY;

/** 用来存放需要自主控制事件分发的子view,以及其对应的滑动方向 */

private HashMap<View, Integer> mViewAndOrientation = new HashMap<View, Integer>();

/** 表示某次事件发生时,找到的mViewAndOrientation中符合条件的子view */

private View mFirstTarget = null;

private ViewConfiguration configuration;

public InterceptorFrameLayout(Context context, AttributeSet attrs,

int defStyleAttr) {

super(context, attrs, defStyleAttr);

init();

}

public InterceptorFrameLayout(Context context, AttributeSet attrs) {

super(context, attrs);

init();

}

public InterceptorFrameLayout(Context context) {

super(context);

init();

}

private void init() {

configuration = ViewConfiguration.get(getContext());

}

@Override

public boolean dispatchTouchEvent(MotionEvent ev) {

int action = ev.getAction();

// 意思应该是触发移动事件的最短距离,如果小于这个距离就不触发移动控件,

// 如viewpager就是用这个距离来判断用户是否翻页

mTouchSlop = configuration.getScaledTouchSlop();

if (mFirstTarget != null) {

// mFirstTarget不为空,表示最近的一次DOWN事件已经被mViewAndOrientation集合中的某个子view响应

// 于是将后续的事件继续分发给这个子view

boolean flag = mFirstTarget.dispatchTouchEvent(ev);

// 如果flag=true,表示事件被完全消耗,结束了,如果事件是ACTION_CANCEL或者ACTION_UP,

// 也代表事件的结束,于是将mFirstTarget置空,便于下一次DOWN事件的响应

if (flag

&& (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP)) {

mFirstTarget = null;

}

// 返回flag

return flag;

}

// 拿到本次事件的坐标,由于只需要计算差值,所以getX也可以

final float currentX = ev.getX();

final float currentY = ev.getY();

switch (ev.getAction()) {

case MotionEvent.ACTION_DOWN:

mFirstTarget = findTargetView(ev, ORIENTATION_ALL);

downX = currentX;

downY = currentY;

break;

case MotionEvent.ACTION_MOVE:

if (Math.abs(currentX - downX) / Math.abs(currentY - downY) > 0.5f

&& Math.abs(currentX - downX) > mTouchSlop) {

System.out.print("左右滑动");

// 左右滑动

if (currentX - downX > 0) {

// 右滑

mFirstTarget = findTargetView(ev, ORIENTATION_RIGHT);

System.out.println("mFirstTarget="+mFirstTarget);

} else {

// 左滑

mFirstTarget = findTargetView(ev, ORIENTATION_LEFT);

System.out.println("mFirstTarget="+mFirstTarget);

}

} else if (Math.abs(currentY - downY) / Math.abs(currentX - downX) > 0.5f

&& Math.abs(currentY - downY) > mTouchSlop) {

System.out.print("上下滑动");

// 上下滑动

if (currentY - downY > 0) {

// 向下

mFirstTarget = findTargetView(ev, ORIENTATION_DOWN);

System.out.println("mFirstTarget="+mFirstTarget);

} else {

// 向上

mFirstTarget = findTargetView(ev, ORIENTATION_UP);

System.out.println("mFirstTarget="+mFirstTarget);

}

mFirstTarget = null;

}

break;

case MotionEvent.ACTION_CANCEL:

case MotionEvent.ACTION_UP:

mFirstTarget = null;

break;

}

// 走到这里,只要mFirstTarget不为空,则在集合中找到了对应的子view,

// 则返回true,表示本次事件被消耗,不继续分发

if (mFirstTarget != null) {

return true;

} else {

return super.dispatchTouchEvent(ev);

}

}

/** 在集合中查找对应event和方向参数的view,找到了则返回,没找到返回null */

private View findTargetView(MotionEvent ev, int orientation) {

// mViewAndOrientation为存放要监测触摸事件的子view和对应方向参数的集合

Set<View> keySet = mViewAndOrientation.keySet();

for (View view : keySet) {

Integer ori = mViewAndOrientation.get(view);

// 由于所有的方向参数都是二进制相互与运算为0的

// 所以这里使用与运算来判断方向是否符合

// 这里所有的判断条件是:

// ①该子view在mViewAndOrientation集合内

// ②方向一致

// ③触摸事件落在该子view的范围内

// ④该子view可以消费掉本次事件

// 同时满足上面四个条件,则代表该子view是我们要找的子view,于是返回

if ((ori & orientation) == orientation && isTouchInView(ev, view)

&& view.dispatchTouchEvent(ev)) {

return view;

}

}

return null;

}

public static boolean isTouchInView(MotionEvent ev, View view) {

view.getLocationOnScreen(touchLocation);

float motionX = ev.getRawX();

float motionY = ev.getRawY();

// 返回是否在范围内

return motionX >= touchLocation[0]

&& motionX <= (touchLocation[0] + view.getWidth())

&& motionY >= touchLocation[1]

&& motionY <= (touchLocation[1] + view.getHeight());

}

/** 添加拦截 */

public void addInterceptorView(final View view, final int orientation) {

// 到主线程执行

BaseApplication.getMainThreadHandler().post(new Runnable() {

@Override

public void run() {

if (!mViewAndOrientation.containsKey(view)) {

mViewAndOrientation.put(view, orientation);

}

}

});

}

/** 去除拦截效果 */

public void removeInterceptorView(final View v) {

// 到主线程执行

BaseApplication.getMainThreadHandler().post(new Runnable() {

@Override

public void run() {

if (!mViewAndOrientation.containsKey(v)) {

mViewAndOrientation.remove(v);

}

}

});

}

}

demo项目源码下载:http://download.csdn.net/detail/cyp331203/8621903
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐