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<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的值返回。

@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);
			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<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://blog.csdn.net/cyp331203/article/details/45198549,非允许请勿用于商业或盈利用途,违者必究。

时间: 2024-10-29 11:54:46

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

android自定义控件系列教程----视图的测量和布局

前面说点什么 当我们的一个视图界面绘制在android屏幕上面的时候其实都必须经过这几步measure. layout.draw这几个阶段,我们可以在view类里面看到这几个函数,然后里面有几个函数是onmeasure.onlayout.ondraw这几个函数是我们重写控件需要注意的这几个函数,下面我们就来讲讲这几个函数的功能和作用. onMeasure 正如这个函数的名子一样就是测量,所有的图示其实系统在绘制之前都不知道它到底有多大的,所以在很多时候我们在初始化界面oncreate的时候直接去

Android自定义控件系列八:详解onMeasure()(二)--利用onMeasure测量来实现图片拉伸永不变形,解决屏幕适配问题

上一篇文章详细讲解了一下onMeasure/measure方法在Android自定义控件时的原理和作用,参看博文:Android自定义控件系列七:详解onMeasure()方法中如何测量一个控件尺寸(一),今天就来真正实践一下,让这两个方法大显神威来帮我们搞定图片的屏幕适配问题. 请尊重原创劳动成果,转载请注明出处:http://blog.csdn.net/cyp331203/article/details/45038329,非允许请勿用于商业或盈利用途,违者必究. 使用ImageView会遇到

Android自定义控件系列七:详解onMeasure()方法中如何测量一个控件尺寸(一)

转载请注明出处:http://blog.csdn.net/cyp331203/article/details/45027641 自定义view/viewgroup要重写的几个方法:onMeasure(),onLayout(),onDraw().(不熟悉的话可以查看专栏的前几篇文章:Android自定义控件系列二:自定义开关按钮(一)). 今天的任务就是详细研究一下protected void onMeasure(int widthMeasureSpec, int heightMeasureSpe

android自定义控件系列教程-----touch事件的传递

前沿: 很久没有写过博客了,因为工作的原因很少有时间写东西了,最近想写一个UI系列的博客,因为我发现这一系列的都很少,而且没有那么系统,这里我想以我自己的观点来阐述一下如何自定义android 控件系列. 自定义控件阐述: 在我的理解里面自定义控件,需要了解到touch事件的传递.分发.拦截机制,Scroller类的运用,andorid 视图的理解,ViewGroup的熟悉,因为我们绝大多的控件都是继承自ViewGroup,还有就是要学会布局测量等. Touch事件的传递 首先我们要了解在and

[转]Android自定义控件系列五:自定义绚丽水波纹效果

出处:http://www.2cto.com/kf/201411/353169.html 今天我们来利用Android自定义控件实现一个比较有趣的效果:滑动水波纹.先来看看最终效果图: 图一 效果还是很炫的:饭要一口口吃,路要一步步走,这里我们将整个过程分成几步来实现 一.实现单击出现水波纹单圈效果: 图二 照例来说,还是一个自定义控件,这里我们直接让这个控件撑满整个屏幕(对自定义控件不熟悉的可以参看我之前的一篇文章:Android自定义控件系列二:自定义开关按钮(一)).观察这个效果,发现应该

Android自定义控件系列二:如何自定义属性

上一篇Android自定义控件系列一:如何测量控件尺寸 我们讲了如何确定控件的属性,这篇接着也是讲个必要的知识-如何自定义属性.对于一个完整的或者说真正有实用价值的控件,自定义属性是必不可少的. 如何为控件定义属性 在res/values/attrs.xml(attrs.xml如果不存在,可以创建个)中使用<declare-styleable>标签定义属性,比如我想定义个显示头像的圆形的图片控件(AvatarImageView): 01.<?xml version="1.0&q

Android自定义控件系列三:自定义开关按钮(三)--- 自定义属性

尊重原创,转载请注明出处:http://blog.csdn.net/cyp331203/article/details/40855377 接之前的:Android自定义控件系列二:自定义开关按钮(一)和Android自定义控件系列三:自定义开关按钮(二)继续,今天要讲的就是如何在自定义控件中使用自定义属性,实际上这里有两种方法,一种是配合XML属性资源文件的方式,另一种是不需要XML资源文件的方式:下面我们分别来看看: 一.配合XML属性资源文件来使用自定义属性: 那么还是针对我们之前写的自定义

Android自定义控件系列一:如何测量控件尺寸

测量控件尺寸(宽度.高度)是开发自定义控件的第一步,只有确定尺寸后才能开始画(利用canvas在画布上画,我们所使用的控件实际上都是这样画上去的).当然,这个尺寸是需要根据控件的各个部分计算出来的,比如:padding.文字大小,间距等. 非容器控件的onMeasure 下面我们就来看看如何给非容器控件(即直接extends View)这只尺寸的: 1.@Override 2.protected void onMeasure(int widthMeasureSpec, int heightMea

Android自定义控件系列之应用篇——圆形进度条

一.概述 在上一篇博文中,我们给大家介绍了Android自定义控件系列的基础篇.链接:http://www.cnblogs.com/jerehedu/p/4360066.html 这一篇博文中,我们将在基础篇的基础上,再通过重写ondraw()方法和自定义属性实现圆形进度条,效果如图所示: 二.实现步骤   1.  编写自定义组件MyCircleProgress扩展View public class MyCircleProgress extends View { - } 2.  在MyCircl