遇到的问题:
在上图的例子中,会发现一个问题,就是当手指在顶部轮播图上滑动的时候,如果我们想滑动轮播图,只能在手指非常水平的时候才能让轮播图翻动,而在手指滑动轨迹稍微有一点倾斜的时候,就发现触摸事件被ListView给响应了,变成了上下滑动ListView,这种体验显然不是很好。
假如说我们现在想要一种简单的实现:可能整个应用有很多页面,现在想在当前这个特定的界面,使得当手指在轮播图范围内滑动的时候,当手指轨迹角度<45度的时候(方向上较水平),那么让轮播图响应触摸事件,使得顶部图片能够水平滑动;让当手指手势轨迹角度>45度的时候(方向上较竖直),能够ListView来响应触摸事件,使得整个ListView能够上下滑动,这种效果要如何实现呢?
解决办法:
专栏的上一篇文章中,详细分析了Android的触摸事件的分发流程和ViewGroup的源代码(不熟悉的朋友可以看看:Android自定义控件系列九:从源码看Android触摸事件分发机制)。看过上一篇文章之后,应该了解到,Andrioid事件的分发是一层一层的进行的,最开始分发的时候总是从上层到下层,从活动的Activity开始,到DecorView,然后到我们写的布局,然后再是布局中的其他组件,那么本文的解决办法就是自定义一个ViewGroup,包裹在原来的ListView之外,放在这个特定的界面上。由于事件分发是一层层的进行的,所以我们重写这个外层的自定义ViewGroup的dispatchTouchEvent方法就可以实现控制所有子view的事件分发机制,从而在这个特定的界面实现我们想要的触摸事件的响应机制。
写一个自定的FrameLayout叫InterceptorFrameLayout,重写dispatchTouchEvent(MotionEvent ev)方法,主要解决几个问题:
1、在事件分发的时候,我们得到的是MotionEvent 事件,如何判断这个事件是否落在我们想要的控件区域上呢?
思路:可以在InterceptorFrameLayout中,使用一个Map集合,来存放我们想要控制触摸事件的View和对应的代表方向的参数,对外界暴露add和remove方法,来添加和移除拦截的view对象。然后拿到event事件之后,调用event.getRawX和event.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的值返回。
注意一点:这里我们的方向值定义如下:
- /** 代表滑动方向向上 */
- 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的方向,所以在这个时候,我们只能把事件传递下去,并返回符合条件的子view的view.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<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