Android事件傳遞、多點觸控及滑動衝突的處理
基本概念
- 所有Touch事件都會被封裝MotionEvent, 包括Touch的型別、位置(相對螢幕的絕對位置,相對View的相對位置)、時間、歷史記錄以及第幾個手指(多點觸控)等;
- 事件有多種型別,常用的事件型別有:ACTION_DOWN,ACTION_UP,ACTION_MOVE,ACTION_CANCEL 等;
- 對事件的處理包括三類: 事件傳遞,dispatchTouchEvent(); 攔截,onInterceptTouchEvent(); 消費,onTouchEvent()、OnTouchListener;
傳遞過程
網上有很多資料對事件的分發過程做了詳盡的程式碼追蹤,比如
有興趣的同學可以參考並去詳細走一下,這裡我做一個文字性描述:
傳遞細節描述
- 事件從 Activity.dispatchTouchEvent() 開始傳遞, 依次通過getWindow().superDispatchTouchEvent(event)、mDecor.superDispatchTouchEvent(event) 傳遞,即從Activity-> PhoneWindow ->DecorView, DecorView 是整個 ViewTree 的頂層 ViewGroup ;
- 在整個 ViewGroup 中,事件從頂層開始,依次往子View傳遞;
- 父 ViewGroup 可以通過 onInterceptTouchEvent() 對事件做攔截,阻止其往下傳遞;
- 如果未被攔截,則子 View 可以通過 onTouchEvent() 消費(處理)事件;
- 如果事件從上往下傳遞過程中一直沒有被攔截,且最底層子 View 沒有消費事件,事件會反向往上傳遞,這時父 ViewGroup 可以在 onTouchEvent() 中消費該事件,如果還是沒有被消費的話,最後會到 Activity 的 onTouchEvent() 函式;
- 底層View是具有事件的優先消費權的;
- 如果View 沒有對 ACTION_DOWN 進行消費,此次點選的後續事件不會傳遞過來;
- 如果 View 消費了 ACTION_DOWN ,此次點選的後續事件會直接給這個 View,這裡的後續事件指的是 ACTION_MOVE 和 ACTION_UP 事件;此時,其父 ViewGroup 的 onIntercept 函式仍會被呼叫,仍能進行攔截,但它自己的 onIntercept 不會被呼叫了;
- 子 View 可以在 onTouchEvent 中呼叫 getParent().requestDisallowInterceptTouchEvent(true),這樣父 ViewGroup 的 onIntercept 在後續的事件中就不會被呼叫了;
- 如果第一個事件即 ACTION_DOWN 就被父 ViewGroup 攔截了,子 View 將不會獲取到消費事件的機會;
- OnTouchListener 優先於 onTouchEvent() 對事件進行消費;
- 消費指的是相應的函式返回 true ;
- ViewGroup 才有 onIntercept 方法,View 是沒有的,即View不可以攔截事件;
- 所有的事件處理過程都是以 ACTION_DOWN 開始,ACTION_UP 或者 ACTION_CANCEL 結束,ACTION_UP 是事件正常處理邏輯的結束標誌,ACTION_CANCEL 是由父 ViewGroup 主動發出,當父 ViewGroup 攔截了除 ACTION_DOWN 之外的事件,會給正在消費 ACTION_DOWN 並等待後續事件的子 View 傳送一個 ACTION_CANCEL 事件,通知子 View 結束自己的事件等待;
TouchTarget
關於第7、8兩點,ViewGroup是如何在 dispatchTouchEvent 過程中快速命中並分發到對應子 View 的呢?這裡是通過 TouchTarget 這個結構來實現的。
private static final class TouchTarget {
private static final int MAX_RECYCLED = 32;
// 用於控制同步的鎖
private static final Object sRecycleLock = new Object[0];
// 注意這是static型別的,內部可複用例項連結串列表頭
private static TouchTarget sRecycleBin;
// 內部可複用的例項連結串列的長度
private static int sRecycledCount;
public static final int ALL_POINTER_IDS = -1; // all ones
// 當前被觸控的 View
public View child;
// 對目標捕獲的所有指標的指標id的組合位掩碼
public int pointerIdBits;
// 連結串列中指向的下一個目標
public TouchTarget next;
private TouchTarget() {
}
...
}
複製程式碼
在ViewGroup中維護了一個變數:mFirstTouchTarget,這是在 ViewGroup 中維護的連結串列, 用於記錄當前響應事件序列的子 View (一個事件序列對應一個響應它的子View),mFirstTouchTarget 指向連結串列首部。
先看一下 mFirstTouchTarget 的賦值:
// 這是發生在ViewGroup中的dispatchTouchEvent方法中
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
...
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
newTouchTarget = addTouchTarget(child, idBitsToAssign);
}
// 當響應事件的目標child View新增到連結串列中,同時讓 mFirstTouchTarget 指向連結串列的表頭
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;
mFirstTouchTarget = target;
return target;
}
複製程式碼
再看 mFirstTouchTarget 在 dispatchTouchEvent 方法中的使用:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (onFilterTouchEventForSecurity(ev)) {
final int action = ev.getAction();
final int actionMasked = action & MotionEvent.ACTION_MASK;
1、如果事件是 ACTION_DOWN 事件,重置 touchTargets 狀態,在 cancelAndClearTouchTargets 方法中會發出 ACTION_CANCEL 事件
if (actionMasked == MotionEvent.ACTION_DOWN) {
cancelAndClearTouchTargets(ev);
resetTouchState();
}
2、對於一個事件序列,當其中某一個事件成功攔截時,那麼對於剩下的一系列事件也會被攔截,並且不會再次執行onInterceptTouchEvent方法。如果 ACTION_DOWN 事件被攔截了,即當前ViewGroup的 onInterceptTouchEvent(ev) return true;此時 mFirstTouchTarget 必然為null,後續的事件都會當前 ViewGroup 攔截不再傳遞
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
intercepted = true;
}
3、如果事件既沒有cancel,也沒有被 intercept,遍歷子View進行事件分發
if (!canceled && !intercepted) {
...
}
4、事件分發過程中,如果dispatchTouchEvent返回了false,或者說當前的ViewGroup沒有子元素的話,會走到這個邏輯。mFirstTouchTarget == null說明子View並沒有消費事件,所以沒有對mFirstTouchTarget進行賦值。這裡child == null,程式碼會進一步執行super.dispatchTouchEvent(event),即 View 中的 dispatchTouchEvent 方法
if (mFirstTouchTarget == null) {
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
5、mFirstTouchTarget != null, 說明事件被子View消費,此時會依次將事件分發到 mFirstTouchTarget 儲存的連結串列 View中
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
...
target = next;
}
}
}
if (!handled && mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
}
return handled;
}
複製程式碼
這個地方重點關注一下1、2、3、4、5幾個註釋點。現在我們回到7 8兩點。
如果View 沒有對 ACTION_DOWN 進行消費,此次點選的後續事件不會傳遞過來。這個很顯然,如果沒有對 ACTION_DOWN 進行消費,就不會被儲存到 TouchTarget 連結串列中,後續事件的分發是直接往這個連結串列中進行分發的。
如果 View 消費了 ACTION_DOWN ,此次點選的後續事件會直接給這個 View,這裡的後續事件指的是 ACTION_MOVE 和 ACTION_UP 事件;此時,其父 ViewGroup 的 onIntercept 函式仍會被呼叫,仍能進行攔截,但它自己的 onIntercept 不會被呼叫了。這個可以從第2點註釋中找到答案,如果事件被消費了,mFirstTouchTarget != null, 後續事件可以從mFirstTouchTarget 連結串列中直接分發,同時後續事件過來的時候會跳過intercepted 的判斷,所以自己的 onIntercept 就不會呼叫了。
RecyclerView 的事件傳遞
這裡以點選 RecyclerView 中的某個Item中的 Button 為例:
點下Button
- 產生了一個down事件,activity-->phoneWindow-->ViewGroup-->ListView-->botton,中間如果有重寫了攔截方法,則事件被該view攔截可能消耗;
- 沒攔截,事件到達了button,這個過程中建立了一條事件傳遞的view連結串列;
- 到button的dispatch方法-->onTouch-->view是否可用-->Touch代理;
移動點選按鈕的時候
- 產生move事件,RecyclerView 中會對move事件做攔截;
- 此時 RecyclerView 會將該滑動事件消費掉;
- 後續的滑動事件都會被 RecyclerView 消費掉;
- Button之前已經處理了 down 事件,現在還在等著後續事件,這個時候 RecyclerView 就會發出 cancel 事件通知Button不要再等了
手指擡起 前面建立了一個view連結串列,RecyclerView 的父view在獲取事件的時候,會直接取連結串列中的RecyclerView 讓其進行事件消耗
有興趣的同學可以帶著這個步驟去追蹤 RecyclerView 的原始碼。
多點觸控
多點觸控涉及到了多個手指點選事件的處理,這裡要增加兩個額外的事件
- ACTION_POINTER_DOWN:額外⼿手指按下(按下之前已經有別的⼿手指觸控到 View)
- ACTION_POINTER_UP:有⼿手指擡起,但不不是最後⼀一個(擡起之後,仍然還有別的⼿手指在觸控著 View)
事件型別: ACTION_POINTER_UP; active pointer index: 0; pointer: x: 200, y: 300, index: 0, id: 1; pointer: x: 300, y: 500, index: 1, id: 2
多點觸控觸控事件的結構
- 觸控事件是按序列列來分組的,每⼀一組事件必然以 ACTION_DOWN 開頭,以 ACTION_UP 或 ACTION_CANCEL 結束;
- ACTION_POINTER_DOWN 和 ACTION_POINTER_UP 和 ACTION_MOVE ⼀一樣,只是事件序列列中 的組成部分,並不不會單獨分出新的事件序列列;
- 同⼀一時刻,⼀一個 View 要麼沒有事件序列列,要麼只有⼀一個事件序列列;
- 多點觸控要解決的問題之一是:手指觸控的順序,手指的區分,這兩個問題通過 index 和 id 來區分;
- 多點觸控要解決的問題二:多點觸控時滑動了一個手指,這時候要知道動的是哪個
多點觸控的三種類型
- 接⼒力力型 同⼀一時刻只有⼀一個 pointer 起作⽤用,即最新的 pointer。 典型:ListView、 RecyclerView。 實現⽅方式:在 ACTION_POINTER_DOWN 和 ACTION_POINTER_UP 時記錄下最 新的 pointer,在之後的 ACTION_MOVE 事件中使⽤用這個 pointer 來判斷位置。
- 配合型 所有觸控到 View 的 pointer 共同起作⽤用。 典型:ScaleGestureDetector,以及 GestureDetector 的 onScroll() ⽅方法判斷。 實現⽅方式:在 每個 DOWN、POINTER_DOWN、POINTER_UP、UP 事件中使⽤用所有 pointer 的座標來共同更更新焦點座標,並在 MOVE 事件中使⽤用所有 pointer 的座標來判斷位置。
- 各⾃自為戰型 各個 pointer 做不不同的事,互不不影響。 典型:⽀支援多畫筆的畫板應⽤用。 實現⽅方式: 在每個 DOWN、POINTER_DOWN 事件中記錄下每個 pointer 的 id,在 MOVE 事件中使⽤用 id 對 它們進⾏行行跟蹤。
滑動衝突處理
什麼是滑動衝突?就是父 View 和子 View 都需要處理滑動,例如父 View 需要左右滑動,子 View 需要上下滑動(ViewPager 巢狀 RecyclerView),一個點選事件,到底交給誰處理?
首先我們需要定義好處理規則,然後我們在父 View 的 onIntercept、子 View 的 onTouchEvent 以及父 View 的 onTouchEvent 函式中實現我們定義的規則即可。例如父 View 的 onIntercept 中,如果發現是左右滑動,那就攔截,否則不攔截。
NestedScrollView 巢狀 RecyclerView 也是一樣的道理,NestedScrollView 發現是上下滑動,就直接攔截並處理,RecyclerView 就沒有處理的機會了。
參考文章