本篇文章已授權微信公眾號 hongyangAndroid (鴻洋)獨家發布
這篇文章會先講Android中View的事件分發機制,然后再介紹Android滑動沖突的形成原因并給出解決方案。因水平有限,講的不會太過深入,只希望各位看了之后對事件分發機制的流程有個大概的概念,并且以后能自己解決有關滑動沖突的問題,用語淺薄,文筆生疏,見諒。
View的事件分發機制
View的事件分發機制說白了就是點擊事件的傳遞,也就是一個Down事件,若干個Move事件,一個Up事件構成的事件序列的傳遞。
當你手指按了屏幕,點擊事件就會遵循Activity->Window->View這一順序傳遞。
這一傳遞過程有三個重要的方法,分別是:
boolean dispatchTouchEcent(MotionEvent ev),
boolean onInterceptTouchEvent(MotionEvent event),
boolean onTouchEvent(MotionEvent event)
先一個一個簡單介紹下:
dispatchTouchEcent:
只要事件傳遞到了當前View,那么dispatchTouchEcent方法就一定會被調用。返回結果表示是否消耗當前事件。
onInterceptTouchEvent:
在dispatchTouchEcent方法內部調用此方法,用來判斷是否攔截某個事件。如果當前View攔截了某個事件,那么在這同一個事件序列中,此方法不會再次被調用。返回結果表示是否攔截當前事件。
onTouchEvent:
在dispatchTouchEcent方法內調用此方法,用來處理事件。返回結果表示是否處理當前事件,如果不處理,那么在同一個事件序列里面,當前View無法再收到后續的事件。
上面的解釋聽起來比較抽象,我們可以用一段偽代碼來表示上面三個方法的關系:
public boolean dispatchTouchEvent(MotionEvent ev){
boolean consum = false;
if(onInterceptTouchEvent(ev)){
consum = onTouchEvent(ev);
}else{
consum = child.dispatchTouchEvent(ev);
}
return consum;
}
上面代碼很好的解釋了三個方法之間的關系,我們也可以從代碼中大致摸索到事件傳遞的順序規則:當點擊事件傳遞到根ViewGroup里,會執行dispatchTouchEvent,在其內部會先調用onInterceptTouchEvent詢問是否攔截事件,若攔截,則執行onTouchEvent方法處理這個事件;若不攔截,則執行子元素的dispatchTouchEvent,進入向下分發的傳遞,直到事件被處理。
在處理一個事件的時候,是有優先級的,如果設置了OnTouchListener,會先執行其內部的onTouch方法,這時若onTouch方法返回true,那么表示事件被處理了,不會向下傳遞了;如果返回了false,那么事件會繼續傳遞給onTouchEvent方法處理,在onTouchEvent方法中如果當前設置了OnClickListener,那么就會調用其onClick方法。所以其優先級為:OnTouchListen>onTouchEvent>OnClickListen。
這里有一種情況,如果一個View的onTouchEvent返回了false,那么它父容器的onTouchEvent方法將會被調用。我們寫個例子來試一下:
@Override
public boolean onTouchEvent(MotionEvent event) {
Toast.makeText(mContext, "Button", Toast.LENGTH_SHORT).show();
return false;
}
先自定義一個Button,重寫其onTouchEvent方法返回false。在自定義一個MyTouchView作為父布局。效果如下:
大家可以自己試試。
既然如此,在開頭我們說過事件的傳遞順序是Activity->Window->View,所以如果所有的元素都返回了false,那么最后事件就會再次傳遞到Activity里,由Activity的onTouchEvent方法來處理。
《Android開發藝術探索》這本書里總結了11條關于事件傳遞的結論:
1:同一個事件序列是指手機接觸屏幕那一刻起,到離開屏幕那一刻結束,有一個down事件,若干個move事件,一個up事件構成。
2:某個View一旦決定攔截事件,那么這個事件序列之后的事件都會由它來處理,并且不會再調用onInterceptTouchEvent。
3:正常情況下,一個事件序列只能被一個View攔截并消耗。這個原因可以參考第2條,因為一旦攔截了某個事件,那么這個事件序列里的其他事件都會交給這個View來處理,所以同一事件序列中的事件不能分別由兩個View同時處理,但是我們可以通過特殊手段做到,比如一個View將本該自己處理的事件通過onTouchEvent強行傳遞給其他View處理。
4:一個View如果開始處理事件,如果它不處理down事件(onTouchEvent里面返回了false),那么這個事件序列的其他事件就不會交給它來繼續處理了,而是會交給它的父元素去處理。
5:如果一個View處理了down事件,卻沒有處理其他事件,那么這些事件不會交給父元素處理,并且這個View還能繼續受到后續的事件。而這些未處理的事件,最終會交給Activity來處理。
6:ViewGroup的onInterceptToucheEvent默認返回false,也就是默認不攔截事件。
7:View沒有InterceptTouchEvent方法,如果有事件傳過來,就會直接調用onTouchEvent方法。
8:View的onTouchEvent方法默認都會消耗事件,也就是默認返回true,除非他是不可點擊的(longClickable和clickable同時為false)。
9:View的enable屬性不會影響onTouchEvent的默認返回值。就算一個View是不可見的,只要他是可點擊的(clickable或者longClickable有一個為true),它的onTouchEvent默認返回值也是true。
10:onClick方法會執行的前提是當前View是可點擊的,并且它收到了down和up事件。
11:事件傳遞過程是由外向內的,也就是事件會先傳給父元素在向下傳遞給子元素。但是子元素可以通過requestDisallowInterceptTouchEvent來干預父元素的分發過程,但是down事件除外(因為down事件方法里,會清除所有的標志位)。
滑動沖突
介紹完了事件分發機制的基本流程,我們來看看滑動沖突。滑動沖突的基本形式分為兩種,其他復雜的滑動沖突都可以拆成這兩種基本形式:
1:外部滑動方向與內部方向不一致。
2:外部方向與內部方向一致。
先來看第一種,比如你用ViewPaper和Fragment搭配,而Fragment里往往是一個豎直滑動的ListView,這種情況是就會產生滑動沖突,但是由于ViewPaper本身已經處理好了滑動沖突,所以我們無需考慮,不過若是換成ScrollView,我們就得自己處理滑動沖突了。圖示如下:
再看看第二種,這種情況下,因為內部和外部滑動方向一致,系統會分不清你要滑動哪個部分,所以會要么只有一層能滑動,要么兩層一起滑動得很卡頓。圖示如下:
對于這兩種情況,我們處理的方法也很簡單,并且都有相應的套路。
第一種:第一種的沖突主要是一個橫向一個豎向的,所以我們只要判斷滑動方向是豎向還是橫向的,再讓對應的View滑動即可。判斷的方法有很多,比如豎直距離與橫向距離的大小比較;滑動路徑與水平形成的夾角等等。
第二種:對于這種情況,比較特殊,我們沒有通用的規則,得根據業務邏輯來得出相應的處理規則。舉個最常見的例子,ListView下拉刷新,需要ListView自身滑動,但是當滑動到頭部時需要ListView和Header一起滑動,也就是整個父容器的滑動。如果不處理好滑動沖突,就會出現各種意想不到情況。
滑動沖突的處理方法
滑動沖突的攔截方法有兩種:
一種是讓事件都經過父容器的攔截處理,如果父容器需要則攔截,如果不需要則不攔截,成為外部攔截法,其偽代碼如下:
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int)event.getX();
int y = (int)event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
intercepted = false;
break;
}
case MotionEvent.ACTION_MOVE: {
if (滿足父容器的攔截要求) {
intercepted = true;
} else {
intercepted = false;
}
break;
}
case MotionEvent.ACTION_UP: {
intercepted = false;
break;
}
default:
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
在這里,首先down事件父容器必須返回false ,因為若是返回true,也就是攔截了down事件,那么后續的move和up事件就都會傳遞給父容器,子元素就沒有機會處理事件了。其次是up事件也返回了false,一是因為up事件對父容器沒什么意義,其次是因為若事件是子元素處理的,卻沒有收到up事件會讓子元素的onClick事件無法觸發。
另一種是父容器不攔截任何事件,將所有事件傳遞給子元素,如果子元素需要則消耗掉,如果不需要則通過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;
if (父容器需要此類點擊事件) {
parent.requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
然后修改父容器的onInterceptTouchEvent方法:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}
這里父容器也不能攔截down事件。
看代碼看不出所以然,我們通過實例來看看滑動沖突是怎么樣的。我們先模擬第一種場景,內外滑動方向不一致,我們先自定義一個父控件,讓其可以左右滑動,類似于ViewPaper:
然后將里面換成三個ListView:
可以看到左右滑動失效了,說明確實沖突了。那么我們就來解決一下,首先我們要明白滑動規則是什么,這個例子中如果我們豎直滑動就讓ListView消耗事件,水平滑動就讓我們自定義的父容器滑動。知道了這個我們只需要將其替換到之前偽代碼里的攔截條件里即可。
先用外部攔截法:
@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: {
Log.d(TAG, "onInterceptTouchEvent: ACTION_DOWN");
intercepted = false;
break;
}
case MotionEvent.ACTION_MOVE: {
Log.d(TAG, "onInterceptTouchEvent: ACTION_MOVE");
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
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;
}
這里我們判斷橫向滑動的距離與豎直滑動距離的長短。若是豎直滑動的長,則判斷為豎直滑動,那么就是ListView的滑動,就將intercepted置為false,讓父容器不攔截,交由子元素ListView處理。若是橫向,則intercepted置為true,交由父容器處理。
效果如下:
接下來看看內部攔截法:
先自定義一個MyListView繼承ListView,重寫其dispatchTouchEvent方法:
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
mHorizontalScrollViewEx.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)) {
mHorizontalScrollViewEx.requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
再重寫外部父容器的oninterceptTouchEvent方法:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
mLastX = x;
mLastY = y;
return false;
} else {
return true;
}
}
效果和外部攔截法一樣。
接下來看看同方向的滑動沖突,這里我們用一個豎直的ScrollView嵌套一個ListView做例子。首先看看沒有解決滑動沖突的時候是咋樣的:
我們看到只要ScrollView可以滑動,內部的ListView是不能滑動的。那我們現在來解決這個問題,同向滑動沖突和與不同向滑動沖突不一樣,得根據實際的需求來確定攔截的規則。這里我們的需求是當ListView滑到頂部了,并且繼續向下滑就讓ScrollView攔截掉;當ListView滑到底部了,并且繼續向下滑,就讓ScrollView攔截掉,其余時候都交給ListView自身處理事件。
首先用外部攔截法,我們需要重寫ScrollView的onInterceptTouchEvent方法,代碼如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
nowY = y;
intercepted = super.onInterceptTouchEvent(event);
break;
}
case MotionEvent.ACTION_MOVE: {
if(mListView.getFirstVisiblePosition()==0
&& y>nowY){
intercepted = true;
break;
}
else if(mListView.getLastVisiblePosition()==mListView.getCount()-1
&& y<nowY){
intercepted = true;
break;
}
intercepted = false;
break;
}
case MotionEvent.ACTION_UP: {
intercepted = false;
break;
}
default:
break;
}
return intercepted;
}
這里我們看到Down事件里我們并沒有返回false而是返回了super.onInterceptTouchEvent(event),這是因為ScrollView在Down方法時需要初始化一些參數如果我們直接返回false,會導致滑動出現問題。并且前面說過ViewGroup
的onInterceptTouchEvent方法是默認返回false的,所以我們這里直接返回super方法即可。
處理了滑動沖突后效果如下:
接下來看看內部攔截法:
先重寫ScrollView的onInterceptTouchEvent方法,讓其攔截除了Down事件以外的其他方法:
@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;
return super.onInterceptTouchEvent(event);
} else {
return true;
}
}
在重寫ListView的dispatchTouchEvent方法,規則已經說明過了:
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
nowY = y;
mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
if(this.getFirstVisiblePosition()==0
&& y>nowY){
mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
break;
}
else if(this.getLastVisiblePosition()==this.getCount()-1
&& y<nowY){
mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
break;
}
mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
return super.dispatchTouchEvent(event);
}
最終效果和外部攔截法一樣。
好了,這篇文章到此結束,希望各位看了能對事件分發機制有個大致的了解,并且遇到了滑動沖突的問題能夠迎刃而解。謝謝忍受我的文章。
2月17號更新:感謝 @ShadowXv所指出的問題,我已改正,改正如下:
將listView是否滑動到頂部或者底部的判斷改為
public boolean isBottom(final ListView listView) {
boolean result=false;
if (listView.getLastVisiblePosition() == (listView.getCount() - 1)) {
final View bottomChildView = listView.getChildAt(listView.getLastVisiblePosition() - listView.getFirstVisiblePosition());
result= (listView.getHeight()>=bottomChildView.getBottom());
};
return result;
}
public boolean isTop(final ListView listView) {
boolean result=false;
if(listView.getFirstVisiblePosition()==0){
final View topChildView = listView.getChildAt(0);
result=topChildView.getTop()==0;
}
return result ;
}
那么onInterceptTouchEvent方法也該改為如下所示:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted = false;
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
nowY = y;
intercepted = super.onInterceptTouchEvent(event);
break;
}
case MotionEvent.ACTION_MOVE: {
if(isTop(mListView)
&& y>nowY){
intercepted = true;
break;
}
else if(isBottom(mListView)
&& y<nowY){
intercepted = true;
break;
}
intercepted = false;
break;
}
case MotionEvent.ACTION_UP: {
intercepted = false;
break;
}
default:
break;
}
return intercepted;
}
內部攔截法也是將判斷條件換一下就可以了,這里就不貼代碼了。。。
最后再一次感謝指出問題!