上一篇文章講了View分發機制的原始碼,這次來講講解決View滑動衝突的方式和原理。
一. 滑動衝突場景以及產生原因
產生滑動衝突的場景主要有兩種:
- 父ViewGroup和子View的滑動方向一致
- 父ViewGroup和子View的滑動方向不一致
那為什麼會產生滑動衝突呢,例如在父ViewGroup和子View的滑動方向一致的情況,我需要讓兩者都可以滑動。在上篇部落格中我們分析了事件分發機制,其中提到ViewGroup的onInterceptTouchEvent方法預設情況下是返回false,也就是ViewGroup預設情況下是不會攔截事件的。當ViewGroup接收到事件時,由於不攔截事件,會去尋找能夠處理事件的子View。此時,一旦子View處理了DOWN事件,預設情況下接下來同一事件序列的其他事件都交由子View處理,此時可以看到的效果是子View可以滑動,但是父ViewGroup始終滑動不了,此時滑動衝突就出現了。
二. 滑動衝突的解決方式
滑動衝突主要有兩種解決方式:外部攔截法和內部攔截法
2.1 ViewPager 滑動衝突
例如我們使用ViewPager時,往往會結合Fragment,然後Fragment內部為一個ListView。這裡ViewPager已經為我們解決了滑動衝突,因此在使用時並不會衝突。試想下,若ViewPager未解決滑動衝突,預設情況下ViewPager的onInterceptTouchEvent方法返回false,由於ListView可以滾動,代表ListView可以處理事件,所以所有事件都被ListView處理了,因此我們看到的效果會是ListView可以在豎直方向上滾動,但是ViewPager在水平方向上無法滑動。
可以重寫ViewPager,讓ViewPager的onInterceptTouchEvent方法返回預設狀態下的false,ViewPager內部是多個ListView。
public class MyViewPager extends ViewPager {
public MyViewPager(@NonNull Context context) {
super(context);
}
public MyViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return false;
}
}
複製程式碼
執行效果如圖
所以ViewPager是如何解決這樣的滑動衝突的呢,由此引出外部攔截法。
2.2 外部攔截法
2.2.1 原理
所謂外部攔截法,就是當事件傳遞到父容器時,通過父容器去判斷自己是否需要此事件,若需要則攔截事件,不需要則不攔截事件,將事件傳遞給子View。 上述MyViewPager和ListView顯然產生了滑動衝突,我們來分析下。我們要的效果是在水平方向上滑動時ViewPager可以水平滾動,在豎直方向上滑動時,ListView可以滾動但ViewPager不動,因此我們需要為ViewGroup指定事件處理的條件,於是就有了下面的虛擬碼。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_MOVE:
if (ViewPager需要此事件) {
return true;
}
break;
default:
break;
}
return false;
}
複製程式碼
現在我們來分析下為什麼這段程式碼可以解決滑動衝突。
這邊首先要注意一點,外部攔截時在重寫ViewGroup的onInterceptTouchEvent方法時,ViewGroup不能攔截DOWN事件和UP事件。因為一旦ViewGroup攔截了DOWN事件,也就是和mFirstTouchTarget始終為空,同一事件序列中的其他事件都不會再往下傳遞;若ViewGroup攔截了UP事件,則子View就不會觸發單擊事件,因為子View的單擊事件是在UP事件時被觸發的。
- 當ViewPager接收到DOWN事件,ViewPager預設不攔截DOWN事件,DOWN事件交由ListView處理,由於ListView可以滾動,即可以消費事件,則ViewPager的mFirstTouchTarget會被賦值,即找到處理事件的子View。然後ViewPager接收到MOVE事件,
- 若此事件是ViewPager不需要,則同樣會將事件交由ListView去處理,然後ListView處理事件;
- 若此事件ViewGroup需要,因為DOWN事件被ListView處理,mFirstTouchEventTarget會被賦值,也就會呼叫onInterceptedTouchEvent,此時由於ViewPager對此事件感興趣,則onInterceptedTouchEvent方法會返回true,表示ViewPager會攔截事件,此時當前的MOVE事件會消失,變為CANCEL事件,往下傳遞或者自己處理,同時mFirstTouchTarget被重置為null。
- 當MOVE事件再次來到時,由於mFristTouchTarget為null,所以接下來的事件都交給了ViewPager。
2.2.2 解決方式
這邊ViewPager處理事件的條件可以有多種方法,例如水平方向和豎直方向上的滑動速度、水平方向和豎直方向的滑動距離等。這邊根據滑動距離判斷,當水平方向的滑動距離大於豎直方向的滑動距離,則ViewPager處理事件,反之則將事件傳遞給ListView。
public class MyViewPager extends ViewPager {
private int mLastX;
private int mLastY;
public MyViewPager(@NonNull Context context) {
super(context);
}
public MyViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
//一些ViewPager拖拽的標誌位要設定,必調super,否則看不到效果
super.onInterceptTouchEvent(ev);
boolean isIntercepted = false;
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
if (needEvent(ev)) {
isIntercepted = true;
}
break;
default:
}
mLastX = (int) ev.getX();
mLastY = (int) ev.getY();
LogUtils.d(" lastX = " + mLastX + " lastY = " + mLastY);
return isIntercepted;
}
private boolean needEvent(MotionEvent ev) {
//水平滾動距離大於垂直滾動距離則將事件交由ViewPager處理
return Math.abs(ev.getX() - mLastX) > Math.abs(ev.getY() - mLastY);
}
}
複製程式碼
執行效果:
2.2.3 總結
- 外部攔截法主要是父容器去控制事件的攔截,若事件是父容器需要的,則進行攔截,不需要的則向下傳遞。
- 父容器不能攔截DOWN事件或者UP事件。
2.2.4 通用模板
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean isIntercept = false;
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
//DOWN事件不能攔截,否則事件將無法分發到子View
isIntercept = false;
break;
case MotionEvent.ACTION_MOVE:
//根據條件判斷是否攔截事件
isIntercept = needThisEvent();
break;
case MotionEvent.ACTION_UP:
//一旦父容器攔截了UP事件,子View將無法觸發點選事件
isIntercept = false;
break;
default:
break;
}
return isIntercept;
}
複製程式碼
2.3 內部攔截法
2.3.1 衝突場景
下面講一種稍微複雜一點的同向滑動衝突。ScrollView內部的內部的LinearLayout存在三個子View,從上到下分別為ImageView、ListView以及TextView。
先上下效果圖:
可以看到現在需要的效果是觸控ListView外部的區域,ScrollView的滑動不受限制。當觸控ListView區域時,存在多種情況。當ListView滾動到頂部時(ListView處於初始狀態),此時若手指往下滑動,則ScrollView往下滑動;當ListView滾動到底部時,若此時手指往上滑動,則ScrollView往上滑動,其餘情況下ListView滾動。
2.3.2 原理
內部攔截法: ViewGroup預設情況下不攔截事件,由子View去控制事件的處理,若子View需要此事件,則自己處理,否則交由父容器處理。
使用內部攔截需要同時重寫父ViewGroup的onInterceptTouchEvent和ViewGroup中需要解決衝突的子View的dispatchTouchEvent方法,和上面一樣,先上虛擬碼。
子View虛擬碼
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
//禁止父容器攔截事件
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
if (當期View不需要此事件) {
// 允許父容器攔截事件
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
default:
break;
}
return super.dispatchTouchEvent(ev);
}
複製程式碼
ViewGroup 虛擬碼
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
return false;
default:
return true;
}
}
複製程式碼
這邊我們結合ScrollView和ListView這個具體例項和流程圖進行分析。
首先父容器ScrollView不能攔截DOWN事件,必須將DOWN事件分發至子View,這邊子View是 ListView,因為父容器一旦攔截DOWN事件,同一事件序列中的其他事件都不會傳遞到子View,這點在事件分發原始碼分析時已經分析了,這裡不再贅述。
由於內部攔截是將事件交由子View,由子View去控制事件的處理,所以事件在一開始不能被父ViewGroup直接攔截,由於DOWN事件被子View處理,此時mFristTonchTarget不為null,在預設情況下會去呼叫onInterceptedTouchEvent,若針對該事件該方法返回true,則事件就會被父容器攔截了,事件顯然不會傳遞到子View,但是我們需要將事件傳遞到子View,讓子View去控制事件的處理。那我們要怎麼將事件傳遞到子View呢?從原始碼可以看到在呼叫onInterceptedTouchEvent方法前還有一個判斷。
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
//是否禁止攔截事件,預設為false
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;
}
複製程式碼
從原始碼可以看到,會根據disallowIntercept的值判斷是否要呼叫onInterceptTouchEvent這個方法,disallowIntercept預設為false。此時若可以將disallowIntercept的值變為true,就可以繞過onIntercepted方法,將事件傳遞到子View了,也就是我們需要在MOVE事件到來之前給mGroupFlags設定FLAG_DISALLOW_INTERCEPT標誌位,設定好後,若MOVE事件到來,disallowIntercept的值就會變為true,就會繞過onInterceptedTouchEvent方法的執行,將事件傳遞到子View了,那如何在MOVE事件到來之前給ViewGroup設定這個標誌位呢?我們可以在ViewGroup中看到這個方法。
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
return;
}
if (disallowIntercept) {
mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
} else {
mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
}
// Pass it up to our parent
if (mParent != null) {
mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
}
}
複製程式碼
可以看到,若在呼叫requestDisallowInterceptTouchEvent方法時,引數為true,則mGroupFlags設定了FLAG_DISALLOW_INTERCEPT標誌位,也就是disallowIntercept的值就會變為true。至於呼叫時機,我們只需要在子View接收到DOWN事件時呼叫該方法即可,此後父ViewGroup會直接將事件傳遞給處理DOWN事件的子View。
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
//禁止父容器攔截事件
getParent().requestDisallowInterceptTouchEvent(true);
break;
...
}
...
}
}
複製程式碼
若接下來的事件是子View感興趣的,則直接處理掉,如果子View對事件不感興趣,則將事件交還給父View,讓它去處理。那麼問題又來了,如何將子View不需要的事件重新交還給父View處理?此時可能有人會說,在事件分發中,子View處理不了的事件,不是自動會交給父ViewGroup處理嗎?我們說的子View處理不了的事件會傳遞給父ViewGroup處理,這個是針對預設的DOWN事件分發流程,但是在這不是DOWN事件且這裡存在人工干預的情況,真的會是這樣嗎,我們來看看原始碼。
先明確下當前的情景,子View處理了DOWN事件和部分MOVE事件,此時父ViewGroup的mFirstTouchEvent肯定是不為null的。接下來的MOVE事件子View不需要,也就是子View不做處理,那麼子View的dispatchTouchEvent方法會返回false。
public boolean dispatchTouchEvent(MotionEvent ev) {
...
if (mFirstTouchTarget == null) {
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
//子View 不處理事件, 子View的dispatchTouchEvent返回false,dispatchTransformedTouchEvent為false
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}
if (canceled
|| actionMasked == MotionEvent.ACTION_UP
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
resetTouchState();
} else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) {
final int actionIndex = ev.getActionIndex();
final int idBitsToRemove = 1 << ev.getPointerId(actionIndex);
removePointersFromTouchTargets(idBitsToRemove);
}
if (!handled && mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
}
//直接返回false
return handled;
}
複製程式碼
從原始碼可以看到,在這個情景下,ViewGroup的dispatchTouchEvent方法會直接返回false,不處理當前子View不感興趣的MOVE事件,父ViewGroup的父容器也是這樣直接返回false,直到傳遞給Activity,事件被Activity處理或者消失。並且當再一個MOVE事件來臨時,MOVE還是會傳遞到子View,但是子View對當前MOVE事件不感興趣,也就是說之後的所有MOVE事件都不會被父ViewGroup處理,這樣明顯是存在問題的。所以子View在對事件不感興趣時,要如何事件處理權交給父ViewGroup?我們在子View 通過呼叫ViewGroup的requestDisallowInterceptTouchEvent方法,禁止父ViewGroup攔截事件,同樣也可以在子View對事件不感興趣時,呼叫ViewGroup的requestDisallowInterceptTouchEvent方法,允許父容器去攔截事件。
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_MOVE:
if (當期View不需要此事件) {
// 允許父容器攔截事件
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
default:
break;
}
return super.dispatchTouchEvent(ev);
}
複製程式碼
對子View來說,對事件處理的控制邏輯已經完成了,但是對於父ViewGroup來說並沒有,必須要重寫ViewGroup的onInterceptedTouchEvent方法,讓MOVE和UP事件返回true,表示攔截子View不感興趣的事件,這邊父ViewGroup攔截MOVE事件是可以理解的,但是為什麼要攔截UP事件呢,因為父ViewGroup只有攔截了UP事件才可以接收單擊事件。
2.3.3 具體實現
上述分析了原理,現在來真正解決一下ScrollView和ListView的滑動衝突。其實內部攔截的模板已經在虛擬碼中體現了。只要實現子View 對事件處理的判斷即可。我們需要監聽ListView滾動到頂部和底部的狀態,當ListView滾動到頂部時且手指觸控方向向下或者ListView滾動到底部且手機觸控方向向上,則將事件交由ScrollView處理。
public class MyListView extends ListView implements AbsListView.OnScrollListener {
private boolean isScrollToTop;
private boolean isScrollToBottom;
private int mLastX;
private int mLastY;
public MyListView(Context context) {
this(context, null);
}
public MyListView(Context context, AttributeSet attrs) {
this(context, attrs, -1);
}
public MyListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
setOnScrollListener(this);
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
LogUtils.d("" + Constants.getActionName(ev.getAction()));
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
getParent().requestDisallowInterceptTouchEvent(true);
mLastX = (int) ev.getX();
mLastY = (int) ev.getY();
break;
case MotionEvent.ACTION_MOVE:
if (superDispatchMoveEvent(ev)) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
LogUtils.d("ACTION_UP");
break;
default:
break;
}
return super.dispatchTouchEvent(ev);
}
/**
* 將事件交由父容器處理
*
* @param ev
* @return
*/
private boolean superDispatchMoveEvent(MotionEvent ev) {
//下滑
boolean canScrollBottom = isScrollToTop && (ev.getY() - mLastY) > 0;
boolean canScrollTop = isScrollToBottom && (ev.getY() - mLastY) < 0;
return canScrollBottom || canScrollTop;
}
@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
}
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
isScrollToBottom = false;
isScrollToTop = false;
if (firstVisibleItem == 0) {
android.view.View firstVisibleItemView = getChildAt(0);
if (firstVisibleItemView != null && firstVisibleItemView.getTop() == 0) {
LogUtils.d("##### 滾動到頂部 ######");
isScrollToTop = true;
}
}
if ((firstVisibleItem + visibleItemCount) == totalItemCount) {
View lastVisibleItemView = getChildAt(getChildCount() - 1);
if (lastVisibleItemView != null && lastVisibleItemView.getBottom() == getHeight()) {
LogUtils.d("##### 滾動到底部 ######");
isScrollToBottom = true;
}
}
}
}
複製程式碼
至於ScrollView,預設在拖拽狀態下會攔截MOVE事件,預設不攔截UP事件,若需要攔截UP事件,可重寫ScrollView的onInterceptTouchEvent方法,但不是必須攔截UP事件,若父ViewGroup不需要觸發單擊事件,則可以不攔截。
public class MyScrollView extends ScrollView {
public MyScrollView(Context context) {
super(context);
}
public MyScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public MyScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = super.onInterceptTouchEvent(ev);
if (ev.getAction() == MotionEvent.ACTION_UP) {
intercepted = true;
}
return intercepted;
}
}
複製程式碼
2.3.4 總結
- 內部攔截法是將事件控制權交給子View,若子View需要事件,則對事件進行處理,不需要則將事件傳遞給父ViewGroup,讓父ViewGroup處理。
- 子View通過呼叫父ViewGroup的requestDisallowInterceptTouchEvent來干預父ViewGroup對事件的攔截狀況
- 父ViewGroup不能攔截DOWN事件,至於MOVE或者UP事件的攔截狀態要根據具體的情景
好了,到這裡兩種解決滑動衝突的方式就介紹完了,但要注意的是解決ViewPager與ListView滑動衝突並不是只能用外部攔截,同樣可以使用內部攔截實現,第二個情景也是一樣。解決方式並不是絕對的,我們要做的是選擇最方便實現的方案。