像QQ一樣處理滑動衝突

在項目中,如果要用到滑動控件嵌套滑動控件,總會讓人很心塞。因爲很可能會出現衝突的問題。這裏舉個例子,利用事件分發機制,處理側滑菜單控件和列表中的側滑刪除控件間的衝突。

分析

提到側滑刪除,一個經典的例子就是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在刪除控件展開的情況下,按住刪除控件左邊區域下滑後,再左右滑,會出現列表跳動的問題。

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

源碼地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章