高階 UI 成長之路 (二) 深入理解 Android 8.0 View 觸控事件分發機制
前言
在上一篇文章中我們介紹了 View 的基礎知識以及 View 滑動的實現,本篇將為大家帶來 View 的一個核心知識點 事件分發機制。事件分發機制不僅僅是核心知識點也是 Android 中的一個難點,下面我們就從原始碼的角度來分析事件的傳遞還有最後是如何解決滑動衝突的。
事件分發機制
點選事件的傳遞規則
在介紹事件傳遞規則之前,首先我們要明白要分析的物件就是 MotionEvent , 關於 MotionEvent 在上一篇文章介紹滑動的時候咱們已經用過了。其實所謂的點選事件分發就是對 MotionEvent 事件的分發過程,點選事件的分發過程由三個很重要的方法共同完成,如下:
1. dispatchTouchEvent(MotionEvent ev)
用來進行事件的分發。如果事件能夠傳遞給當前的 View ,那麼此方法一定會被呼叫,返回結果受當前 View 的 onTouchEvent 和下級 View 的 dispatchTouchEvent 方法的影響,表示是否消耗當前事件。
2. onInterceptTouchEvent(MotionEvent ev)
在上述內部方法呼叫,用來判斷是否攔截某個事件,如果當前 View 攔截了某個事件,那麼在同一個事件序列中,此方法不會被再次呼叫,返回結果表示是否攔截當前事件。
3. onTouchEvent(MotionEvent ev)
在第一個方法中呼叫,用來處理點選事件,返回結果表示是否消耗此事件,如果不消耗,當前 View 就無法再次接收到事件。
下面我畫了一個圖來具體說明下上面 3 個方法之間的關係
也可以用一段虛擬碼來說明,如下:
fun dispatchTouchEvent(MotionEvent ev):Boolean{ var consume = false //父類是否攔截 if(onInterceptTouchEvent(ev)){ //如果攔截將執行自身的 onTouchEvent 方法 consume = onTouchEvent(ev) }else{ //如果事件在父類不攔截,將繼續分發給子類 consume = child.dispatchTouchEvent(ev) } reture consume }
上圖跟虛擬碼意思一樣,特別是虛擬碼已經將它們三者的關係表現得非常到位,通過上面虛擬碼我們可以大致瞭解到點選事件的一個傳遞規則,對應一個根 ViewGroup 來說,點選事件產生後,首先會傳遞給它, 這時它的 dispatchTouchEvent 就會被呼叫,如果這個 ViewGroup 的 onInterceptTouchEvent 方法返回 true 就表示它要攔截當前事件,接著事件就會交給這個 ViewGroup 處理,即它的onTouchEvent 方法就會被呼叫;如果這個 ViewGroup 的 onInterceptTouchEvent 返回 false 就表示它不攔截當前事件,這時當前事件就會傳遞給它的子元素,接著子元素的 dispatchTouchEvent 方法就會被呼叫,如此反覆直到事件被最終處理。
當一個 View 需要處理事件時的呼叫規則,如下虛擬碼:
fun dispatchTouchEvent(MotionEvent event): boolean{
//1. 如果當前 View 設定 onTouchListener
if(onTouchListener != null){
//2. 那麼自身的 onTouch 就會被呼叫,如果返回 false 其自身的 onTouchEvent 被呼叫
if(!onTouchListener.onTouch(v: View?, event: MotionEvent?)){
//3. onTouch 返回了 false ,onTouchEvent 呼叫,並且會呼叫內部的 onClick 事件
if(!onTouchEvent(event)){
//4. 如果也設定了 onClickListener 那麼 onClick 也會被呼叫
onClickListener.onClick()
}
}
}
}
上面的虛擬碼的邏輯總結一下就是如果當前 View 設定了 onTouchListener 那麼自身的 onTouch 就會執行,如果 onTouch 返回值是 false ,其自身的 onTouchEvent 會被呼叫。如果 onTouchEvent 返回也為 false 那麼 onClick 就會執行 。優先順序為 onTouch > onTouchEvent > onClick。
當一個點選事件產生後,它的傳遞過程遵循如下順序:Activity -> Window -> View ,即事件總是先傳遞給 Activity , Activity 再傳遞給 Window , 最後 Window 再傳遞給頂級 View 。頂級 View 接收到事件後,就會按照事件分發機制去分發事件。考慮一種情況,如果一個 View 的 onTouchEvent 返回 false ,那麼它的父容器的 onTouchEvent 將會被呼叫,依次類推。如果所有的元素都不處理這個事件,那麼這個事件將會最終傳遞給 Activity 處理,即 Activity 的 onTouchEvent 方法會被呼叫。下面我們就以一段程式碼示例來演示一下這種場景,程式碼如下:
-
重寫 Activity dispatchTouchEvent 分發和 onTouchEvent 事件處理
class MainActivity : AppCompatActivity() { override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { if (ev.action == MotionEvent.ACTION_DOWN) println("事件分發機制開始分發 ----> Activity dispatchTouchEvent") return super.dispatchTouchEvent(ev) } override fun onTouchEvent(event: MotionEvent?): Boolean { if (event.action == MotionEvent.ACTION_DOWN) println("事件分發機制處理 ----> Activity onTouchEvent 執行") return super.onTouchEvent(event) } }
-
重寫根 ViewGroup dispatchTouchEvent 分發和 onTouchEvent 事件處理
public class GustomLIn(context: Context?, attrs: AttributeSet?) : LinearLayout(context, attrs) { override fun onTouchEvent(event: MotionEvent?): Boolean { if (event.action == MotionEvent.ACTION_DOWN) println("事件分發機制處理 ----> 父容器 LinearLayout onTouchEvent") return false } override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { if (ev.action == MotionEvent.ACTION_DOWN) println("事件分發機制開始分發 ----> 父容器 dispatchTouchEvent") return super.dispatchTouchEvent(ev) } override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { if (ev.action == MotionEvent.ACTION_DOWN) println("事件分發機制開始分發 ----> 父容器是否攔截 onInterceptTouchEvent") return super.onInterceptTouchEvent(ev) } }
-
重寫子 View dispatchTouchEvent 分發和 onTouchEvent 事件處理
public class Button(context: Context?, attrs: AttributeSet?) : AppCompatButton(context, attrs) { override fun dispatchTouchEvent(event: MotionEvent?): Boolean { if (event.action == MotionEvent.ACTION_DOWN) println("事件分發機制開始分發 ----> 子View dispatchTouchEvent") return super.dispatchTouchEvent(event) } override fun onTouchEvent(event: MotionEvent?): Boolean { if (event.action == MotionEvent.ACTION_DOWN) println("事件分發機制處理 ----> 子View onTouchEvent") return false } }
輸出:
System.out: 事件分發機制開始分發 ----> Activity dispatchTouchEvent
System.out: 事件分發機制開始分發 ----> 父容器 dispatchTouchEvent
System.out: 事件分發機制開始分發 ----> 父容器是否攔截 onInterceptTouchEvent
System.out: 事件分發機制開始分發 ----> 子View dispatchTouchEvent
System.out: 事件分發機制開始處理 ----> 子View onTouchEvent
System.out: 事件分發機制開始處理 ----> 父容器 LinearLayout onTouchEvent
System.out: 事件分發機制開始處理 ----> Activity onTouchEvent 執行
得出的結論跟之前的描述完全一致,這就說明了如果子 View ,父 ViewGroup 都不處理事件的話,最後交於 Activity 的 onTouchEvent 方法。也可以從上面的結果看出來事件傳遞是由外向內傳遞的,即事件總是先傳遞給父元素,然後再由父元素分發給子 View 。
事件分發原始碼解析
上一小節我們分析了 View 的事件分發機制,本節將從原始碼的角度進一步去分析。
-
Activity 對點選事件的分發過程
點選事件用 MotionEvent 來表示,當一個點選操作發生時,事件最先傳遞給當前 Activity ,由 Activity 的 dispatchTouchEvent 來進行事件派發,具體的工作是由 Activity 內部的 Window 來完成的。Window 會將事件傳遞給 DecorView ,DecorView 一般就是當前介面的底層容器也就是setContentView 所設定的父容器,它繼承自 FrameLayout ,它在 Activity 中可以通過 getWindow().getDecorView()獲得 ,由於事件最先由 Activity 開始進行分發,那麼我們就直接看它的 dispactchTouchEvent 方法,程式碼如下:
//Activity.java public boolean dispatchTouchEvent(MotionEvent ev) { /** * 首先按下的觸發的是 ACTION_DOWN 事件 */ if (ev.getAction() == MotionEvent.ACTION_DOWN) { onUserInteraction(); } /** * 拿到當前 Window 呼叫 superDispatchTouchEvent 方法 */ if (getWindow().superDispatchTouchEvent(ev)) { return true; } /** * 如果所有的 View 都沒有處理,那麼最終會執行到 Activity onTouchEvent 方法中。 */ return onTouchEvent(ev); }
通過上面的程式碼我們知道首先執行的是 ACTION_DOWN 按下事件執行 onUserInteraction 空方法,然後呼叫 getWindow() 的 superDispatchTouchEvent 方法,這裡的 getWindow 其實就是它的唯一子類 PhoneWindow 我們看它的具體呼叫實現,程式碼如下:
//PhoneWindow.java public class PhoneWindow extends Window implements MenuBuilder.Callback { ... private DecorView mDecor; @Override public boolean superDispatchTouchEvent(MotionEvent event) { return mDecor.superDispatchTouchEvent(event); } ... }
在 PhoneWindow 的 superDispatchTouchEvent 函式中又交於了 DecorView 來處理,那麼 DecorView 是什麼呢?
//DecorView.java public class DecorView extends FrameLayout implements RootViewSurfaceTaker, WindowCallbacks { ... DecorView(Context context, int featureId, PhoneWindow window, WindowManager.LayoutParams params) { super(context); ... @Override public final View getDecorView() { if (mDecor == null || mForceDecorInstall) { installDecor(); } return mDecor; } } ... }
我們看到 DecorView 它其實就是繼承的 FrameLayout ,我們知道在 Activity 中我們可以通過 getWindow().getDecorView().findViewById() 拿到對應在 XML 中的 View 物件 , 那麼 DecorView 又是什麼時候進行例項化呢?還有 PhoneWindow 又是何時進行例項化的呢?因為這些不是咱們今天講解的主要內容,感興趣的可以看我之前對 Activity 啟動原始碼分析該篇中有講過 它們其實都是在 Activity 啟動的時候進行各自的例項化。好了,DecorView 例項化就講到這裡。目前事件傳遞到了 DecorView 這裡,我們看它的內部原始碼實現,程式碼如下:
// DecorView.java public class DecorView extends FrameLayout implements RootViewSurfaceTaker, WindowCallbacks { public boolean superDispatchTouchEvent(MotionEvent event) { return super.dispatchTouchEvent(event); }
我們看到內部又呼叫了父類 dispatchTouchEvent 方法, 所以最終是交給 ViewGroup 頂級 View 來處理分發了。
-
頂級 View 對點選事件的分發過程
在上一小節中我們知道了一個事件的傳遞流程,這裡我們就大致在回顧一下。首先點選事件到達頂級 ViewGroup 之後,會呼叫自身的 dispatchTouchEvent 方法,之後如果自身的攔截方法 onInterceptTouchEvent 返回 true ,則事件不會繼續下發給子類,如果自身設定了 mOnTouchListener 監聽,則 onTouch 會被呼叫,否則 onTouchEvent 會被呼叫,如果 onTouchEvent 中設定了 mOnClickListener 那麼 onClick 會呼叫。如果 ViewGroup 的 onInterceptTouchEvent 返回 false,則事件會傳遞到所點選的子 View 中,這時子 View 的 dispatchTouchEvent 會被呼叫。到此為止,事件已經從頂級 View 傳遞給了下一層 View ,接下來的傳遞過程和頂級 ViewGroup 一樣,如此迴圈就完成了整個事件的分發。
在該小節的第一點中我們知道,在 DecorView 中的 superDispatchTouchEvent 方法內部呼叫了父類的 dispatchTouchEvent 方法,我們看它的實現,程式碼如下:
//ViewGroup.java @Override public boolean dispatchTouchEvent(MotionEvent ev) { ... if (actionMasked == MotionEvent.ACTION_DOWN) { //這裡主要是在新事件開始時處理完上一個事件 cancelAndClearTouchTargets(ev); resetTouchState(); } /** 檢查事件攔截,表示事件是否攔截*/ final boolean intercepted; /** * 1. 判斷當前是否是按下 */ if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) { final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0; //2. 子類可以通過 requestDisallowInterceptTouchEvent 方法來設定父類不要攔截 if (!disallowIntercept) { //3 intercepted = onInterceptTouchEvent(ev); //恢復事件防止其改變 ev.setAction(action); } else { intercepted = false; } } else { intercepted = true; } ... }
從上面程式碼我們可以看出如果 actionMasked == MotionEvent.ACTION_DOWN 或者 mFirstTouchTarget != null 成立的話會執行註釋 2 的判斷(mFirstTouchTarget 的意思如果當前事件被子類消費了,就不成立,後面會提高),disallowIntercept 可以在子類中通過呼叫父類的 requestDisallowInterceptTouchEvent(true) 請求父類不要攔截分發事件,也就是阻止執行註釋 3 的攔截子類接收按下的事件,反之執行 onInterceptTouchEvent(ev); 如果返回 true 說明攔截了事件 。
上面介紹了註釋 1,2,3 onInterceptTouchEvent 返回 true 的情況,說明攔截了事件,下面我們來講解 intercepted = false 當前 ViewGroup 不攔截事件的時候,事件會下發給它的子 View 進行處理,下面看子 View 處理的原始碼,程式碼如下:
//ViewGroup.java public boolean dispatchTouchEvent(MotionEvent ev) { ... if (!canceled && !intercepted) { final View[] children = mChildren; for (int i = childrenCount - 1; i >= 0; i--) { final int childIndex = getAndVerifyPreorderedIndex( childrenCount, i, customOrder); final View child = getAndVerifyPreorderedView( preorderedList, children, childIndex); if (childWithAccessibilityFocus != null) { if (childWithAccessibilityFocus != child) { continue; } childWithAccessibilityFocus = null; i = childrenCount - 1; } if (!canViewReceivePointerEvents(child) || !isTransformedTouchPointInView(x, y, child, null)) { ev.setTargetAccessibilityFocus(false); continue; } newTouchTarget = getTouchTarget(child); if (newTouchTarget != null) { newTouchTarget.pointerIdBits |= idBitsToAssign; break; } resetCancelNextUpFlag(child); if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) { mLastTouchDownTime = ev.getDownTime(); if (preorderedList != null) { for (int j = 0; j < childrenCount; j++) { if (children[childIndex] == mChildren[j]) { mLastTouchDownIndex = j; break; } } } else { mLastTouchDownIndex = childIndex; } mLastTouchDownX = ev.getX(); mLastTouchDownY = ev.getY(); newTouchTarget = addTouchTarget(child, idBitsToAssign); alreadyDispatchedToNewTouchTarget = true; break; } ev.setTargetAccessibilityFocus(false); } } ... }
上面這段程式碼也很好理解,首先遍歷 ViewGroup 子孩子,然後判斷子元素是否在播放動畫和點選事件是否落在了子元素的區域內。如果某個子元素滿足這 2 個條件,那麼事件就會傳遞給該子類來處理,可以看到 ,dispatchTransformedTouchEvent 實際上呼叫的就是子類的 dispatchTouchEvent 方法,在它的內部有如下一段內容,而在上面的程式碼中 child 傳遞不是 null ,因此它會直接呼叫子元素的 dispatchTouchEvent 方法,這樣事件就交由子元素處理,從而完成了一輪事件分發。
//ViewGroup.java private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel, View child, int desiredPointerIdBits) { ... if (child == null) { handled = super.dispatchTouchEvent(event); } else { handled = child.dispatchTouchEvent(event); } ... }
這裡如果 child.dispatchTouchEvent(event) 返回 true , 那麼 mFirstTouchTarget 就會被賦值同時跳出 for 迴圈,如下所示:
//ViewGroup.java public boolean dispatchTouchEvent(MotionEvent ev) { ... newTouchTarget = addTouchTarget(child, idBitsToAssign); alreadyDispatchedToNewTouchTarget = true; ... } private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) { final TouchTarget target = TouchTarget.obtain(child, pointerIdBits); target.next = mFirstTouchTarget; //這個時候 mFirstTouchTarget 就代表子 View 成功處理了事件 mFirstTouchTarget = target; return target; }
這幾行程式碼完成了 mFirstTouchTarget 的賦值並終止了對子元素的遍歷。如果子元素的 dispatchTouchEvent 返回 false ,ViewGroup 會繼續遍歷進行事件分發給下一個子元素。
如果遍歷所有的子元素後事件都沒有被處理的時候,那麼 ViewGroup 就會自己處理點選事件,這裡包含 2 種情況下 ViewGroup 會自己處理事件 (其一: ViewGroup 沒有子元素,其二:子元素處理了點選事件,但是在 dispatchTouchEvent 中返回了false,這一般是在子元素的 onTouchEvent 中返回了 false )
程式碼如下:
public boolean dispatchTouchEvent(MotionEvent ev) { ... if (mFirstTouchTarget == null) { handled = dispatchTransformedTouchEvent(ev, canceled, null, TouchTarget.ALL_POINTER_IDS); } ... }
可以看到如果 mFirstTouchTarget == null 的時候,那麼就是代表 ViewGroup 的子 View 沒有被消費點選事件,將呼叫自身的 dispatchTransformedTouchEvent 方法。注意上面這段程式碼這裡的第三個引數 child 為 null ,從前面的分析可以知道,它會呼叫 super.dispatchTouchEvent(event) ,顯然,這裡就會呼叫父類 View 的 dispatchTouchEvent 方法,即點選事件開始交由 View 處理,請看下面的分析:
-
View 對點選事件的處理過程
其實 View 對點選事件的處理過程稍微簡單一些,注意這裡的 View 不包含 ViewGroup 。先看它的 dispatchTouchEvent 方法,程式碼如下:
//View.java public boolean dispatchTouchEvent(MotionEvent event) { ... if (onFilterTouchEventForSecurity(event)) { if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) { result = true; } ListenerInfo li = mListenerInfo; //1. if (li != null && li.mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED && li.mOnTouchListener.onTouch(this, event)) { result = true; } //2. if (!result && onTouchEvent(event)) { result = true; } } .... return result; }
View 中的事件處理邏輯比較簡單,我們先看註釋 1 處,如果我們外部設定了 mOnTouchListener 點選事件,那麼就會執行 onTouch 回撥,如果該回調的返回值為 false ,那麼才會執行 onTouchEvent 方法,可見onTouchListener 優先順序高於 onTouchEvent 方法,下面我們來分析 onTouchEvent 方法實現,程式碼如下:
//View.java public boolean onTouchEvent(MotionEvent event) { final float x = event.getX(); final float y = event.getY(); final int viewFlags = mViewFlags; final int action = event.getAction(); final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE; /** * 1. View 處於不可用狀態下的點選事件的處理過程 */ if ((viewFlags & ENABLED_MASK) == DISABLED) { if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) { setPressed(false); } mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN; // A disabled view that is clickable still consumes the touch // events, it just doesn't respond to them. return clickable; } /** * 2. 如果 View 設定了代理,那麼還會執行 TouchDelegate 的 onTouchEvent 方法。 */ if (mTouchDelegate != null) { if (mTouchDelegate.onTouchEvent(event)) { return true; } } /** * 3. 如果 clickable 或 (viewFlags & TOOLTIP) == TOOLTIP 有一個成立那麼就會處理該事件 */ if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) { switch (action) { case MotionEvent.ACTION_UP: mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN; if ((viewFlags & TOOLTIP) == TOOLTIP) { handleTooltipUp(); } if (!clickable) { removeTapCallback(); removeLongPressCallback(); mInContextButtonPress = false; mHasPerformedLongPress = false; mIgnoreNextUpEvent = false; break; } // 用於識別快速按下 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) { removeLongPressCallback(); if (!focusTaken) { if (mPerformClick == null) { mPerformClick = new PerformClick(); } /** * 如果設定了點選事件 mOnClickListener 就會執行內部回撥 */ if (!post(mPerformClick)) { performClick(); } } } if (mUnsetPressedState == null) { mUnsetPressedState = new UnsetPressedState(); } if (prepressed) { postDelayed(mUnsetPressedState, ViewConfiguration.getPressedStateDuration()); } else if (!post(mUnsetPressedState)) { // If the post failed, unpress right now mUnsetPressedState.run(); } removeTapCallback(); } mIgnoreNextUpEvent = false; break; case MotionEvent.ACTION_DOWN: ... //判斷是否是在滾動容器中 boolean isInScrollingContainer = isInScrollingContainer(); if (isInScrollingContainer) { mPrivateFlags |= PFLAG_PREPRESSED; if (mPendingCheckForTap == null) { mPendingCheckForTap = new CheckForTap(); } mPendingCheckForTap.x = event.getX(); mPendingCheckForTap.y = event.getY(); //傳送一個延遲執行長按事件的操作 postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout()); } else { // Not inside a scrolling container, so show the feedback right away setPressed(true, x, y); checkForLongClick(0, x, y); } break; case MotionEvent.ACTION_CANCEL: if (clickable) { setPressed(false); } //移除一些回撥比如長按事件 removeTapCallback(); removeLongPressCallback(); mInContextButtonPress = false; mHasPerformedLongPress = false; mIgnoreNextUpEvent = false; mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN; break; case MotionEvent.ACTION_MOVE: if (clickable) { drawableHotspotChanged(x, y); } if (!pointInView(x, y, mTouchSlop)) { //移除一些回撥比如長按事件 removeTapCallback(); removeLongPressCallback(); if ((mPrivateFlags & PFLAG_PRESSED) != 0) { setPressed(false); } mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN; } break; } return true; } return false; }
上面程式碼雖然比較多,但是邏輯還是很清楚的,我們來分析一下
- 判斷 View 是否處於不可用的狀態下使用,返回一個 clickable 。
- 判斷 View 是否設定了代理,如果設定了代理將會執行 代理的 onTouchEvent 方法。
- 如果 clickable 或 (viewFlags & TOOLTIP) == TOOLTIP 有一個成立那麼就會處理 MotionEvent 事件。
- 在 MotionEvent 事件中分別會在 up 和 down 中會執行點選 onClick 和 onLongClick 回撥。
到這裡點選事件的分發機制原始碼實現已經分析完了,結合之前分析的傳遞規則和下面這張圖,然後結合原始碼相信你應該理解了事件分發跟事件處理機制了。
滑動衝突
本小節將介紹 View 體系中一個非常重要的知識點滑動衝突,相信在開發中特別是做一些滑動效果處理的時候而且還不止一層滑動,又的是巢狀好幾層的滑動,那麼它們之間如果不解決滑動衝突必定是不可行的,下面我們先來看看造成滑動衝突的場景。
滑動衝突場景及處理規則
1. 外部滑動方向和內部滑動方向不一致
主要是將 ViewPager 和 Fragment 配合使用所組成的頁面滑動效果,主流應用幾乎都會使用這個效果。在這種效果中,可以通過左右滑動來切換頁面,而每個頁面內部往往是一個 RecyclerView 。本來這種情況下是有滑動衝突的,但是 ViewPager 內部處理了這種滑動衝突,因此採用 ViewPager 時我們無須關注這個問題,但是如果我們採用的是 ScrollView 等滑動控制元件,那就必須手動處理滑動衝突了,否則造成的後果就是內外兩層只能由一層能夠滑動,這是因為兩者之間的滑動事件有衝突。
它的處理規則是:
當用戶左右滑動時,需要讓外部的 View 攔截點選事件,當用戶上下滑動的時候,需要讓內部的 View 攔截點選事件。這個時候我們就可以根據他們的特徵來解決滑動衝突。具體來說就是可以通過判斷滑動手勢是水平方向還是豎直方向具體來對應攔截事件。
2. 外部滑動方向和內部滑動方向一致
這種情況就稍微複雜一些,當內外兩層都在同一個方向可以滑動的時候,顯然存在邏輯問題。因為當手指開始滑動的時候,系統無法知道使用者到底是想讓那一層滑動,所以當手指滑動的時候就會出現問題,要麼只有一層能滑動,要麼就是內外兩層都滑動得很卡頓。在實際的開發中,這種場景主要是指內外兩層同時能上下滑動或者內外兩層同時能左右滑動。
它的處理規則是:
這種事比較特殊的,因為它無法根據滑動的角度、距離差以及速度差來做判斷,但是這個時候一般都能在業務上找到突破點,比如業務有規定,當處理某種狀態的時候需要外部 View 響應使用者的滑動,而處於另外一種狀態時則需要內部 View 來響應 View 的滑動,根據這種業務上的需求我們也能得出相應的處理規則,有了處理規則同樣可以進行下一步處理。這種場景通過文字描述可能比較抽象,在下一小節中我們會通過實際例子來演示這種情況。
3. 1 + 2 場景的巢狀
場景三是場景一和場景二兩種情況的巢狀,因此場景三的滑動衝突看起來就更加複雜了。比如在許多應用中會有這麼一個效果:內層有一個場景 1 中的滑動效果,然後外層又有一個場景 2 中的滑動效果。雖然說場景三的滑動衝突看起來是比較複雜的,但是它是幾個單一的滑動衝突的疊加,所以只需要分別處理內中外層之間的衝突就行了,處理方式跟場景 1 和 2 一致。
下面我們就來看一下滑動衝突的處理規則。
它的處理規則是:
它的滑動規則就更復雜了,和場景 2 一樣,它也無法直接根據滑動的角度、距離以及速度差來做判斷,同樣還是隻能從業務員上找到突破點,具體方法和場景 2 一樣,都是從業務的需求上得出相應的處理規則,在下一節中同樣會給出程式碼示例來進行演示。
滑動衝突的解決方式
上面說過針對場景 1 中的滑動,我們可以根據滑動的距離差來進行判斷,這個距離差就是所謂的滑動規則。如果用 ViewPager 去實現場景 1 中的效果,我們不需要手動處理滑動衝突,因為 ViewPager 已經幫我們做了,但是這裡為了更好的演示滑動衝突解決思想,沒有采用 ViewPager 。其實在滑動過程中得到滑動的角度這個是相當簡單的,但是到底要怎麼做才能將點選事件交給合適的 View 去處理呢?這時就要用到 3.4 節所講述的事件分發機制了。針對滑動衝突,這裡給出 2 種解決滑動衝突的方式,外部攔截和內部攔截髮。
-
外部攔截法
所謂外部攔截就是指點選事件先經過父容器的攔截處理,如果父容器需要此事件就攔截,如果不需要此事件就不攔截,這樣就可以解決滑動衝突的問題,這種方法比較符合點選事件的分發機制。外部攔截法需要重寫 onInterceptTouchEvent方法,在內部做響應的攔截即可,可以參考下面程式碼:
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { when (ev.action) { MotionEvent.ACTION_DOWN -> { isIntercepted = false } MotionEvent.ACTION_MOVE -> { //攔截子類的移動事件 if (true) { println("事件分發機制開始分發 ----> 攔截子類的移動事件 onInterceptTouchEvent") isIntercepted = true } else { isIntercepted = false } } MotionEvent.ACTION_UP -> { isIntercepted = false } } return isIntercepted }
上述程式碼是外部攔截的典型邏輯,針對不同的滑動衝突只需要修改父容器需要當前點選事件這個條件即可,其它均不做修改也不能修改。這裡對上述程式碼再描述一下,在 onInterceptTouchEvent 方法中,首先是 ACTION_DOWN 這個事件,父容器必須返回 false 。既不攔截 ACTION_DOWN 事件,這是因為一旦父容器攔截了 ACTION_DOWN , 這是因為一旦父容器攔截 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 fun dispatchTouchEvent(event: MotionEvent): Boolean { when (event.action) { MotionEvent.ACTION_DOWN -> { println("事件分發機制開始分發 ----> 子View dispatchTouchEvent ACTION_DOWN") parent.requestDisallowInterceptTouchEvent(true) } MotionEvent.ACTION_MOVE -> { println("事件分發機制開始分發 ----> 子View dispatchTouchEvent ACTION_MOVE") if (true){ parent.requestDisallowInterceptTouchEvent(false) } } MotionEvent.ACTION_UP -> { println("事件分發機制開始分發 ----> 子View dispatchTouchEvent ACTION_UP") } } return super.dispatchTouchEvent(event) }
上述程式碼是內部攔截法的典型程式碼,當面對不同的滑動策略時只需要修改裡面的條件即可,其它不需要做改動而且也不能有改動,除了子元素需要做處理以外,父元素也要預設攔截除了 ACTION_DOWN 以外的其它事件,這樣當子元素呼叫 parent.requestDisallowInterceptTouchEvent(false) ,父元素才能繼續攔截所需的事件。
下面就以實戰的 demo 具體來說明一下。
實戰
場景一 滑動衝突案例
我們自定義一個 ViewPager + RecyclerView 包含左右 + 上下滑動,這樣就滿足了我們場景一的滑動衝突,我們先來看一下完整的效果圖:
[圖片上傳失敗...(image-1d2523-1636703567722)]
上面錄屏的效果解決了上下滑動跟左右滑動衝突,實現方式就是自定義 ViewGroup 利用 Scroller 達到像 ViewPager 一樣絲滑般的感覺 ,然後內部添加了 3 個 RecyclerView 。
我們看一下自定義 ViewGroup 實現:
class ScrollerViewPager(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) {
/**
* 定義 Scroller 例項
*/
private var mScroller = Scroller(context)
/**
* 判斷拖動的最小移動畫素點
*/
private var mTouchSlop = 0
/**
* 手指按下螢幕的 x 座標
*/
private var mDownX = 0f
/**
* 手指當前所在的座標
*/
private var mMoveX = 0f
/**
* 記錄上一次觸發 按下是的座標
*/
private var mLastMoveX = 0f
/**
* 介面可以滾動的左邊界
*/
private var mLeftBorder = 0
/**
* 介面可以滾動的右邊界
*/
private var mRightBorder = 0
/**
* 記錄下一次攔截的 X,y
*/
private var mLastXIntercept = 0
private var mLastYIntercept = 0
/**
* 是否攔截
*/
private var interceptor = false
init {
init()
}
constructor(context: Context?) : this(context, null) {
}
private fun init() {
/**
* 通過 ViewConfiguration 拿到認為手指滑動的最短的移動 px 值
*/
mTouchSlop = ViewConfiguration.get(context).scaledPagingTouchSlop
}
/**
* 測量 child 寬高
*/
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//拿到子View 個數
val childCount = childCount
for (index in 0..childCount - 1) {
val childView = getChildAt(index)
//為 ScrollerViewPager 中的每一個子控制元件測量大小
measureChild(childView, widthMeasureSpec, heightMeasureSpec)
}
}
/**
* 測量完之後,拿到 child 的大小然後開始對號入座
*/
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
if (changed) {
val childCount = childCount
for (child in 0..childCount - 1) {
//拿到子View
val childView = getChildAt(child)
//開始對號入座
childView.layout(
child * childView.measuredWidth, 0,
(child + 1) * childView.measuredWidth, childView.measuredHeight
)
}
//初始化左右邊界
mLeftBorder = getChildAt(0).left
mRightBorder = getChildAt(childCount - 1).right
}
}
/**
* 外部解決 1. 根據垂直或水平的距離來判斷
*/
// override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
// interceptor = false
// var x = ev.x.toInt()
// var y = ev.y.toInt()
// when (ev.action) {
// MotionEvent.ACTION_DOWN -> {
// interceptor = false
// }
// MotionEvent.ACTION_MOVE -> {
// var deltaX = x - mLastXIntercept
// var deltaY = y - mLastYIntercept
// interceptor = Math.abs(deltaX) > Math.abs(deltaY)
// if (interceptor) {
// mMoveX = ev.getRawX()
// mLastMoveX = mMoveX
// }
// }
// MotionEvent.ACTION_UP -> {
// //拿到當前移動的 x 座標
// interceptor = false
// println("onInterceptTouchEvent---ACTION_UP")
//
// }
// }
// mLastXIntercept = x
// mLastYIntercept = y
// return interceptor
// }
/**
* 外部解決 2. 根據第二點座標 - 第一點座標 如果差值大於 TouchSlop 就認為是在左右滑動
*/
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
interceptor = false
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
//拿到手指按下相當於螢幕的座標
mDownX = ev.getRawX()
mLastMoveX = mDownX
interceptor = false
}
MotionEvent.ACTION_MOVE -> {
//拿到當前移動的 x 座標
mMoveX = ev.getRawX()
//拿到差值
val absDiff = Math.abs(mMoveX - mDownX)
mLastMoveX = mMoveX
//當手指拖動值大於 TouchSlop 值時,就認為是在滑動,攔截子控制元件的觸控事件
if (absDiff > mTouchSlop)
interceptor = true
}
}
return interceptor
}
/**
* 父容器沒有攔截事件,這裡就會接收到使用者的觸控事件
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_MOVE -> {
println("onInterceptTouchEvent---onTouchEvent--ACTION_MOVE ")
mLastMoveX = mMoveX
//拿到當前滑動的相對於螢幕左上角的座標
mMoveX = event.getRawX()
var scrolledX = (mLastMoveX - mMoveX).toInt()
if (scrollX + scrolledX < mLeftBorder) {
scrollTo(mLeftBorder, 0)
return true
} else if (scrollX + width + scrolledX > mRightBorder) {
scrollTo(mRightBorder - width, 0)
return true
}
scrollBy(scrolledX, 0)
mLastMoveX = mMoveX
}
MotionEvent.ACTION_UP -> {
//當手指抬起是,根據當前滾動值來判定應該回滾到哪個子控制元件的介面上
var targetIndex = (scrollX + width / 2) / width
var dx = targetIndex * width - scrollX
/** 第二步 呼叫 startScroll 方法彈性回滾並重新整理頁面*/
mScroller.startScroll(scrollX, 0, dx, 0)
invalidate()
}
}
return super.onTouchEvent(event)
}
override fun computeScroll() {
super.computeScroll()
/**
* 第三步 重寫 computeScroll 方法,並在其內部完成平滑滾動的邏輯
*/
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.currX, mScroller.currY)
postInvalidate()
}
}
}
上面程式碼很簡單,通過 2 種方式處理了外部攔截法衝突,分別是:
- 根據垂直或水平的距離來判斷
- 根據第二點座標 - 第一點座標 如果差值大於 TouchSlop 就認為是在左右滑動
當然我們也可以用內部攔截法來解決,按照我們前面對內部攔截法的分析,我們只需要修改自定義 RecylerView 的 分發事件 dispatchTouchEvent 方法中的父容器的攔截邏輯,下面請看程式碼實現:
class MyRecyclerView(context: Context, attrs: AttributeSet?) : RecyclerView(context, attrs) {
/**
* 分別記錄我們上次滑動的座標
*/
private var mLastX = 0;
private var mLastY = 0;
constructor(context: Context) : this(context, null)
/**
* 重寫分發事件
*/
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
val x = ev.getX().toInt()
val y = ev.getY().toInt()
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
var par = parent as ScrollerViewPager
//請求父類不要攔截事件
par.requestDisallowInterceptTouchEvent(true)
Log.d("dispatchTouchEvent", "---》子ACTION_DOWN");
}
MotionEvent.ACTION_MOVE -> {
val deltaX = x - mLastX
val deltaY = y - mLastY
if (Math.abs(deltaX) > Math.abs(deltaY)){
var par = parent as ScrollerViewPager
Log.d("dispatchTouchEvent", "dx:" + deltaX + " dy:" + deltaY);
//交於父類來處理
par.requestDisallowInterceptTouchEvent(false)
}
}
MotionEvent.ACTION_UP -> {
}
}
mLastX = x
mLastY = y
return super.dispatchTouchEvent(ev)
}
}
還需要改父類 onInterceptTouchEvent 方法
/**
* 子類請求父類也叫做內部攔截法
*/
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
interceptor = false
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
//拿到手指按下相當於螢幕的座標
mDownX = ev.getRawX()
mLastMoveX = mDownX
if (!mScroller.isFinished) {
mScroller.abortAnimation()
interceptor = true
}
Log.d("dispatchTouchEvent", "--->onInterceptTouchEvent, ACTION_DOWN" );
}
MotionEvent.ACTION_MOVE -> {
//拿到當前移動的 x 座標
mMoveX = ev.getRawX()
//拿到差值
mLastMoveX = mMoveX
//父類消耗移動事件,那麼自身 onTouchEvent 會被呼叫
interceptor = true
Log.d("dispatchTouchEvent", "--->onInterceptTouchEvent, ACTION_MOVE" );
}
}
return interceptor
}
<?xml version="1.0" encoding="utf-8"?>
<com.devyk.customview.sample_1.ScrollerViewPager //父節點
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
//子節點
<com.devyk.customview.sample_1.MyRecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent">
</com.devyk.customview.sample_1.MyRecyclerView>
<com.devyk.customview.sample_1.MyRecyclerView
android:id="@+id/recyclerView2"
android:layout_width="match_parent"
android:layout_height="match_parent">
</com.devyk.customview.sample_1.MyRecyclerView>
<com.devyk.customview.sample_1.MyRecyclerView
android:id="@+id/recyclerView3"
android:layout_width="match_parent"
android:layout_height="match_parent">
</com.devyk.customview.sample_1.MyRecyclerView>
</com.devyk.customview.sample_1.ScrollerViewPager>
這裡解釋一下上面程式碼的含義,首先在 MyRecylerView 中重寫 dispatchTouchEvent 事件分發事件,分別對 DOWN, MOVE 做處理。
**DOWN: **當我們手指按下的時候會執行到ViewGroup 的 dispatchTouchEvent 方法,並且會執行 ViewGroup 的 onInterceptTouchEvent 攔截事件方法,由於在 ScrollerViewPager 中重寫了 onInterceptTouchEvent 事件,可以看到上面 DOWN 只有再滑動沒有結束的情況下事件會由父類攔截,那麼一般情況下返回的就是 false 父類不攔截,當父類不攔截 DOWN 事件的時候,子節點 MyRecylerView 的 dispatchTouchEvent 的 DOWN 事件就會被觸發,大家注意看,在 DOWN 事件中,我呼叫了當前根節點 ScrollerViewPager 的 requestDisallowInterceptTouchEvent(true) 方法,其意思就是不讓父類執行 onInterceptTouchEvent 方法。
MOVE: 當我們手指滑動的時候由於我們請求父類不攔截子節點事件,ViewGroup 的 onInterceptTouchEvent 就不會執行,現在就執行到子節點的 MOVE 方法,如果當前按下的 x,y 座標減去上一次 x,y 座標 只要 deltaX 的絕對值 > deltaY 那麼就認為是在 左右滑動,現在就要攔截子節點 MOVE 事件交於父節點來處理,從而在 ScrollerViewPager 就可以了左右滑動。反之就認為在上下滑動,子節點來處理。
可以看到內部攔截法比較複雜,不僅要修改子節點內部程式碼,還要修改父節點方法,其穩定和可維護性明顯不如外部攔截法,所以還是推薦大家使用外部攔截法來解決時間衝突。
下面看一個 APP 常用功能,側滑刪除實現,一般側滑是由一個 RecyclerView + 側滑自定義 ViewGroup 來實現:
實戰
參考該 Demo 中的實現,從中你可以學到自定義 ViewGroup 、滑動衝突解決等技術。
[圖片上傳失敗...(image-acb847-1636703567722)]
本文轉自 https://juejin.cn/post/6844904002753150983,如有侵權,請聯絡刪除。
**更多Android系列教程上傳在 bilibili: https://space.bilibili.com/686960634