像 QQ 一樣處理滑動衝突

NanBox發表於2017-12-13

在專案中,如果要用到滑動控制元件巢狀滑動控制元件,總會讓人很心塞。因為很可能會出現衝突的問題。這裡舉個例子,利用事件分發機制,處理側滑選單控制元件和列表中的側滑刪除控制元件間的衝突。

分析

提到側滑刪除,一個經典的例子就是 QQ 了。QQ 的首頁是一個大的側滑選單控制元件,巢狀一個列表,列表裡面再巢狀側滑刪除的控制元件。我們就仿照這個樣式,看看能不能做一個和它類似的效果。

這裡關注的重點是在滑動手勢的處理上,簡單分析一下需要做什麼處理:

(下面把側滑選單控制元件稱作選單控制元件,列表側滑刪除控制元件稱作刪除控制元件。)

  1. 在首頁上下滑動時,滾動列表。

  2. 選單控制元件關閉的情況下,如果列表裡面沒有展開的刪除項,則手指向右滑動是滑動選單控制元件,向左滑動是滑動刪除控制元件。

  3. 如果列表裡面有展開的刪除控制元件,則選單控制元件和列表項都不可滑動。除了刪除按鍵,點選其他區域,都是將展開項關閉。

  4. 當手指滑動刪除控制元件時,手指滑動到螢幕的任意區域都可以滑動展開項。

  5. 選單控制元件開啟的情況下,點選右邊主頁區域,將選單控制元件關閉。

有點複雜的感覺啊,我們一個個來解決。

我自定義了上面說到的三個控制元件,根據巢狀關係,從大到小分別是:

  • 選單控制元件 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 也有一些小問題,比如 QQ 在刪除控制元件展開的情況下,按住刪除控制元件左邊區域下滑後,再左右滑,會出現列表跳動的問題。

大家可以點下面去看原始碼。就到這吧,妥妥的。

原始碼地址

相關文章