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

        这个例子是比较有用的,基本上可以说,写完这一次,以后很多情况下,直接拿过来addView一下,然后再addInterceptorView一下,就可以轻轻松松的达到组合界面中特定控件来响应特定方向的触摸事件了。


        请尊重原创劳动成果,转载请注明出处:http://blog.csdn.net/cyp331203/article/details/45198549,非允许请勿用于商业或盈利用途,违者必究。


        在写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的右上角坐标,具体判断方法如下:


	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());
	}


/** 在集合中查找对应event和方向参数的view,找到了则返回,没找到返回null */
	private View findTargetView(MotionEvent ev, int orientation) {
		// mViewAndOrientation为存放要监测触摸事件的子view和对应方向参数的集合
		Set 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的值返回。


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

	/** 代表滑动方向向上 */
	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置空。


@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事件做判断使用。


// 拿到本次事件的坐标,由于只需要计算差值,所以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:


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事件:

		case MotionEvent.ACTION_CANCEL:
		case MotionEvent.ACTION_UP:
			mFirstTarget = null;
			break;
		}


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

// 走到这里,只要mFirstTarget不为空,则在集合中找到了对应的子view,
		// 则返回true,表示本次事件被消耗,不继续分发
		if (mFirstTarget != null) {
			return true;
		} else {
			return super.dispatchTouchEvent(ev);
		}


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





下面是InterceptorFrameLayout完整代码:

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 mViewAndOrientation = new HashMap();
	/** 表示某次事件发生时,找到的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 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



请尊重原创劳动成果,转载请注明出处:http://blog.csdn.net/cyp331203/article/details/45198549,非允许请勿用于商业或盈利用途,违者必究。



        

你可能感兴趣的:(Android,Android自定义控件)