1 概述
當Android系統捕獲到觸摸事件后,如何準確地傳遞給真正需要這個事件的View呢?Android系統給我們提供了一整套完善的事件分發機制,來幫助開發者完成準確的事件分發。
2 預備知識
2.1 觸摸事件(MotionEvent)
一次手指觸碰屏幕的行為所產生的事件序列中典型的事件類型有如下幾種:
ACTION_DOWN ----- 手指剛接觸屏幕
ACTION_MOVE ----- 手指在屏幕上移動
ACTION_UP ----- 手指從屏幕上松開的一瞬間
正常情況下,一次手指觸碰屏幕的行為會觸發的觸摸事件序列包含如下幾種情況:
點擊屏幕后立即松開,事件順序為 ACTION_DOWN -> ACTION_UP
點擊屏幕后滑動一會再松開,事件順序為 ACTION_DOWN -> ACTION_MOVE ->...-> ACTION_MOVE -> ACTION_UP
對于多指觸摸的場景則在ACTION_DOWN 和 ACTION_UP之間會成對出現ACTION_POINTER_DOWN 和 ACTION_POINTER_UP
上面兩種情況是典型的事件序列。通過MotionEvent對象我們可以得到點擊事件發生的x和y坐標,MotionEvent提供了兩組方法:getX/getY返回的是相對于當前View左上角的x和y坐標,getRawX/getRawY返回的是相對于手機屏幕左上角的x和y坐標。
2.2 FLAG_DISALLOW_INTERCEPT 標記
當View被設置了該標記(調用requestDisallowInterceptTouchEvent方法)時該View將不再攔截觸摸事件,直到接收到ACTION_DOWN類型觸摸事件(觸摸事件序列的第一個觸摸事件)時會清空該標記。
3 觸摸事件序列分發和處理機制
當一個觸摸事件序列產生后,它的分發順序如下:
**Activity -> Window -> DecorView -> ... -> 目標View **
然后在不被攔截的情況下,觸摸事件會被傳遞到觸摸位置對應的目標View,分發完成后就要處理觸摸事件了,處理順序是從最底層View向Activity進行的。
3.1 Activity對觸摸事件的分發和處理過程
當一個觸摸事件產生時,觸摸事件最先分發給Activity,接著調用Activity的dispatchTouchEvent來進行觸摸事件的分發:
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}
首先事件分發給Activity所附屬的Window,如果返回true,即消耗了觸摸事件,整個觸摸事件的分發和處理過程就結束了,返回false意味著事件沒有View消耗該觸摸事件,那么Activity的onTouchEvent()方法就會被調用:
public boolean onTouchEvent(MotionEvent event) {
if (mWindow.shouldCloseOnTouch(this, event)) {
finish();
return true;
}
return false;
}
可以看到Window的shouldCloseOnTouch方法會被調用:
public boolean shouldCloseOnTouch(Context context, MotionEvent event) {
final boolean isOutside =
event.getAction() == MotionEvent.ACTION_DOWN && isOutOfBounds(context, event)
|| event.getAction() == MotionEvent.ACTION_OUTSIDE;
if (mCloseOnTouchOutside && peekDecorView() != null && isOutside) {
return true;
}
return false;
}
可以看到當觸摸事件在Activity的范圍外并且mCloseOnTouchOutside為true時該觸摸事件被消耗,從而在onTouchEvent方法中會finish該Activity,mCloseOnTouchOutside可以通過Activity的setFinishOnTouchOutside方法設置的,當將Activity設置為Dialog的樣式時,點擊Activity的之外的屏幕區域自動隱藏Activity就可以通過該方法實現(android 14版本該屬性默認設置為true)。
3.2 Window對觸摸事件的分發過程
Window.superDispatchTouchEvent()是一個抽象方法,通過閱讀相關文檔和源碼可知是Window的唯一實現類是PhoneWindow。查看PhoneWindow的superDispatchTouchEvent方法:
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}
可以看到PhoneWindow將事件直接分發給mDecor處理,DecorView中superDispatchTouchEvent方法源碼如下:
public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
}
DecorView繼承自FrameLayout,所以mDecor對觸摸事件的分發和處理與ViewGroup一樣,下面會進行詳細講解。
3.3 ViewGroup對觸摸事件的分發和處理過程
根據上圖給出我對于ViewGroup觸摸事件分發和處理機制的結論:
單指觸摸場景(紅色布局為父布局,紅色、紫色和藍色布局是紅色布局的子布局):
1> 在正常情況下,同一個觸摸事件序列中所有的觸摸事件只被一個View消耗。
上圖中從A點滑動到B點,A點的觸摸事件被紅色布局消耗,那么從A點到B點所有觸摸事件都會分發給紅色布局處理
上圖中從B點滑動到C點,B點的觸摸事件被黃色布局消耗,那么從B點到C點所有觸摸事件都會分發給黃色布局處理
2> 某個View一旦開始處理事件,如果它不消耗ACTION_DOWN事件,那么同一事件序列中的其他事件都不會再交給它來處理。
上圖中從B點滑動到C點,若B點的觸摸事件未被黃色布局消耗,那么從之后的所有觸摸事件都不會分發給黃色布局處理
3> 如果View不消耗除ACTION_DOWN以外的其他事件,那么這個觸摸事件會消失,并且父元素的onTouchEvent()并不會被調用,
并且當前View可以持續收到后續的事件,最終這些消失的點擊事件會傳遞給Activity處理
上圖中從B點滑動到C點,若B點的觸摸事件被黃色布局消耗,那么從之后的所有觸摸事件都會分發給黃色布局處理,
如果黃色布局不消耗之后的觸摸事件,那么紅色布局也不會消耗之后的觸摸事件。
4> View的onTouchEvent的返回值取決于它是否可點擊的(clickable和longClickable屬性),如果這兩個屬性都為false的話,
onTouchEvent就會返回false,其余情況都返回true。View的longClickable屬性默認為flase,clickable屬性要分情況,比如Button的clickable
屬性默認為true,而TextView的clickable屬性默認為false。注意,setOnClickListener()會自動將View的clickable屬性設為true。
View的enable屬性不影響onTouchEvent()的返回值。哪怕一個View是disable狀態的,只要它的clickable或者longClickable有一個為true,
那么它的onTouchEvent就會返回true。
5> 如果View的enable屬性被設置為false,此時無法響應點擊事件,可以通過設置setAllowClickWhenDisabled方法
允許disable狀態下響應點擊事件,很多應用登陸界面的登陸按鈕即是是灰色也響應點擊事件,也許就是這樣實現的。
多指觸摸場景:
1> 紅色布局、黃色布局 和 紫色布局是同一層級布局
第一個手指觸摸到A點并且消耗掉ACTION_DOWN事件,第二個手指觸摸到B點,分發給B點(黃色布局)的是ACTION_DOWN事件
第一個手指觸摸到B點并且消耗掉ACTION_DOWN事件,第二個手指觸摸到C點,分發給C點(紫色布局)的是ACTION_DOWN事件
2> 紅色布局是黃色布局的父布局
第一個手指觸摸到A點并且消耗掉ACTION_DOWN事件,第二個手指觸摸到B點,分發給B點(紅色布局)的是ACTION_POINTER_DOWN事件
下面在源碼(ViewGroup的dispatchTouchEvent方法)的層面來講解觸摸事件的分發和處理機制,首先是事件序列第一個事件(ACTION_DOWN類型觸摸事件)對于的初始化邏輯:
// 處理ACTION_DOWN類型觸摸事件(觸摸事件序列的第一個觸摸事件)
if (actionMasked == MotionEvent.ACTION_DOWN) {
// 將mFirstTouchTarget鏈表清空,即mFirstTouchTarget會被置為null
// 清空PFLAG_CANCEL_NEXT_UP_EVENT標記
cancelAndClearTouchTargets(ev);
// 清空FLAG_DISALLOW_INTERCEPT標記
resetTouchState();
}
在這里我們先來了解mFirstTouchTarget成員變量,該變量在事件分發過程中起著很重要的作用,mFirstTouchTarget是TouchTarget類型變量,接下來看一下TouchTarget主要成員變量:
// 消耗觸摸事件的View
public View child;
// 多點觸摸時每個觸摸點都會攜帶一個pointerId,pointerIdBits包含被View消耗的觸摸點的pointerId的組合
public int pointerIdBits;
// 鏈表中的下一個元素
public TouchTarget next;
mFirstTouchTarget為鏈表中的第一個元素,對于mFirstTouchTarget有如下結論:
1> 單點觸摸:mFirstTouchTarget鏈表只包含一個TouchTarget對象
2> 多點觸摸并且所有觸摸點事件被同一個View消耗:mFirstTouchTarget鏈表只包含一個TouchTarget對象。
3> 多點觸摸并且所有觸摸點事件被多個View消耗:mFirstTouchTarget成為鏈表,每一個TouchTarget對象對應一個消耗觸摸點事件的View。
繼續看dispatchTouchEvent方法中攔截觸摸事件相關的源碼:
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
// 根據onInterceptTouchEvent的返回結果來決定該View是否攔截觸摸事件
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
// 設置FLAG_DISALLOW_INTERCEPT標記時,該View將不會攔截觸摸事件
intercepted = false;
}
} else {
// 對于之前的ACTION_DOWN類型觸摸事件沒有被其子布局消耗并且當前觸摸事件不是ACTION_DOWN類型情況,該View將會持續攔截觸摸事件
intercepted = true;
}
繼續看dispatchTouchEvent方法的幾個變量:
// 由于在3.3中的第一段代碼中間接的調用了resetCancelNextUpFlag方法,因此這里調用resetCancelNextUpFlag方法返回false
final boolean canceled = resetCancelNextUpFlag(this)
|| actionMasked == MotionEvent.ACTION_CANCEL;
// 當設置FLAG_SPLIT_MOTION_EVENTS標記時(通過調用setMotionEventSplittingEnabled方法設置),則支持觸摸事件拆分,即支持多點觸控。
// 當API版本大于等于Build.VERSION_CODES.HONEYCOMB(11)時,則默認設置FLAG_SPLIT_MOTION_EVENTS標記。
final boolean split = (mGroupFlags & FLAG_SPLIT_MOTION_EVENTS) != 0;
// 用來標記該觸摸事件被View消耗時對應的TouchTarget對象
TouchTarget newTouchTarget = null;
// 用來標記該觸摸事件是否被新的View消耗
boolean alreadyDispatchedToNewTouchTarget = false;
接下來是遍歷子View尋找ACTION_DOWN或者ACTION_POINTER_DOWN類型觸摸事件的對應的子View然后分發和處理該遞觸摸事件:
// 當觸摸事件未被取消并且未被攔截時進入到觸摸事件分發和處理的邏輯
if (!canceled && !intercepted) {
// 只針對ACTION_DOWN或者ACTION_POINTER_DOWN類型的觸摸事件(鼠標相關的事件觸摸事件類型ACTION_HOVER_MOVE不考慮)
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
// 代表多點觸摸中第actionIndex個觸摸點
final int actionIndex = ev.getActionIndex(); // always 0 for down
// 這里只考慮支持多點觸摸的情況,即split為true
// 1 << ev.getPointerId(actionIndex)是對0000 0001左移pointerId位,一般情況pointerId從0開始,每次+1,
// 例如 0對應0000 0001,2對應0000 0100。
final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
: TouchTarget.ALL_POINTER_IDS;
// Clean up earlier touch targets for this pointer id in case they
// have become out of sync.
removePointersFromTouchTargets(idBitsToAssign);
final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
final float x = ev.getX(actionIndex);
final float y = ev.getY(actionIndex);
// 下面刪除了子View繪制排序的相關源碼,即認為customOrder == false,有興趣的同學可以參照源碼
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = i;
final View child = children[i];
// 當子VIew可見性為VISIBLE或者正在執行動畫,則canViewReceivePointerEvents方法返回true
// 當子View的范圍包含觸摸事件,則isTransformedTouchPointInView返回true
if (!canViewReceivePointerEvents(child)
|| !isTransformedTouchPointInView(x, y, child, null)) {
continue;
}
// getTouchTarget方法用來在mFirstTouchTarget鏈表中查找包含child的TouchTarget對象
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// 找到了TouchTarget對象newTouchTarget并且其包含的觸摸事件和當前觸摸事件都在child范圍內,
// 則將當前觸摸事件的idBitsToAssign添加到newTouchTarget中。
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
resetCancelNextUpFlag(child);
// dispatchTransformedTouchEvent方法返回true代表child消耗了當前觸摸事件
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// addTouchTarget方法使用child和idBitsToAssign創建一個TouchTarget對象并將其放在mFirstTouchTarget鏈表的頭部,
// 返回值為該TouchTarget對象。
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
}
}
if (newTouchTarget == null && mFirstTouchTarget != null) {
// mFirstTouchTarget != null代表之前的ACTION_DOWN類型觸摸事件被某個子View消耗
// newTouchTarget == null代表當前的ACTION_POINTER_DOWN類型觸摸事件的未找到消耗其的子View,
// 那么就讓mFirstTouchTarget的第一個TouchTarget對象包含的View消耗當前觸摸事件
newTouchTarget = mFirstTouchTarget;
while (newTouchTarget.next != null) {
newTouchTarget = newTouchTarget.next;
}
newTouchTarget.pointerIdBits |= idBitsToAssign;
}
}
}
接下來就是分發和處理摸事件的邏輯:
if (mFirstTouchTarget == null) {
// mFirstTouchTarget == null 代表沒有子View處理當前的觸摸事件,則交給當前View處理
// dispatchTransformedTouchEvent方法的參數child傳null代表當前觸摸事件交給當前View處理
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// 遍歷mFirstTouchTarget鏈表尋找處理當前觸摸事件的TouchTarget對象
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
// alreadyDispatchedToNewTouchTarget為true代表當前觸摸事件是ACTION_DOWN
// 或者ACTION_POINTER_DOWN類型并且已經被消耗掉,因此不需要再次被分發和處理
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
// dispatchTransformedTouchEvent方法返回true代表target.child消耗了當前觸摸事件
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
// 如果攔截了當前觸摸事件,則移除之前觸摸事件對應的TouchTarget對象
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}
滑動沖突
對于Android開發者,相信對滑動沖突大家一定不陌生,下面就來介紹滑動沖突和解決滑動沖突(利用上面介紹的事件傳遞、處理的機制解決)。
-
常見的滑動沖突場景
常見的滑動沖突的場景可以分為如下三種:
場景1 --- 外部滑動方向和內部滑動方向不一致
場景2 --- 外部滑動方向和內部滑動方向一致
場景3 --- 上面兩種情況的嵌套
滑動沖突的場景 - 滑動沖突的處理規則
對于場景1,它的處理規則是:當用戶左右滑動時,需要讓外部的View攔截觸摸事件,當用戶上下滑動時,需要讓內部View攔截觸摸事件。
對于場景2,它沒有既定的處理規則,因為它要根據具體的業務來制定處理規則,即當處于某種狀態下時需要外部View攔截觸摸事件,而處于另外一種狀態時需要內部View攔截觸摸事件。
對于場景3,與場景2相同,必須根據具體業務制定處理規則。 - 滑動沖突的解決方案
對于3種常見的滑動沖突場景,本節將會一一分析各種場景并給出具體的解決方案。無論多復雜的滑動沖突,它們之間的區別僅僅是滑動沖突處理規則不同,所以我們可以拋開滑動沖突處理規則,找到一種不依賴具體的滑動沖突處理規則的通用解決方案,然后根據不同的滑動沖突場景和業務來修改有關滑動沖突處理規則的邏輯即可。
針對滑動沖突,這里給出兩種解決滑動沖突的方案:外部攔截法和內部攔截法。
1> 外部攔截法
所謂外部攔截法是指所有的觸摸事件都會先經過經過父容器的傳遞,從而父容器在需要此觸摸事件的時候就可以攔截此觸摸事件,否者就傳遞給子View。這樣就可以解決滑動沖突的問題,這種方法比較符合觸摸事件的傳遞、處理機制。外部攔截法需要重寫父容器的onInterceptTouchEvent方法,在該方法中根據滑動沖突處理規則做相應的攔截即可,這種方法的典型代碼如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
intercepted = false;
break;
}
case MotionEvent.ACTION_MOVE: {
if (父容器需要當前觸摸事件) {
intercepted = true;
} else {
intercepted = false;
}
break;
}
case MotionEvent.ACTION_UP: {
intercepted = false;
break;
}
default:
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
上述代碼是外部攔截法的典型邏輯,針對不同的滑動沖突場景,只需要修改父容器需要當前觸摸事件這個滑動沖突處理規則即可,其它均不用修改并且不能修改。這里對上述代碼再來解釋一下,在onInterceptTouchEvent方法中,首先是ACTION_DOWN類型的觸摸事件,父容器必須返回false,即不攔截ACTION_DOWN類型的觸摸事件,這是因為一旦父容器攔截了ACTION_DOWN類型的觸摸事件,那么后續處于同一個事件序列的ACTION_MOVE和ACTION_UP類型的觸摸事件就會直接交給父容器處理,這個時候事件就沒法再傳遞給子元素了;其次是ACTION_MOVE類型的觸摸事件,這個類型的觸摸事件可以根據需求來決定是否攔截,如果父容器需要攔截就返回true,否者返回false(與滑動沖突處理規則有關);最后是ACTION_UP類型的觸摸事件,這里必須返回false,考慮一張情況,假設事件交由子元素處理,如果父容器在ACTION_UP類型的觸摸事件時返回了true,就會導致子元素無法接收到ACTION_UP類型的觸摸事件,這個時候子元素中的onClick方法就無法觸發。
2> 內部攔截法
內部攔截法是指父容器不攔截任何觸摸事件,所有的觸摸事件都傳遞給子元素,如果子元素需要此觸摸事件就直接消耗掉,否者就交由父容器進行處理,這種方法和Android中的事件傳遞、處理機制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作,使用起來較外部攔截法稍顯復雜。這種方法需要重寫子元素的dispatchTouchEvent方法和父容器的onInterceptTouchEvent方法,這種方法的典型代碼如下:
子元素的dispatchTouchEvent方法
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
getParent().requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (父容器需要當前觸摸事件) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
父容器的onInterceptTouchEvent方法
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}
上面的代碼是內部攔截法的典型代碼,當面對不同的滑動沖突處理規則時只需要修改里面的條件即可,其它的不需要修改而且也不能修改。除了子元素需要做處理以外,父容器也要默認攔截除了ACTION_MOVE類型觸摸事件的其他事件,這樣子元素調用requestDisallowInterceptTouchEvent(false)方法時,父容器才能繼續攔截所需事件。
參考
- 《Android開發藝術探索》
- 《Android群英傳》