在專案中,如果要用到滑動控制元件巢狀滑動控制元件,總會讓人很心塞。因為很可能會出現衝突的問題。這裡舉個例子,利用事件分發機制,處理側滑選單控制元件和列表中的側滑刪除控制元件間的衝突。
分析
提到側滑刪除,一個經典的例子就是 QQ 了。QQ 的首頁是一個大的側滑選單控制元件,巢狀一個列表,列表裡面再巢狀側滑刪除的控制元件。我們就仿照這個樣式,看看能不能做一個和它類似的效果。
這裡關注的重點是在滑動手勢的處理上,簡單分析一下需要做什麼處理:
(下面把側滑選單控制元件稱作選單控制元件,列表側滑刪除控制元件稱作刪除控制元件。)
-
在首頁上下滑動時,滾動列表。
-
選單控制元件關閉的情況下,如果列表裡面沒有展開的刪除項,則手指向右滑動是滑動選單控制元件,向左滑動是滑動刪除控制元件。
-
如果列表裡面有展開的刪除控制元件,則選單控制元件和列表項都不可滑動。除了刪除按鍵,點選其他區域,都是將展開項關閉。
-
當手指滑動刪除控制元件時,手指滑動到螢幕的任意區域都可以滑動展開項。
-
選單控制元件開啟的情況下,點選右邊主頁區域,將選單控制元件關閉。
有點複雜的感覺啊,我們一個個來解決。
我自定義了上面說到的三個控制元件,根據巢狀關係,從大到小分別是:
- 選單控制元件 SwipeMenuLayout
- 列表控制元件 MyRecyclerView
- 刪除控制元件 SwipeDeleteLayout
其中,SwipeMenuLayout 和 SwipeDeleteLayout 都是繼承自 FrameLayout,用 ViewDragHelper 實現滑動效果。MyRecyclerView 則繼承自 RecyclerView。
我們知道事件分發和三個方法有關:
- 負責分發的 dispatchTouchEvent
- 負責攔截的 onInterceptTouchEvent
- 負責消費的 onTouchEvent
簡單概括一下這個機制就是:分發從父到子,消費從子到父。
一般我們不對分發做特殊處理,下面按執行順序看看三個控制元件的 onInterceptTouchEvent 和 onTouchEvent 方法是怎麼寫的。
onInterceptTouchEvent
onInterceptTouchEvent 方法的返回值決定是否攔截事件。
選單控制元件
這部分要稍微囉嗦一點。我們先看看選單關閉的情況,這時如果手指向右滑且沒有展開的刪除控制元件,我們就可以把事件攔截了,所以 onInterceptTouchEvent 可以寫成這樣:
if (mState == State.CLOSE) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN: {
mDownX = ev.getRawX();
mDownY = ev.getRawY();
}
break;
case MotionEvent.ACTION_MOVE: {
float deltaX = ev.getRawX() - mDownX;
float deltaY = ev.getRawY() - mDownY;
//向右滑動且列表沒有展開項且橫向滑動距離比豎向滑動距離大,則攔截
if (deltaX > 0 &&
MainAdapter.mOpenItems.size() == 0 &&
Math.abs(deltaY / deltaX) < 1) {
return true;
}
}
break;
}
}
複製程式碼
mState 代表當前側滑控制元件的狀態,MainAdapter.mOpenItems 儲存的是當前開啟的刪除控制元件。我使用 Math.abs(deltaY / deltaX) 是否小於1來判斷手指的滑動方向。
這裡還有兩種不攔截的情況,向左滑動或者有展開項的話,都是和側滑選單沒關係的,滑動事件裡面再加入以下程式碼:
//如果是向左滑,且豎直滑動距離大於橫向滑動距離,不攔截
//MainPage開啟的item個數大於0,不攔截
if ((deltaX < 0 && Math.abs(deltaY / deltaX) > 1) ||
MainAdapter.mOpenItems.size() > 0) {
return false;
}
複製程式碼
接下來是選單開啟的情況。這時候當手指點選了右側的主頁面區域是需要攔截並且將選單關閉。如果手指向右滑動則不需要攔截:
if (mState == State.OPEN) {
//完全展開時並且點到主頁面,攔截並關閉選單
if (mMainContent.getLeft() <= mRange && ev.getRawX() > mRange) {
return true;
}
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mDownX = ev.getRawX();
break;
case MotionEvent.ACTION_MOVE:
//如果是向右滑,不攔截
float deltaX = ev.getRawX() - mDownX;
if (deltaX > 0) {
return false;
}
break;
}
}
複製程式碼
mRange 是側滑出來的選單寬度,關閉選單的操作可以放在 ViewDragHelper 的 Callback 方法處理。
除了上面這些情況,預設情況下是否攔截交給 ViewDragHelper 處理就好了,呼叫它的 shouldInterceptTouchEvent 方法。
完整程式碼如下:
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (mState == State.CLOSE) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN: {
mDownX = ev.getRawX();
mDownY = ev.getRawY();
}
break;
case MotionEvent.ACTION_MOVE: {
float deltaX = ev.getRawX() - mDownX;
float deltaY = ev.getRawY() - mDownY;
//向右滑動且列表沒有展開項且橫向滑動距離比豎向滑動距離大,則攔截
if (deltaX > 0 &&
MainAdapter.mOpenItems.size() == 0 &&
Math.abs(deltaY / deltaX) < 1) {
return true;
}
//如果是向左滑,且豎直滑動距離大於橫向滑動距離,不攔截
//MainPage開啟的item個數大於0,不攔截
if ((deltaX < 0 && Math.abs(deltaY / deltaX) > 1) ||
MainAdapter.mOpenItems.size() > 0) {
return false;
}
}
break;
}
} else if (mState == State.OPEN) {
//完全展開時並且點到主頁面,攔截並關閉選單
if (mMainContent.getLeft() <= mRange && ev.getRawX() > mRange) {
return true;
}
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
mDownX = ev.getRawX();
break;
case MotionEvent.ACTION_MOVE:
//如果是向右滑,不攔截
float deltaX = ev.getRawX() - mDownX;
if (deltaX > 0) {
return false;
}
break;
}
}
return mDragHelper.shouldInterceptTouchEvent(ev);
}
複製程式碼
列表控制元件
列表裡面其實只做了一個處理,就是判斷上下滑動的時候就把事件攔截了:
@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
switch (e.getAction()) {
case MotionEvent.ACTION_DOWN:
mDownX = e.getRawX();
mDownY = e.getRawY();
break;
case MotionEvent.ACTION_MOVE:
//豎向滑動時攔截事件
float deltaX = e.getRawX() - mDownX;
float deltaY = e.getRawY() - mDownY;
if (deltaY != 0.0 &&
Math.abs(deltaX / deltaY) < 1) {
return true;
}
break;
}
return super.onInterceptTouchEvent(e);
}
複製程式碼
刪除控制元件
這裡什麼都不用做,交給 ViewDragHelper 就好了:
public boolean onInterceptTouchEvent(MotionEvent ev) {
return mDragHelper.shouldInterceptTouchEvent(ev);
}
複製程式碼
onTouchEvent
onTouchEvent 方法的返回值決定是否消費事件。
刪除控制元件
刪除控制元件的 onTouchEvent 又有幾個地方要做特殊處理的。當有展開的刪除項時,點選別的刪除項時就將展開的關閉。這樣就可以了:
//存在已展開的控制元件且當前控制元件為關閉狀態,則將所有展開控制元件關閉
if (MainAdapter.mOpenItems.size() > 0 && mState == State.CLOSE) {
return false;
}
複製程式碼
這裡我沒有消費事件,也沒有進行關閉的操作,因為我把關閉的操作交給父控制元件去處理了,否則會有卡頓的現象(QQ 就有這個問題)。
如果點選的是展開的刪除項左邊區域,這個又比較特殊了。因為手指按下之後,有可能是滑動,也可能是點選。滑動的話是滑動刪除項,點選則是將刪除項關閉。所以我們要判斷一下使用者是否有滑動的操作:
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mDownX = event.getRawX();
break;
case MotionEvent.ACTION_MOVE:
float deltaX = event.getRawX() - mDownX;
if (Math.abs(deltaX) > 50) {
isDrag = true;
}
break;
case MotionEvent.ACTION_UP:
if (!isDrag &&
event.getRawX() <= mWidth - mBackWidth) {
close();
return true;
}
isDrag = false;
break;
}
複製程式碼
當滑動距離大於 50 時,我就把它當做是一個滑動操作,這時候把滑動交給 ViewDragHelper 處理,否則就將當前控制元件關閉。
最後還有一個,當我滑動刪除控制元件時,如果手指滑到了別的地方,滑動的依然是當前這個刪除控制元件。換一個說法,其實就是一旦滑動了,父控制元件就不能再攔截我的滑動事件了。其實 ViewGroup 裡面有一個 requestDisallowInterceptTouchEvent 方法,傳 true 的時候,相當於通知它的所有父控制元件不要再攔截了。所以可以這樣來處理:
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_CANCEL:
requestDisallowInterceptTouchEvent(false);
break;
case MotionEvent.ACTION_UP:
requestDisallowInterceptTouchEvent(false);
break;
}
複製程式碼
完整程式碼如下:
public boolean onTouchEvent(MotionEvent event) {
//存在已展開的控制元件且當前控制元件為關閉狀態,則將所有展開控制元件關閉
if (MainAdapter.mOpenItems.size() > 0 && mState == State.CLOSE) {
MainAdapter.closeAll();
return true;
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mDownX = event.getRawX();
break;
case MotionEvent.ACTION_MOVE:
requestDisallowInterceptTouchEvent(true);
float deltaX = event.getRawX() - mDownX;
if (Math.abs(deltaX) > 50) {
isDrag = true;
}
break;
case MotionEvent.ACTION_CANCEL:
requestDisallowInterceptTouchEvent(false);
break;
case MotionEvent.ACTION_UP:
requestDisallowInterceptTouchEvent(false);
if (!isDrag &&
event.getRawX() <= mWidth - mBackWidth) {
//展開狀態下,點選左側部分將其關閉
close();
return true;
}
isDrag = false;
break;
}
mDragHelper.processTouchEvent(event);
return true;
}
複製程式碼
列表控制元件
當有展開刪除項且點選了別的刪除項的時候,把關閉的操作繼續往父控制元件拋就好了:
public boolean onTouchEvent(MotionEvent e) {
return MainAdapter.mOpenItems.size() == 0 && super.onTouchEvent(e);
}
複製程式碼
選單控制元件
在這裡處理一下上面說的那種情況:
public boolean onTouchEvent(MotionEvent event) {
if (MainAdapter.mOpenItems.size() > 0) {
MainAdapter.closeAll();
return true;
}
mDragHelper.processTouchEvent(event);
return true;
}
複製程式碼
效果
扯了這麼多,看下效果吧:
搞半天其實也就這樣而已。
小結
這篇有點囉嗦啊,裡面涉及到的細節比較多。最後可能還會存在一些問題,這裡主要是提供利用事件分發機制,處理手勢衝突的思路。
寫這個的時候發現 QQ 也有一些小問題,比如 QQ 在刪除控制元件展開的情況下,按住刪除控制元件左邊區域下滑後,再左右滑,會出現列表跳動的問題。
大家可以點下面去看原始碼。就到這吧,妥妥的。