接上一篇:Android藝術開發探索第三章————View的事件體系(上)
3.4 View 的事件分發機制
本節介紹 View 的事件分發機制。
這章文字超級多,不過都是精華,盡量不予以刪減書中文字,留著以后忘記了,可以快速復習一遍
3.4.1 點擊事件的傳遞規則
所謂的點擊事件分發,其實就是對 MotionEvent 事件的分發過程,即當一個 MotionEvent 產生以后,系統需要把這個事件傳遞給一個具體的 View ,而這個傳遞的過程就是分發過程。
public boolean dispathTouchEvent ( MotionEvent ev)
用來進行事件的分發。如果事件能夠傳遞給當前 View ,那么此方法一定會被調用,返回結果受當前 View 的 onTouchEvent 和下級 View 的 dispathTouchEvent 方法的影響,表示是否消耗當前事件。
public boolean onInterceptTouchEvent( MotionEvent ev)
在上述方法內部調用,用來判斷是否攔截某個事件,如果當前 View 攔截了某個事件,那么在同一個時間序列當中,此方法不會被再次調用,返回結果表示是否攔截當前事件。
public boolean onTouchEvent( MotionEvent ev)
在 dispatchTouchEvent 方法中調用,用來處理點擊事件,返回結果表示是否消耗當前事件,如果不消耗,則在同一個事件序列中,當前 View 無法再次接收到事件。
我們可以大致了解點擊事件的傳遞規則:對于一個根 ViewGroup 來說,點擊事件產生后,首先會傳遞給它,這時它的 dispathTouchEvent 就會被調用,如果這個 ViewGroup 的 onInterceptTouchEvent 方法返回 true 就表示要攔截當前事件,接著事件就會交給這個 ViewGroup 處理,即它的 onTouch 方法就會被調用;如果這個 ViewGroup 的 onInterceptTouchEvent 方法返回 false,就表示不攔截當前事件,這時候當前事件就會繼續傳遞給它的子元素,就這子元素的 dispathTouchEvent 方法就會被調用,如此反復直到事件被最終處理。
當一個 View 需要處理事件時,如果它設置了 OnTouchListener,那么 OnTouchListene r中的onTooch方法會被回調。這時事件如何處理還要看 onTouch 的返回值,如果返回false,那當前的 View 的方法 OnTouchListener 會被調用;如果返回 true,那么 onTouchEvent 方法將不會被調用。由此可見,給View設置的 OnTouchListener,其優先級比 onTouchEvent 要高,在onTouchEvent 方法中,如果當前設置的有 OnClickListener,那么它的 onClick 方法會用。可以看出,平時我們常用的 OnClickListener,其優先級最低,即處于事尾端。
當一個點擊事件產生后,它的傳遞過程遵循如下順序:Activity > Window > View,即事件總是先傳遞給 Activity, Activity 再傳遞給 Window,最后 Window 再傳遞給頂級 View 頂級 View 接收到事件后,就會按照事件分發機制去分發事件。考慮一種情況,如果一個 View 的 onTouchEvent 返回 false,那么它的父容器的 onTouchEvent 將會被調用,依此類推,如果所有的元素都不處理這個事件,那么這個事件將會最終傳遞給 Activity 處理,即 Activity 的 onTouchEvent 方法會被調用。
這里給出一些結論:
(1)同一個事件序列是指從手指接觸屏幕的那一刻起,到手指離開屏幕的那一刻結束,在這個過程中所產生的一系列事件,這個事件序列以 down 事件開始,中間含有數量不定的 move 事件,最終以 up 事件結束。
(2)正常情況下,一個事件序列只能被一個 View 攔截且消耗。但是通過特殊手段可以做到,比如一個 View 將本該自己處理的事件通過 onTouchEvent 強行傳遞給其他 View 處理。
(3)某個 View 一旦決定攔截,那么這一個事件序列都只能由它來處理(如果事件序列能夠傳遞給它的話),并且它的 onInterceptTouchEvent 不會再被調用。
(4)某個 View 一旦開始處理事件,如果它不消耗 ACTION_DOWN 事件( onTouchEvent 返回了 false),那么同一事件序列中的其他事件都不會再交給它來處理,并且事件將重新交由它的父元素去處理,即父元素的 onTouchEvent 會被調用。
(5)如果 View 不消耗除 ACTION_DOWN 以外的其他事件,那么這個點擊事件會消失,此時父元素的 onTouchEvent 并不會被調用,并且當前 View 可以持續收到后續的事件,最終這些消失的點擊事件會傳遞給 Activity 處理。
(6) ViewGroup 默認不攔截任何事件。 Android 源碼中 ViewGroup 的 onInterceptTouchEvent 方法默認返回 false。
(7)View 沒有 onInterceptTouchEvent 方法,一旦有點擊事件傳遞給它,那么它的 onTouchEvent 方法就會被調用。
(8)View 的 onInterceptTouchEvent 默認都會消耗事件(返回 true),除非它是不可點擊的(clickable 和 longClickable 同時為 false)。View 的 longClickable 屬性默認都為 false ,clickable 屬性要分情況,比如 Button 的 clickable 屬性默認為 true ,而 TextView 的 cilckable 為 false。
(9)View 的enable 屬性不影響 onTouchEvent 的默認返回值。哪怕一個 View 是 disable 狀態的,只要它的 clickable 或者 longClickable 有一個為 true,那么它的 onTouchEvent 就返回 true。
(10)onClick 會發生的前提是當前 View 是可點擊的,并且它收到了 down 和 up 的事件。
(11)事件傳遞過程是由外向內的,即事件總是先傳遞給父元素,然后再由父元素分發給子 View,通過 requestDisallowInterceptTouchEvent 方法可以在子元素中干預父元素的事件分發過程,但是 ACTION_DOWN 事件除外。
3.4.2 事件分發的源碼解析
開始分析源碼,沒有源碼一切都是軟并卵。
- Activity 對點擊事件的分發過程
點擊事件用 MontionEvent 來表示,當一個點擊操作發生時,最先傳遞給當前的 Activity,由 Activity 的 dispatchTouchEvent 來進行事件派發,具體的工作是由 Activity 內部的 Window 來完成的。Window 會將事件傳遞給 decor view,decor view 一般就是當前界面的底層容器(即 setContentView 所設置的 View 的父容器),通過 Activity . getWindow . getDecorView() 可以獲得。先分析 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 的onTouch 都返回了 false,那么 Activity 的onTouchEvent 就會被調用。
接下來看 Window 是如何傳遞給 ViewGroup 的。查看源碼我們知道, Window是一個抽象類,而 Window 的 superDisapatchTouchEvent 方法也是個抽象方法,因此我們必須找到 Window 的實現類才行。
/**
* Abstract base class for a top-level window look and behavior policy. An
* instance of this class should be used as the top-level view added to the
* window manager. It provides standard UI policies such as a background, title
* area, default key processing, etc.
*
* <p>The only existing implementation of this abstract class is
* android.policy.PhoneWindow, which you should instantiate when needing a
* Window. Eventually that class will be refactored and a factory method
* added for creating Window instances without knowing about a particular
* implementation.
*/
public abstract class Window {
...
public abstract boolean superDispatchTouchEvent(MotionEvent event);
...
}
上面這段話的大概意思是:Window 類可以控制頂級 View 的外觀和行為策略,它的唯一實現位于 android.policy.PhoneWindow 中,當你要實例化這個 Window 類的時候,你并不知道它的細節,因為這個類會被重構,只有一個工廠方法可以使用。盡管這看起來有點模糊,不過我們可以看一下 android.policy.PhoneWindow這個類,盡管實例化的時候會被重構,僅是重構而已,功能是類似的。
由于 Window 的唯一實現是 PhoneWindow,因此直接看 PhoneWindow 是如何處理點擊事件的。
public class PhoneWindow extends Window implements MenuBuilder.Callback {
...
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}
...
}
到這里邏輯就很清晰了,PhoneWindow 繼承 Window 重寫 superDispatchTouchEvent 將事件直接傳遞給了 mDecor ,這個 mDecor 是什么?請看下面:
public class PhoneWindow extends Window implements MenuBuilder.Callback {
private DecorView mDecor;
@Override
public final View getDecorView() {
if (mDecor == null) {
installDecor();
}
return mDecor;
}
我們知道,通過((ViewGroup)getWindow().getDecorView().findViewById(android.R.id.content)).getChildAt(0);這中方式就可以獲取 Activity 所設置的 View,這個 mDecor 顯然就是 getWindow().getDecorView() 返回的 View,而我們通過 setContentView 設置的 View 是它的一個子 View。目前事件傳遞到了 DecorView 這里,由于 DecorView 繼承自 FrameLayout 且是父 View ,所以最終事件會傳遞給 View。從這里開始,時間已經傳遞到了頂級 View 了,即在 Activity 中 setContentView 設置的 View,另外頂級 View 也叫根 View,頂級 View 一般來說都是 ViewGroup。
這波源碼看懂了,其實就是一個繼承重寫然后轉移事件的一個套路。
- 頂級 View 對點擊事件的分發過程
// Check for interception.
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 {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
從上面代碼我們可以看出,ViewGroup 在如下兩種情況下會判斷是否要攔截當前事件:事件類型為 ACTION_DOWN 或者 mFirstTouchTarget != null。ACTION_DOWN 事件很好理解,mFirstTouchTarget 這個在后面的代碼邏輯中可以看出來,當事件由 ViewGroup 的子元素成功處理時, mFirstTouchTarget 會被賦值并指向子元素。那么當 ACTION_DOWN 和 ACTION_UP 事件到來時,由于 (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) 這個條件為 false,將導致 ViewGroup 的 onInterceptTouchEvent 不會再被調用,并且同一序列中的其他事件都會默認交給它處理。
當然,這里有一種特殊情況,那就是 FLAG_DISALLOW_INTERCEPT 標記位,這個標記位是通過 requestDisallowInterceptTouchEvent 方法來設置,一般用于子 View 中。FLAG_DISALLOW_INTERCEPT 一旦設置后,ViewGroup 將無法攔截除了 ACTION_DOWN 以外的其他點擊事件。為什么說是除了 ACTION_DOWN 以外的其他事件呢 ?這是因為 ViewGroup 在分發事件時,如果是 ACTION_DOWN 就會重置 FLAG_DISALLOW_INTERCEPT 這個標記位,因此當面對 ACTION_DOWN 事件時,ViewGroup 總是會調用自己的 onInterceptTouchEvent 方法來詢問自己是否要攔截事件,這一點從源碼中也可以看出來。
// Handle an initial down.
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Throw away all previous state when starting a new touch gesture.
// The framework may have dropped the up or cancel event for the previous gesture
// due to an app switch, ANR, or some other state change.
cancelAndClearTouchTargets(ev);
resetTouchState();
}
當 ViewGroup 不攔截事件的時候,事件會向下分發交由它的子 View 進行處理,這段源碼如下所示:
final View[] children = mChildren;
final boolean customOrder = isChildrenDrawingOrderEnabled();
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = customOrder ?
getChildDrawingOrder(childrenCount, i) : i;
final View child = children[childIndex];
if (!canViewReceivePointerEvents(child)
|| !isTransformedTouchPointInView(x, y, child, null)) {
continue;
}
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// Child is already receiving touch within its bounds.
// Give it the new pointer in addition to the ones it is handling.
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
resetCancelNextUpFlag(child);
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// Child wants to receive touch within its bounds.
mLastTouchDownTime = ev.getDownTime();
mLastTouchDownIndex = childIndex;
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
}
上面這段代碼邏輯也很清晰,首先遍歷 ViewGroup 的所有子元素,然后判斷子元素是否能夠接收到點擊事件。是否能夠接收點擊事件主要由兩點來衡量:子元素是否在播動畫和點擊事件的坐標是否落在子元素的區域內。如果某個子元素滿足這兩個條件,那么事件就會傳遞給它來處理。可以看到,dispatchTransformedTouchEvent 實際上調用的就是子元素的 dispatchTouchEvent 方法,在它的內部有如下一段內容,而在上面的代碼中 child 傳遞的不是 null,因此它會直接調用子元素的 dispatchTouchEvent 方法,這樣事件就交由子元素處理了,從而完成了一輪事件分發。
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {...
}---->>>
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
final boolean handled;
// Canceling motions is a special case. We don't need to perform any transformations
// or filtering. The important part is the action, not the contents.
final int oldAction = event.getAction();
if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
event.setAction(MotionEvent.ACTION_CANCEL);
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
}
event.setAction(oldAction);
return handled;
}
如果子元素的 dispatchEvent 返回 true,這時我們暫時不用考慮事件在子元素內部是怎么分發的,那么 mFirstTouchTarget 就會被賦值同時跳出 for 循環,如下所示:
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
...
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
...
}
這幾行代碼完成了 mFirstTouchTarget 的賦值并終止對子元素的遍歷。如果子元素的 dispatchTouchEvent 返回 false,ViewGroup 就會把事件分發給下一個子元素。
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;
mFirstTouchTarget = target;
return target;
}
其實 mFirstTouchTarget 真正的賦值過程是在 addTouchTarget 內部完成的,從下面的 addTouchTarget 方法內部結構可以看出,mFirstTouchTarget 是一種單鏈表結構,mFirstTouchTarget 是否被賦值,將直接影響到 ViewGroup 對事件的攔截策略,如果 mFirstTouchTarget 為 null,那么 ViewGroup 就默認攔截接下來同一序列中所有的點擊事件。
如果遍歷所有的子元素后事件都沒有被適合地處理,這包含兩種情況:第一種是 ViewGroup 沒有子元素,第二種是子元素處理了點擊事件,但是在 dispatchTouchEvent 中返回了 false。一般是第二種情況。在這兩個情況下 ViewGroup 會自己處理點擊事件。
// Dispatch to touch targets.
if (mFirstTouchTarget == null) {
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
}
注意上面這段代碼,這里第三個參數 child 為 null,從前面的分析可以知道,它會調用 super.dispatchTouchEvent(event),很顯然,這里就轉到了 View 的 dispatchTouchEvent 方法,即點擊事件開始交由 View 來處理。
- View 對點擊事件的處理過程
View 對點擊事件的處理過程稍微簡單一些,注意這里的 View 不包含 ViewGroup。先看它的 dispatchTouchEvent 方法,如下所示:
public boolean dispatchTouchEvent(MotionEvent event) {
...
boolean result = false;
...
if (onFilterTouchEventForSecurity(event)) {
...
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
result = true;
}
if (!result && onTouchEvent(event)) {
result = true;
}
}
...
return result;
}
View 對點擊事件的處理過程就比較簡單,因為 View 是一個單獨的元素,它不需要向下傳遞事件,只能自己處理。從上面的源碼可以看出 View 對點擊事件的處理過程,首先會判斷有沒有設置 OnTouchListener,如果 OnTouchListener 中的onTouch 方法返回 true,那么OnTouchListener 就不會被調用,可見 OnTouchListener 的優先級高于 onTouch,這樣做的好處是方便在外界處理點擊事件。
接著再分析 OnTouchEvent 的實現。先看當 View 處于不可用狀態下點擊時間的處理過程,如下所示。很顯然,不可用狀態下的 View 照樣會消耗點擊時間,盡管它看起來不可用。
public boolean onTouchEvent(MotionEvent event) {
...
if ((viewFlags & ENABLED_MASK) == DISABLED) {
if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
setPressed(false);
}
// A disabled view that is clickable still consumes the touch
// events, it just doesn't respond to them.
//禁用視圖,點擊消費仍然觸摸事件,它只是不回應他們
return (((viewFlags & CLICKABLE) == CLICKABLE
|| (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
|| (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE);
}
...
}
接著,如果 View 設置有代理,那么還會執行 TouchDelegate 的 onTouchEvent 方法,這個 onTouchEvent 的工作機制看起來和 OnTouchListener 類似,這里不深入研究了。
if (mTouchDelegate != null) {
if (mTouchDelegate.onTouchEvent(event)) {
return true;
}
}
下面再看一下 onTouchEvent 中對點擊事件的具體處理,如下所示。
if (((viewFlags & CLICKABLE) == CLICKABLE || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
(viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
switch (action) {
case MotionEvent.ACTION_UP:
boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
// 如果我們已經沒有焦點,我們應該在觸摸模式。
boolean focusTaken = false;
if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
focusTaken = requestFocus();
}
if (prepressed) {
// 按鈕被釋放之前,我們實際上顯示為按下。使它顯示按下狀態現在(調度點擊),以確保用戶看到它。
setPressed(true, x, y);
}
if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
// This is a tap, so remove the longpress check
removeLongPressCallback();
// Only perform take click actions if we were in the pressed state
if (!focusTaken) {
// Use a Runnable and post this rather than calling
// performClick directly. This lets other visual state
// of the view update before click actions start.
if (mPerformClick == null) {
mPerformClick = new PerformClick();
}
if (!post(mPerformClick)) {
performClick();
}
}
}
...
break;
...
return true;
從上面代碼來看,只要 View 的 CLICKABLE 和 LONG_CLICKABLE 有一個為 true,那么它就會消耗這個事件,即 onTouchEvent 方法返回 true,不管它是不是 DISABLE 狀態。然后就是當 ACTION_UP 事件發生時,會觸發 performClick 方法,如果 View 設置了 OnClickListener,那么 performClick 方法內部會調用它的 onClick 方法,如下所示:
public boolean performClick() {
final boolean result;
final ListenerInfo li = mListenerInfo;
if (li != null && li.mOnClickListener != null) {
playSoundEffect(SoundEffectConstants.CLICK);
li.mOnClickListener.onClick(this);
result = true;
} else {
result = false;
}
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
return result;
}
View 的 LONG_CLICKABLE 屬性默認為 false,而 CLICKABLE 屬性是否為 false 和具體的 View 有關,確切來說可點擊的 View 其 CLICKABLE 為 true,不可點擊的 View 其 CLICKABLE 為 false,比如 Button 是可點擊的,TextView 是不可點擊的。通過 setClickable 和 setLongClickable 可以分別改變 View 的CLICKABLE 設為 true,setOnLongClickListener 則會自動將 View 的 LONG_CLICKABLE 設為 true,這一點從源碼中可以看出來,如下所示:
public void setOnClickListener(@Nullable OnClickListener l) {
if (!isClickable()) {
setClickable(true);
}
getListenerInfo().mOnClickListener = l;
}
public void setOnLongClickListener(@Nullable OnLongClickListener l) {
if (!isLongClickable()) {
setLongClickable(true);
}
getListenerInfo().mOnLongClickListener = l;
}
到這里,點擊事件的分發機制的源碼實現已經分析完了,結合 3.4.1 節中的理論分析和相關結論,可以更好的理解事件分發。
3.5 View 的滑動沖突
本節開始介紹:滑動沖突。前面 4 節均是為本節服務的,通過本節的學習,滑動沖突將不再是個問題。
3.5.1 常見的滑動沖突場景
常見的滑動沖突場景可以簡單分為三種:
- 場景 1 —— 外部滑動方向和內部滑動方向不一致;
- 場景 2 —— 外部滑動方向和內部滑動方向一致;
- 場景 3 —— 上面兩種情況嵌套。
先說場景 1,主要是將 ViewPager 和 Fragment 配合使用所組成的頁面滑動效果。在這種效果中,可以通過左右滑動來切換頁面,但是每個頁面內部往往又是一個 ListView。本來這種情況下是有滑動沖突的,但是 ViewPager 內部處理了這種滑動沖突,因此采用 ViewPager 時我們我無須關注這個問題,如果我們采用的不是 ViewPager 而是 ScrollView 等,那就必須手動處理滑動沖突了,否則造成的后果就是內外兩層只能有一層滑動,這是因為兩者之間的滑動時間有沖突。
再說場景 2,這種情況稍微復雜一些,當內外兩層都在同一個方向可以滑動的時候,顯然存在邏輯問題。因為當手指開始滑動的時候,系統無法知道用戶到底是想讓哪一層滑動,所以當手指滑動的時候就會出現問題,要么只有一層能滑動,要么就是內外兩層都滑動的很卡頓。
最后說場景 3,場景 3 是場景 1、2兩種情況的嵌套。在許多應用中會有這么一個效果:內存有一個場景 1中的滑動效果,然后外層又有一個場景 2 的滑動效果。具體來說就是,外部有一個 SlideMenu 效果,然后內部有一個 ViewPager,ViewPager 的每一個頁面又是一個 ListView。雖然說場景 3的滑動沖突看起來更加復雜,但是它是幾個單一的滑動沖突疊加的,因此只需要分別處理內層和中層、中層和外層之間的滑動沖突即可,而具體的處理方法其實和場景 1、2相同的。
3.5.2 滑動沖突的處理規則
一般來說,不管滑動沖突多么復雜,它都有既定的規則,根據這些規則我們就可以選擇合適的方法去處理。
對于場景1,它的處理規則是:當用戶左右滑動時,需要讓外部的 View 攔截點擊事件,當用上下滑動的時候,需要讓內部 View 攔截點擊事件。具體來說是:根據水平滑動還是豎直滑動來判斷到底由誰來攔截事件,如圖 滑動過程示意圖 所示,根據滑動過程中兩個點之間的坐標就可以的出來到底是水平滑動還是豎直滑動。如何根據坐標來得到滑動的方向呢?這很簡單,有很多可以參考,比如可以根據滑動路徑和水平方向所形成的夾角,也可以根據水平方向和豎直方向上的距離來判斷,某些特殊時候還可以根據水平和豎直方向的速度來做判斷。這里我們使用哭了差來做判斷。
對于場景 2 來說,比較特殊,它無法根據滑動的角度、距離差以及速度差來做判斷,但是這個時候一般都能在業務上找到突破口,比如業務上有規定:當處于某種狀態時需要外部 View 響應用戶的滑動,而處于另外一種狀態時則需要內部 View 來響應 View 的滑動,根據這種業務上的需求我們也能得出響應的處理規則,有了處理規則同樣可以進行下一步處理。這種場景通過通過文字描述可能比較抽象,等下會通過實際例子來演示。
對于場景 3 來說,它的滑動規則就更復雜了,和場景 2 一樣,它也無法直接根據滑動的角度、距離差以及速度差來做判斷,同樣還是智能從業務上找突破口。
3.5.3 滑動沖突的解決方式
針對場景 1 中的滑動,我們可以根據滑動的距離差來進行判斷,這個距離差就是所謂的滑動規則。如果用 ViewPager 去實現場景 1 中的效果,我們不需要手動處理滑動沖突,因為 ViewPager 已經幫我們做了,所以這里不采用 ViewPager。
- 外部攔截法
所謂的外部攔截法,就是點擊事情都先進過父容器的攔截處理,如果父容器需要此事件就攔截,如果不需要就不攔截,這樣就可以解決滑動沖突的問題,這種方法比較符合點擊事件的分發機制。外部攔截法需要重寫父容器的 onInterceptTouchEvent 方法,在內部做相應的攔截即可,這種方法的偽代碼如下。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean onIntercept = false;
switch (ev.getAction()) {
case ACTION_DOWN:
onIntercept = false;
break;
case ACTION_MOVE:
if (父容器需要當前點擊事件) {
onIntercept = true;
} else {
onIntercept = false;
}
break;
case ACTION_UP:
onIntercept = false;
break;
default:
break;
}
return onIntercept;
}
上述代碼是外部攔截法的典型邏輯,正對不同的滑動沖突,只需要修改父容器需要當前點擊事件這個條件即可,其他均不需要做修改并且也不能修改。這里對上述代碼再描述一下,在 onInterceptTouchEvent 方法中,首先是 ACTION_DOWN 這個事件,父容器必須返回 false,即不攔截 ACTION_DOWN 事件,因為一旦父容器攔截了ACTION_DOWN ,那么后續的 ACTION_MOVE 和 ACTION_UP 都會交由父容器處理,事件就沒辦法再傳遞給子元素了;其次就是 ACTION_MOVE 事件,這個事件可以根據需求來決定是否攔截;最后 ACTION_UP 事件,這里必須要返回 false,因為 ACTION_UP 事件本身沒有太多意義。
考慮到一種情況,假設事件交由子元素處理,如果父容器在 ACTION_UP 時返回了 true,就會導致子元素無法接收到 ACTION_UP 事件,這個時候子元素的 onClick 事件就無法觸發,但是父容器比較特殊,一旦它開始攔截任何一個事件,那么后續的事件都會交給它來處理,而 ACTION_UP 作為最后一個事件也必定可以傳遞給父容器,幾遍父容器的 onInterceptTouchEvent
方法在 ACTION_UP 時返回了 false。
- 內部攔截法
內部攔截法指父容器不攔截任何事件,所有的事件都傳遞給子元素,如果子元素需要此事件就直接消耗掉,否則就交由父容器進行處理,這種方法和 Android 中的事件分發機制不一致,需要配合 requestDisallowInterceptTouchEvent 方法才能正常工作,使用起來比較復雜。它的偽代碼如下,我們需要重寫子元素的 dispatchTouchEvent 方法:
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
parent.requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
Log.d(TAG, "dx:" + deltaX + " dy:" + deltaY);
if (父容器需要此類點擊事件) {
parent.requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
上述代碼是內部攔截法的典型代碼,當面對不同的花哦東策略時只需要修改里面的條件即可。除了子元素需要做處理以外,父元素也要默認攔截除了 ACTION_DOWN 以外的其他事件,這樣當子元素調用 parent.requestDisallowIntercptTouchEvent(false) 方法時,父元素才能繼續攔截所需的事件。
注意一點:ACTION_DOWN 事件不收 FLAG_DISALLOW_INTERCEPT 這個標記位的控制,所以使用內部攔截法,父容器就不能攔截 ACTION_DOWN 事件。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}
下面做一個實例來介紹兩種方法。我們來實現一個類似于 ViewPager 中嵌套 ListView 的效果。為了實現 ViewPager 的效果,我們定義一個類似于水平的 LinerLayout 的東西,只不過它可以水平滑動,初始化時我們在它的內部添加若干個 ListView,這樣一來,由于它內部的 ListView 可以豎直滑動,而它本身又可以水平滑動,因此一個典型的滑動沖突場景就出現了,并且這種沖突屬于類型1的沖突。
首先來看一下 Activity 中的初始化代碼,如下所示。
public class DemoActivity_1 extends AppCompatActivity {
private static final String TAG = "DemoActivity_1";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_demo_1);
initView();
}
//初始化View
private void initView() {
LayoutInflater inflater = getLayoutInflater();
HorizontalScrollViewEx listContainer = (HorizontalScrollViewEx) findViewById(R.id.container);
//獲取屏幕尺寸
int widthPixels = getScreenMetrics(this).widthPixels;
int heightPixels = getScreenMetrics(this).heightPixels;
LogUtil.log(widthPixels + "===" + heightPixels);
for (int i = 0; i < 3; i++) {
ViewGroup layout = (ViewGroup) inflater.inflate(R.layout.content_layout, listContainer, false);
layout.getLayoutParams().width = widthPixels;
TextView textView = (TextView) layout.findViewById(R.id.title1);
textView.setText("page" + (i + 1));
layout.setBackgroundColor(Color.rgb(255 / (i + 1), 255 / (i + 1), 0));
createList(layout);
listContainer.addView(layout);
}
}
//創建ListView
private void createList(ViewGroup layout) {
ListView listView = (ListView) layout.findViewById(R.id.list);
ArrayList<String> mDatas = new ArrayList<>();
for (int i = 0; i < 50; i++) {
mDatas.add("name " + i);
}
ArrayAdapter<String> adapter = new ArrayAdapter<>(this, R.layout.content_list_item, R.id.name, mDatas);
listView.setAdapter(adapter);
listView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Toast.makeText(DemoActivity_1.this, "position = " + position, Toast.LENGTH_SHORT).show();
}
});
}
//獲取屏幕尺寸
private DisplayMetrics getScreenMetrics(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics dm = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(dm);
return dm;
}
}
上述代碼很簡單,就是創建 3 個ListView 并且把 ListView 加入到我們自己定義的HorizontalScrollViewEX 中,這里 HorizontalScrollViewEX 是父容器,而 ListView 就是子元素,關于 HorizontalScrollViewEX 的代碼需要在書中第四章會有詳細的介紹,本文就先不做介紹了。
首先采用外部攔截法來解決這個問題,按照前面的分析,我們只需要修改父容器需要攔截事件的條件即可。對于本例來說,父容器的攔截條件就是滑動過程中水平距離差比豎直距離差大,在這種情況下,父容器就攔截當前點擊事件。
public class HorizontalScrollViewEx extends ViewGroup {
...
@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;
//abortAnimation這一句話主要是為了優化滑動體驗而加入的,可要可不要
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
intercepted = true;
}
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
//當水平方向滑動距離大于豎直方向滑動距離就返回true,攔截事件,反之則不攔截
if (Math.abs(deltaX) > Math.abs(deltaY)) {
intercepted = true;
} else {
intercepted = false;
}
break;
}
case MotionEvent.ACTION_UP: {
intercepted = false;
break;
}
default:
break;
}
Log.d(TAG, "intercepted=" + intercepted);
mLastX = x;
mLastY = y;
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
...
}
當水平方向滑動距離大于豎直方向滑動距離就返回true,父容器攔截事件,ListView 就無法獲取點擊事件,反之父容器不攔截 ACTION_MOVE 事件,事件就傳遞給了 ListView ,這樣 ListView 就能上下滑動了,如此滑動沖突就解決了。
考慮到一種情況,如果用戶正在水平滑動,但是在水平滑動停止之前如果用戶再迅速進行上下滑動,就會導致夾棉在水平方向無法滑動到終點,為了避免這中情況,當水平方向正在滑動時,下一個序列的點擊時間任然交給父容器來處理。
public class HorizontalScrollViewEx extends ViewGroup {
private static final String TAG = "HorizontalScrollViewEx";
private int mChildrenSize;
private int mChildWidth;
private int mChildIndex;
// 分別記錄上次滑動的坐標
private int mLastX = 0;
private int mLastY = 0;
// 分別記錄上次滑動的坐標(onInterceptTouchEvent)
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
private Scroller mScroller;
private VelocityTracker mVelocityTracker;
...
public HorizontalScrollViewEx(Context context, AttributeSet attrs,
int defStyle) {
super(context, attrs, defStyle);
init();
}
private void init() {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
...
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(-deltaX, 0);
break;
}
case MotionEvent.ACTION_UP: {
int scrollX = getScrollX();
int scrollToChildIndex = scrollX / mChildWidth;
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {
mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
} else {
mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
}
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
int dx = mChildIndex * mChildWidth - scrollX;
smoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int measuredWidth = 0;
int measuredHeight = 0;
final int childCount = getChildCount();
measureChildren(widthMeasureSpec, heightMeasureSpec);
int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
if (childCount == 0) {
setMeasuredDimension(0, 0);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
final View childView = getChildAt(0);
measuredHeight = childView.getMeasuredHeight();
setMeasuredDimension(widthSpaceSize, childView.getMeasuredHeight());
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
final View childView = getChildAt(0);
measuredWidth = childView.getMeasuredWidth() * childCount;
setMeasuredDimension(measuredWidth, heightSpaceSize);
} else {
final View childView = getChildAt(0);
measuredWidth = childView.getMeasuredWidth() * childCount;
measuredHeight = childView.getMeasuredHeight();
setMeasuredDimension(measuredWidth, measuredHeight);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childLeft = 0;
final int childCount = getChildCount();
mChildrenSize = childCount;
for (int i = 0; i < childCount; i++) {
final View childView = getChildAt(i);
if (childView.getVisibility() != View.GONE) {
final int childWidth = childView.getMeasuredWidth();
mChildWidth = childWidth;
childView.layout(childLeft, 0, childLeft + childWidth,
childView.getMeasuredHeight());
childLeft += childWidth;
}
}
}
private void smoothScrollBy(int dx, int dy) {
mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
invalidate();
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
@Override
protected void onDetachedFromWindow() {
mVelocityTracker.recycle();
super.onDetachedFromWindow();
}
}
如果采用內部攔截法也是可以的,按照前面對內部攔截法的分析,我們只需要修改 ListView 的 dispatchTouchEvent 方法中的父容器的攔截邏輯,同時讓父容器攔截 ACTION_MOVE 和 ACTION_UP 事件即可。為了重寫 ListView 的 dispatchTouchEvent 方法,我們必須要自定義一個 ListView。
public class ListViewEx extends ListView {
private static final String TAG = "ListViewEx";
private HorizontalScrollViewEx2 mHorizontalScrollViewEx2;
// 分別記錄上次滑動的坐標
private int mLastX = 0;
private int mLastY = 0;
...
public void setHorizontalScrollViewEx2(
HorizontalScrollViewEx2 horizontalScrollViewEx2) {
mHorizontalScrollViewEx2 = horizontalScrollViewEx2;
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
Log.d(TAG, "dx:" + deltaX + " dy:" + deltaY);
if (Math.abs(deltaX) > Math.abs(deltaY)) {
mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
}
除了對 ListView 所做的修改,我們還需要修改 HorizontalScrollViewEx。
public class HorizontalScrollViewEx2 extends ViewGroup {
...
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
mLastX = x;
mLastY = y;
// if (!mScroller.isFinished()) {
// mScroller.abortAnimation();
// return true;
}
return false;
} else {
return true;
}
...
}
上面代碼就是內部攔截法的實例,其中 mScroller.abortAnimation(); 這一句不是必須的,是為了優化體驗增加的,內部攔截法的操作要稍微復雜一些,因此推薦采用外部攔截法來解決常見的滑動沖突。
前面說過,只要我們根據場景 1 的情況來得出通用的解決方案,那么對于場景2 和場景 3 來說我們只需要修改相關滑動規則的邏輯即可,下面我們就來演示如何利用場景 1得出的通用解決方案來解決更復雜的滑動沖突,這里只詳細分析場景 2 中的滑動沖突,對于場景 3 的疊加沖突,都可以拆解為單一的滑動沖突圖,解決方案和 場景 1、2 的解決思想一致,場景 3 就不分析了。
下面用過一個實際的例子來分析場景 2,首先我們可以提供一個可以上下滑動的父容器SickLayout,它看起來就像一個可以上下滑動的豎直的 LinearLayout ,然后在它的內部分別放一個 Header 和 ListView,這樣內外兩層都能上下滑動,于是就形成了場景2中的滑動沖突。
public class StickyLayout extends LinearLayout {
private int mTouchSlop;
private int mLastX = 0;
private int mLastY = 0;
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
public StickyLayout(Context context) {
super(context);
}
...
@Override
public boolean onInterceptHoverEvent(MotionEvent event) {
int intercepted = 0;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastXIntercept = x;
mLastYIntercept = y;
mLastX = x;
mLastY = y;
intercepted = 0;
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (mDisallowInterceptTouchEventOnHeader && y <= getHeaderHeight()) {
intercepted = 0;
} else if (Math.abs(deltaY) <= Math.abs(deltaX)) {
intercepted = 0;
} else if (mStatus == STATUS_EXPANDED && deltaY <= -mTouchSlop) {
intercepted = 1;
} else if (mGiveUpTouchEventListener != null) {
if (mGiveUpTouchEventListener.giveUpTouchEvent(event) && deltaY >= mTouchSlop) {
intercepted = 1;
}
}
break;
case MotionEvent.ACTION_UP:
intercepted = 0;
mLastYIntercept = mLastYIntercept = 0;
break;
}
return intercepted != 0 && mIsSticky;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!mIsSticky) {
return true;
}
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
mHeaderHeight += deltaY;
setHeaderHeight(mHeaderHeight):
break;
case MotionEvent.ACTION_UP:
int destHeight = 0;
if (mHeaderHeight <= mOriginalHeaderHeight * 0.5) {
destHeight = 0;
mStatus = STATUS_COLLAPSED;
} else {
destHeight = mOriginalHeaderHeight;
mStatus = STATUS_EXPANDED;
}
this.smoothSetHeaderHeight(mHeaderHeight, destHeight, 500);
break;
}
mLastX = x;
mLastY = y;
return true;
}
...
}
從上面的代碼來看,這個 SitckyLayout 的實現有點復雜,在第 4 章會詳細介紹這個自定義 View 的實現思想,這里有個大概的印象就即可。下面我們主要看它的 onInterceptTouchEvent 方法中對 ACTION_MOVE 的處理。
@Override
public boolean onInterceptHoverEvent(MotionEvent event) {
switch (event.getAction()) {
...
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (mDisallowInterceptTouchEventOnHeader && y <= getHeaderHeight()) {
intercepted = 0;
} else if (Math.abs(deltaY) <= Math.abs(deltaX)) {
intercepted = 0;
} else if (mStatus == STATUS_EXPANDED && deltaY <= -mTouchSlop) {
intercepted = 1;
} else if (mGiveUpTouchEventListener != null) {
if (mGiveUpTouchEventListener.giveUpTouchEvent(event) && deltaY >= mTouchSlop) {
intercepted = 1;
}
}
分析上面代碼的邏輯,父容器是 StickyLayout,子元素是 ListVIew。首先,當時間落在 Header 上面時父容器不會攔截事件;接著如果豎直距離差小于水平距離差,那么父容器也不會攔截時間;然后,當 Herader 是展開狀態并且向上滑動時父容器攔截事件。另一種情況,當 ListView 滑動到頂部了并且向下滑動時,父容器也會攔截事件,進過層層判斷就可以達到我們想要的效果了。另外。giveUpTouchEvent 是一個接口方法,由外部實現,在本例中主要是用來判斷 ListView 是否滑動到頂部,它的具體實現如下:
private boolean giveUpTouchEvent(MotionEvent event) {
if (expandableListView.getFirstVisiblePosition() == 0) {
View view = expandableListView.getChildAt(0);
if (view != null && view.getTop() >= 0) {
return true;
}
}
return false;
}
上面這個例子比較復雜,需要多多體會其中的寫法和思想。
掌握住上面說的通用方法,其他基本都是一些邏輯問題。