本篇文章主要介紹以下幾個知識點:
- View 的事件分發;
- View 的滑動沖突。
3.4 View 的事件分發
3.4.1 點擊事件的傳遞規則
點擊事件的分發,也就是對 MotionEvent 事件的分發過程,即當一個 MotionEvent 產生后,系統把這個事件傳遞給一個具體的 View,而這個傳遞的過程就是分發過程。
點擊事件的分發過程由三個很重要的分發來完成 dispatchTouchEvent,onInterceptTouchEvent
和 onTouchEvent
。
puhlic boolean dispatch TouchEvent(MotionEvent event)
??用來進行事件的分發。如果事件能夠傳遞給當前 View,那么此方法一定會被調用,返回結果受當前 View 的onTouchEvent
和下級 View 的dispatchTouchEvent
方法的影響,表示是否消耗當前事件。public boolean onIntercept TouchEven(MotionEvent event)
??在上述方法內部調用,用來判斷是否攔截某個事件,如果當前 View 攔截了某個事件,那么在同一個事件序列當中,此方法不會被再次調用,返回結果表示是否攔截當前事件。public boolean onTouchEvent(MotionEvent event)
??在dispatchTouchEvent
方法中調用,用來處理點擊事件,返回結果表示是否消耗當前事件,如果不消耗,則在同一個事件序列中,當前 View 無法再次接收到事件。
上述關系可以用以下偽代碼表示:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
if(onInterceptTouchEvent(ev)){
consume = onTouchEvent(ev);
}else {
consume = child.dispatchTouchEvent(ev);
}
return consume;
}
通過上述的偽代碼,大致可以了解傳遞的規則:對于一個根 ViewGroup 來說,點擊事件產生以后,首先傳遞給它,這時它的 dispatchTouchEvent
就會被調用,若這個 ViewGroup 的 onIntereptTouchEvent
方法返回 true 就表示它要控截當前事件,事件就會交給這個 ViewGroup 處理,即他的 onTouchEvent
方法就會被調用;若這個 ViewGroup 的 onIntereptTouchEvent
方法返回 false 就表示不需要攔截當前事件,這時當前事件就會繼續傳遞給它的子元素,接著子元素的 onIntereptTouchEvent
方法就會被調用,如此反復直到事件被最終處理。
當一個 View 需要處理事件時,若它設置了 OnTouchListener
,那么OnTouchListener
中的 onTouch
方法會被回調。這時事件如何處理還要看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 攔截且消耗。其原因可以參考(3),因為一旦一個元素攔截了某此事件,那么同一個事件序列內的所有事件都會直接交給它處理,因此同一個事件序列中的事件不能分別由兩個 View 同時處理(除非一個 View 將本該自己處理的事件通過 onTouchEvent 強行傳遞給其他 View 處理)。
(3)某個 View 一旦決定攔截,那么這一個事件序列都只能由它來處理(若事件序列能夠傳遞給它的話),并且它的 onInterceprTouchEvent
不會再被調用。
(4)某個 View 一旦開始處理事件,如果它不消耗 ACTON_DOWN 事件(onTouchEvent
返回了 false),那么同一事件序列中的其他事件都不會再交給它來處理,并且事件將重新交由它的父元素去處理,即父元素的 onTouchEvent
會被調用。也就是說事件一旦交給一個 View 處理,那么它就必須消耗掉,否則同一事件序列中剩下的事件就不再交給它來處理了。
(5)如果 View 不消耗除 ACTION_DOWN 以外的其他事件,那么這個點擊事件會消失,此時父元素的onTouchEvent
并不會被調用,并且當前 View 可以持續收到后續的事件,最終這些消失的點擊事件會傳遞給 Activity 處理。
(6)ViewGroup 默認不攔截任何事件。Android 源碼中 ViewGroup 的 onInterceptTouchEvent
方法默認返回 false。
(7)View沒有 onInterceptTouchEvent
方法,一旦有點擊事件傳遞給它,那么它的 onTouchEvent
方法就會被調用。
(8)View 的 onTouchEvent
默認都會消耗事件(返回true),除非它是不可點擊的(clickable 和 longClickable 同時為 false)。View 的 longClickable 屬性默認為 false,clickable 屬性要分情況,比如Button 的 clickable 屬性默認為 true,而 TextView 的 clickable 屬性默認為 false。
(9)View 的 enable 屬性不影響 onTouchEvent
的默認返回值。哪怕一個 View 是 disable 狀態的,只要它的 clickable 或者 longclickable 有一個為 true,那么它的 onTouchEvent
就返會 true。
(10)onClick 會發生的前提實際當前的 View 是可點擊的,并且它收到了 down 和 up 的事件。
(11)事件傳遞過程是由外到內的,即事件總是先傳遞給父元素,再由父元素分發給子 View,通過 requestDisallowInterptTouchEvent
方法可以在子元素中干預元素的事件分發過程,但是 ACTION_DOWN 除外。
3.4.2 事件分發的源碼解析
3.4.2.1 Activity 對點擊事件的分發過程
點擊事件用 MotionEvent 來表示,當一個點擊操作發生的時,事件最先傳遞給 Activity,由 Activity 的 dispatchTouchEvent 來進行事件的派發,具體的工作是由 Activity 內部的 window 來完成的,window 會將事件傳遞給 decor view,decor view 一般都是當前界面的底層容器(setContentView 所設置的父容器),通過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 的 onTouchEvent
都返回 false,那么 Activity 的 onTouchEvent
就會被調用。
接下來看下 Window 是如何將事件傳遞給 ViewGroup 的,通過源碼知道,Window 是個抽象類,而Window 的 superDispatchTouchEvent 方法也是抽象的,因此必須找到 Window 的實現類才行。
public abstract boolean superDispatchTouchEvent(MotionEvent event);
Window 的唯一實現類是 PhoneWindow,接下來看一下它是如何處理點擊事件的。如下:
public boolean superDispatchTouchEvent(MotionEvent ev){
return mDecor.superDispatchTouchEvent(ev);
}
可以看到,phoneWindow 傳遞給了 DecorView,類 DecorView 如下:
public class DecorView extends FrameLayout implements RootViewSurfaceTaker {
private DecorView mDecor;
@Override
public final View getDecorView(){
if(mDecor == null){
installDecor():
}
return mDecor;
}
}
通過 ((ViewGroup)getWindow().getDecorView().findViewByld(android.R.id.content)).getChildAt(0)
可獲取 Activity 所設置的 View,這個 mDecor 顯然是 getWindow().getDecorView()
返回的 View,而通過 setContentView
設置的 View 是它的一個子 View。目前事件傳遞到了 Decorview 這里,由于 DecorView 繼承自 FrameLayout 且是父 View,所以最終事件會傳遞給 View。從這里開始,事件已經傳遞到頂級 View 了,即在Activity中通過 seContentview
所設置的 View,另外頂級 View 也叫根 View,頂級 View 一般來說都是 VewGroup。
3.4.2.2 頂級 View 對事件的分發過程
回顧下點擊事件在 View 中進行的分發:點擊事件達到頂級View(一般是一個ViewGroup)后,會調用 ViewGiroup 的 dispatchTouchEvent
方法,然后的邏輯:若頂級 ViewGroup 攔截事件即 onIntercepTouchEvent
返回 true,則事件由 ViewGroup 處理,這時若 ViewGroup 的 mOnTouchListener
被設置,則 onTouch
會被調用,否則 onTouchEvent
會被調用。也就是說,如果都提供的話,onTouch
會屏蔽掉 onTouchEvent
。在onTouchEvent
中,若設置了 mOnTouchListener
,則 onClick
會被調用。若頂級 ViewGroup 不攔截事件,則事件會傳遞給它所在的點擊事件鏈上的子 View,這時子 View 的dispatchTouchEvent
會被調用。到此為止,事件已經從頂級 View 傳遞給了下一層 View,接下來的傳遞過程和頂級 View 是一致的, 如此循環,完成整個事件的分發。
首先看 ViewGroup 對點擊事件的分發過程,其主要實現在 ViewGroup 的dispatchTouchEvent
方法中,此方法較長,先看下面一段,它描述的是當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
時會判斷是否要攔截當前事件。當 ViewGroup 不攔截事件并將事件交由子元素處理時 mFirstTouchTarget != null
。反過來,一旦事件由當前 ViewGroup 攔截時,mFirstTouchTarget !=null
就不成立。那么當 ACTION_MOVE
和 ACTION_UP
事件到來時,由于 actionMasked == MotionEvent.ACTION_DOWN|| mFirstTouchTarget != null
這個條件為 false,將導致 ViewGroup 的 onInterceptTouchEvent
不會再被調用,并且同一序列中的其他事件都會默認交給它處理。
下面代碼中,ViewGroup 會在 ACTION_DOWN
事件到來時做重置狀態的操作,而在requsstTouchState
方法中會對 FLAG_DISALLOW_INTERCEPT
進行重置,因此子 View 調用request-DisallowInterceptTouchEvent
方法并不能影響 ViewGroup 對 ACTION_DOWN
事件的處理:
// 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 決定攔截事件之后,那么后續的點擊事件,將會默認交給他處理并且不再調用他的 onInterceptTouchEvent
方法。
接著再看 ViewGroup 不攔截事件時,事件會向下分發由他的子 View 進行處理:
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 (!canViewReceivePointerEvents(child)|| !isTransformedTouchPointInView(x, y, child, null)) {
ev.setTargetAccessibilityFocus(false);
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();
if (preorderedList != null) {
// childIndex points into presorted list, find original index
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;
}
上面代碼,首先遍歷的是 ViewGroup 的所有子元素,然后判斷子元素是否能接到點擊事件。否能接到點擊事件主要是兩點來衡量:子元素是否在播動畫和點擊是按的坐標是否落在子元素的區域內。若某子元素滿足這兩個條件,那么事件就會傳遞給他處理。
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
}
若子元素的 dispatchTouchEvent
返回 true,那么 mFirstTouchTarget 就會被賦值同時跳出 for循環:
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
這幾行代碼就完成了 mFirstTouchTarget 的賦值并且并終止對子元素的遍歷。若子元素的 dispatchTouchEvent
返回 false,ViewGroup 就會把事件分給下一個子元素。
mFirstTouchTarget 真正的賦值過程是在 addTouchTarget 內部完成的,從下面的 addTouchTarget 的內部結構就可以看出,mFirstTouchTarget 其實是一種單鏈表的結構,mFirstTouchTarget 是否被賦值,將直接影響到 ViewGroup 對事件的攔截策略,若 mFirstTouchTarget 為 null,那么 ViewGroup 就默認攔截下來統一序列中所有的點擊事件:
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;
mFirstTouchTarget = target;
return target;
}
若遍歷所有的子元素后事件都沒有被合適的處理,有兩種情況:第一是 Viewgroup 沒有子元素,第二是子元素處理了點擊事件,但是在 dispatchTouchEvent
中返回 false,這一般是因為子元素在 onTouchEvent
返回了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,它會調用 supe.dispatchTouchEvent(event)
,從而就轉到了 View 的 dispatchTouchEvent
方法,即點擊事件開始交由 View 處理了。
3.4.2.3 View 對點擊事件的處理
View 對點擊事件的處理稍微簡單一些, 注意這里的 View 不包含 ViewGroup。先看它的dispatchTouchEvent
方法:
public boolean dispatchTouchEvent(MotionEvent event) {
boolean result = false;
. . .
if (onFilterTouchEventForSecurity(event)) {
if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
result = true;
}
//noinspection SimplifiableIfStatement
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 對點擊事件的處理首選會判斷是否有設置 onTouchListener
,若 onTouchListener
中的 onTouch 為 true,那么 onTouchEvent
就不會被調用,可見 onTouchListener
的優先級高于onTouchEvent
,其好處是方便在外界處理點擊事件。
接著分析 onTouchEvent
的實現,先看當 View 處于不可用的狀態下點擊事件的處理過程,如下(不可用狀態下的 View 照樣會消耗點擊事件,盡管它看起來不可用):
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
方法:
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 (!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,不可點擊的為 false,如 button 是可點擊的,textview 是不可點擊的。從源碼可知道通過設置點擊可改變狀態:
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.5 View 的滑動沖突
3.5.1 常見的滑動沖突場景
常見的滑動沖突場景可簡單分為如下3種:
- 場景一:外部滑動方向和內部滑動方向不一致
- 場景二:外部滑動方向和內部滑動方向一致
- 場景三:上面兩種情況的嵌套
場景1,主要是將 ViewPager 和 Fragment 配合使用所組成的頁面滑動效果。在這種效果中,可以通過左右滑動來切換頁面,而每個頁面內部往往又是一個 Listview。本來這種情況下是有滑動沖突的,但是 ViewPager 內部處理了這種滑動沖突,因此采用 ViewPager 時我們無須關注這個問題,若采用的不是 ViewPager 而是 ScrollView等,那就必須手動處理滑動沖突了, 否則造成的后果就是內外兩層只能有一層能夠滑動,這是因為兩者之間的滑動事件有沖突。除了這種典型情況外,還存在其他情況,如外部上下滑動、內部左右滑動等,但它們屬于同一類滑動沖突。
場景2,當內外兩層都在同一個方向可以滑動的時候,顯然存在邏輯問題。在實際的開發中,這種場景主要是指內外兩層同時能上下滑動或者內外兩層同時能左右滑動。
場景3,是場景1和場景2兩種情況的嵌套。雖然說場景3的滑動沖突看起來更復雜,但它是幾個單一的滑動沖突的疊加,因此只需要分別處理內層和中層、中層和外層之間的滑動沖突即可,而具體的處理方法其實是和場景1、場景2相同的。
從本質上來說,這三種滑動沖突場景的復雜度其實是相同的,區別僅僅是滑動策略的不同,其解決方法是通用的。
3.5.2 滑動沖突的處理規則
對于場景1,其處理規則是:當用戶左右滑動時,需要讓外部的View攔截點擊事件,當用戶上下滑動時,需要讓內部View攔截點擊事件。這個時候我們就可以根據滑動是水平滑動還是豎直滑動來判斷到底由誰來攔截事件。如下圖所示,根據滑動過程中兩個點之間的坐標就可以得出到到底由誰來攔截事行:比如可以依據滑動路徑和水平方向做形成的夾角,也可以依據水平方向和豎直方向上的距離差來判斷,某些特殊時候還可以依據水平和豎直方向的速度差來做判斷。這里可以通過水平和豎直方向的距離差來判斷,比如豎直方向滑動的距離大就判斷為豎直滑動,否則判斷為水平滑動。
對于場景2來說,比較特殊,它無法根據滑動的角度、距離差以及速度差來做判斷,但是這個時候一般都能在業務上找到突破點,比如業務上有規定:當處于某種狀態時需要外部 View 響應用戶的滑動,而處于另外一種狀態時則需要內部 View 來響應 View 的滑動,根據這種業務上的需求我們也能得出相應的處理規則(比較抽象)。
對于場景3來說,它的滑動規則就更復雜了,具體方法和場景2一樣,都是從業務的需求上得出相應的處理規則。
3.5.3 滑動沖突的解決方式
首先分析第一種滑動沖突場景,這也是最簡單、最典型的一種滑動沖突,因為它的滑動規則比較簡單,不管多復雜的滑動沖突,它們之間的區別僅僅是滑動的規則不同而已。
上面說過,針對場景1中的滑動,我們可以根據滑動的距離差來進行判斷,這個距離差就是我們的的滑動規則。針對滑動沖突,要用到事件分發機制,這里給出兩個解決辦法:
- 外部攔截法
所謂的外部攔截法是指點擊事件都先經過父容器的攔截處理,若父容器需要此事件就攔截,反之,不攔截。外部攔截法得重寫父容器的 onIterceptTouchEvent
,在內部做相應的攔截即可,偽代碼如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
// 1. ACTION_DOWN 事件,必須返回false,即不攔截 ACTION_DOWN 事件,
// 因為一旦父容器攔截了 ACTION_DOWN,那么后續的 ACTION_MOVE 和
// ACTION_UP事件都會直接交由父容器處理,事件就沒法再傳遞給子元素了
intercepted = false;
break;
case MotionEvent.ACTION_MOVE:
// 2. ACTION_MOVE 事件,可以根據需要來決定是否攔截,
// 若父容器需要攔截就返回true,否則返回false
if("父容器需要當前點擊事件"){
intercepted = true;
}else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
// 3. ACTION_UP 事件,這里必須要返回false,
// 因為 ACTION_UP 事件本身沒有太多意義
intercepted = false;
break;
}
mLastXIntercept = x;
mLastYIntercept = x;
return intercepted;
}
上述代碼是外部攔截法的典型邏輯,針對不同的滑動沖突,只需要修改父容器需要當前點擊事件這個條件即可,其他均不需做修改并且也不能修改。
- 內部攔截法
內部攔截法是指父容器不攔截任何事件,所有的事件都傳遞給子元素,如果子元素要消耗此事件就直接消耗掉,否則就交由父容器進行處理,這種方法和 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:
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = x - mLastY;
if("父容器需要此點擊事件"){
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
上述代碼就是內部攔截法的典型代碼,當面對不同的滑動策略只需要修改里面的條件即可,其他不需要做改動。
父元素修改如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
if(action == MotionEvent.ACTION_DOWN){
// 由于 ACTION_DOWN 事件并不受 FLAG_DISALLOW_DOWN 這個標記位的控制,
// 一旦父容器攔截,那么所有的事件都無法傳遞到子元素中,會造成內部攔截不起作用
// 因此這里返回 false
return false;
}else {
// 父元素默認攔截除 ACTION_DOWN 之外的其他事件,
// 這樣當子元素調用 getParent().requestDisallowInterceptTouchEvent(true)方法時,
// 父元素才能繼續攔截所需要的事件
return true;
}
}
下面通過一個實例來分別介紹這兩種用法,我們來實現一個類似于 ViewPgaer 中嵌套 ListView 的效果,為了制造滑動沖突,我們寫一個類似 ViewPager 的控件即可,名字叫做 HorizontalScrollViewEx。
為了實現 ViewPager 的效果,定義一個類似于水平的 LinearLayout,只不過它可以水平滑動,初始化時,在它的內部添加若干個 ListView,這樣一來,由于ListView是可以豎直滑動的。而它本身就可以水平滑動,一個典型的滑動沖突(場景 1)就出現了。
首先 Activity 的實現如下:
public class MainActivity extends AppCompatActivity {
public static final String TAG = "MainActivity";
private HorizontalScrollViewEx mListContainer;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Log.i(TAG,"onCreate");
initView();
}
private void initView() {
LayoutInflater inflater = getLayoutInflater();
mListContainer = findViewById(R.id.container);
// 屏幕寬高
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
final int w= wm.getDefaultDisplay().getWidth();
final int h = wm.getDefaultDisplay().getHeight();
// 創建 3 個ListView (子元素)并把它加入到自定義的父容器 HorizontalScrollViewEx 中
for (int i = 0; i < 3; i++) {
ViewGroup layout = inflater.inflate(R.layout.content_layout,mListContainer,false);
layout.getLayoutParams().width = w;
TextView textview = (TextView) layout.findViewById(R.id.title);
textview.setText("page" + (i+1));
layout.setBackgroundColor(Color.rgb(255/(i+1),255/(i+1),0));
createList(layout);
mListContainer.addView(layout);
}
}
private void createList(ViewGroup layout) {
ListView listview = (ListView) layout.findViewById(R.id.list);
ArrayList<String>datas= new ArrayList<>();
for (int i = 0; i < 50; i++) {
datas.add("names" + i);
}
ArrayAdapter<String>adapter = new ArrayAdapter<String>(this,R.layout.content_list_item,R.id.name,datas);
listview.setAdapter(adapter);
}
}
采用外部攔截法來解決這個問題,按照之前的分析,只需要修改父容器需要攔截的條件即可,對于本例來說,父容器的攔截條件就是滑動過程中水平距離差比豎直距離差要大,在這種情況下,父容器就攔截當前點擊事件,根據這一個條件進行相應修改,修改其 onInterceptTouchEvent 如下:
public class HorizontalScrollViewEx extends ViewGroup {
public static final String TAG = "HorizontalScrollViewEx";
private int mChindrensize;
private int mChindrenWidth;
private int mChindrenIndex;
//分別記錄上次滑動的坐標
private int mLastX = 0;
private int mLastY = 0;
//分別記錄上次滑動的坐標(onInterceptTouchEvent)
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
private Scroller mScroller;
private VelocityTracker mVelocityTracker;
private void init() {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
@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 = true;
if (!mScroller.isFinished()) {
// 為優化滑動體驗
mScroller.abortAnimation();
intercepted = true;
}
break;
case MotionEvent.ACTION_MOVE:
int deltax = x - mLastXIntercept;
int deltaY = y = mLastYIntercept;
if (Math.abs(deltax) > Math.abs(deltaY)) {
// 在滑動過程中,當水平方向的距離大就判斷水平滑動,讓父容器攔截事件
intercepted = true;
} else {
// 而豎直距離大于就不攔截,事件就傳遞給了ListView,
// 從而 ListView能上下滑動,這就解決了沖突
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
}
mLastX = x;
mLastY = y;
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
@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 / mChindrenWidth;
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {
mChindrenIndex = xVelocity > 0 ? mChindrenIndex - 1 : mChindrenIndex + 1;
} else {
mChindrenIndex = (scrollX + mChindrenWidth / 2) / mChindrenWidth;
}
mChindrenIndex = Math.max(0, Math.min(mChindrenIndex, mChindrensize - 1));
int dx = mChindrenIndex * mChindrenWidth - scrollX;
ssmoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
}
mLastX = x;
mLastY = y;
return true;
}
private void ssmoothScrollBy(int dx, int i) {
mScroller.startScroll(getScrollX(),0,dx,500);
invalidate();
}
@Override
public void computeScroll() {
if(mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
postInvalidate();
}
}
}
采用內部攔截法來解決這個問題,按照之前的分析,我們只需要修改 ListView 的 dispatchTouchEvent
方法中的父容器的攔截邏輯,同時讓父攔截 ACTION_MOVE 和 ACTION_UP 事件即可。自定義一個 ListView 重寫其 dispatchTouchEvent
方法如下:
public class ListViewEx extends ListView {
public static final String TAG = "ListViewEx";
private HorizontalScrollViewEx mHorizontalScrollViewEx;
// 分別記錄上次滑動的坐標
private int mLastX = 0;
private int mLastY = 0;
. . .
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mHorizontalScrollViewEx.requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int delatX = x - mLastX;
int delatY = y - mLastY;
if (Math.abs(delatX) > Math.abs(delatY)) {
mHorizontalScrollViewEx.requestDisallowInterceptTouchEvent(false);
}
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(ev);
}
}
除了對 ListView 的修改,還需要修改 HorizontalScrollViewEx 的 onInterceptTouchEvent
方法如下:
@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;
}
}
以上就是內部攔截法的示例。從實現上來看,內部攔截法的操作要稍微復雜一些,因此推薦采用外部攔截法來解決常見的滑動沖突。
前面說過,只要根據場景1來得出通用的解決方案,那么對于場景2和場景3來說只需要修改相關滑動規則的邏輯即可,下面就來演示如何利用場景1得出的通用的解決方案來解決更復雜的滑動沖突。這里只分析場景2中的滑動沖突,對于場景3中的疊加型滑動沖突,解決思想一致,這里就不分析了。
對于場景2,它的解決方法和場景1一樣,只是滑動規則不同而已,在前面己經得出了通用的解決方案,因此這里只需要替換父容器的攔截規則即可。(注:在場景2中的沖突,由于內部攔截法沒有外部攔截法簡單易用,所以推薦采用外部攔截法)
下面通過一個實際的例子來分析場景2,首先提供一個類 LinearLayout 的可以上下滑動的父容器 StickyLayout,然后在它的內部分別放一個 Header 和 一個 ListView,這樣內外兩層都能上下滑動,就形成了場景2。
這個 StickyLayout 的滑動規則:當 Header 顯示時或者 ListView 滑動到頂部時,由 StickyLayout 攔截事件;當 Header 隱藏時,若 Listview 已經滑動到頂部并且當前手勢是向下滑動的話,這時還是 StickyLayout 攔截事件,其他情況則由ListView攔截事件。
為解決其滑動沖突,需要重寫父容器 StickyLayout 的 oninterceptTouchEvent
方法如下(至于ListView則不用做任何修改):
public class StickyLayout extends LinearLayout {
private int mTouchSlop;
// 分別記錄上次滑動的坐標
private int mLastX = 0;
private int mLastY = 0;
// 分別記錄上次滑動的坐標(oninterceptTouchEvent)
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
. . .
@Override
public boolean oninterceptTouchEvent(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()) {
// 1. 當事件落在Header上面時,父容器不會攔截事件
intercepted = 0;
} else if (Math.abs(deltaY) <= Math.abs(deltaX)) {
// 2. 若豎直距離差小于水平距離差,那么父容器也不會攔截事件
intercepted = 0;
} else if (mStatus == STATUS_EXPANDED && deltaY <= -mTouchSlop) {
// 3. 當Header是展開狀態并且是向上滑動時父容器攔截事件
intercepted = 1;
} else if (mGiveUpTouchEventListener != null) {
if (mGiveUpTouchEventListener.giveUpTouchEvent(event) && deltaY >= mTouchSlop) {
// 4. 當 ListView 滑動到頂部并且向下滑動時,父容器也會攔截事件
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;
}
}
上述代碼中,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;
}
以上,滑動沖突的解決方法就介紹完畢了。