Android自定義控制元件系列 十:利用新增自定義佈局來搞定觸控事件的分發,解決組合介面中特定控制元件響應特定方向的事件
這個例子是比較有用的,基本上可以說,寫完這一次,以後很多情況下,直接拿過來addView一下,然後再addInterceptorView一下,就可以輕輕鬆鬆的達到組合介面中特定控制元件來響應特定方向的觸控事件了。
在寫Android應用的過程之中,經常會遇到這樣的情況:介面包含了多個控制元件,我們希望觸控在介面上的不同滑動動作能被不同的控制元件所接收,或者在介面不同位置滑動的動作能被不同的控制元件所接收,換句話說,能否讓特定子view響應特定方向的觸控事件?一個典型的例子就是ListView和Header的組合:
遇到的問題:
在上圖的例子中,會發現一個問題,就是當手指在頂部輪播圖上滑動的時候,如果我們想滑動輪播圖,只能在手指非常水平的時候才能讓輪播圖翻動,而在手指滑動軌跡稍微有一點傾斜的時候,就發現觸控事件被ListView
假如說我們現在想要一種簡單的實現:可能整個應用有很多頁面,現在想在當前這個特定的介面,使得當手指在輪播圖範圍內滑動的時候,當手指軌跡角度<45度的時候(方向上較水平),那麼讓輪播圖響應觸控事件,使得頂部圖片能夠水平滑動;讓當手指手勢軌跡角度>45度的時候(方向上較豎直),能夠ListView來響應觸控事件,使得整個ListView能夠上下滑動,這種效果要如何實現呢?
解決辦法:
專欄的上一篇文章中,詳細分析了Android的觸控事件的分發流程和ViewGroup的原始碼(不熟悉的朋友可以看看:
寫一個自定的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);
}
}
});
}
}