1. 程式人生 > >滑動衝突研究之ScrollView+ListView

滑動衝突研究之ScrollView+ListView

有一定經驗的Android開發者應該都遇到過類似的需求,看圖
scrollView+ListView

簡單來說就是外層一個ScrollView,在內部又需要一個ListView來展示資料,在滑動時ListView上部的控制元件需要先收起來。
現在為了達到這種效果,主流是兩種做法:
1.編寫一個UnscrollListView,即不可滑動的ListView,然後將其巢狀在ScrollView中,這樣就避免了滑動衝突的問題了。但是這樣ListView的快取優勢就沒了,需要將Adapter中的item全部繪製出來,對記憶體的影響是不可忽視的。
2.採用CoordinatorLayout相關的佈局和控制元件,這個是Google推出的Design包裡的東西,確實很好用,功能非常強大,而且對傳統的控制元件幾乎是一種顛覆,具體就不詳細說了,但是也有一些限制,比如ListView必須要實現NestedScrollingChild介面,當然可以直接採用RecyclerView來代替。不過如果是在舊頁面中修改的話,這工作量是挺大的,而且還需要額外引入各種Support包,導致apk體積變大。

如果是新介面或者新應用,建議直接採用CoordinatorLayout,以後就再也不怕UI給你提樣式需求了。

所以,博主決定以身犯險,來探一探究竟能不能把方案1給優化一下。。。

1.初定方案

首先,我們的需求是在方案1的基礎上做一些優化,最好是不需要改動ListView,將衝突在ScrollView中解決掉。因為ListView我們用得很多,也經常需要對它進行一些改動,ScrollView相對來說會穩定很多,基本就是充當一個容器的作用。
所以,我們決定修改ScrollView來解決衝突的問題。

在這裡先簡單說下滑動衝突的解決方案,一般來說可以分為外部解決內部解決兩種方式。
外部解決

,就是說在Parent中處理衝突,即重寫父View的onInterceptTouchEvent方法,該方法就是判斷父View是否需要攔截Event傳遞,我們的任務就是編寫程式碼來設定什麼時候要攔截,什麼時候不攔截。
內部解決,是在child中去處理衝突,這個一般會在子View的onTouchEvent方法中去處理,因為ViewGroup有一個叫做requestDisallowInterceptTouchEvent的方法,可以設定父View是否攔截事件。

那結合我們上面的分析,我們就選擇外部解決方案了。

2.處理滑動衝突

首先,需要新建一個ModifierScrollView,繼承自ScrollView,然後重寫onInterceptTouchEvent方法。
程式碼如下

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        super.onInterceptTouchEvent(ev);//ScrollView在該方法中進行了一些賦值操作
        boolean isIntercept = false;
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                y = ev.getRawY();
                isIntercept = false;
                break;

            case MotionEvent.ACTION_MOVE:
                float deltaY = y - ev.getRawY();
                if (Math.abs(deltaY) > touchSlop && listener != null){
                    if(listener.canScroll(deltaY)){ //ScrollView是否需要攔截
                        isIntercept = true;
                    }else{
                        isIntercept = false;
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                isIntercept = false;

        }
        return isIntercept;
    }

大家看這個程式碼很簡單,其實這是外部解決滑動衝突的一個公式,在這裡感謝一下《Android開發藝術探索》,此書做了很完整的總結。
isIntercept為false就不攔截,若為ture則攔截事件。
之所以要在ACTION_DOWN中將isIntercept設為false,是因為如果ACTION_DOWN中攔截了事件,那麼後續的事件都不會再去呼叫onInterceptTouchEvent方法判斷,而是預設將事件攔截掉。具體後續會說到。

大家注意這句listener.canScroll(deltaY),在這裡定義了一個介面,讓外部去判斷是否讓ScrollView滑動,這樣有更好的相容性。
具體canScroll()方法的程式碼如下:

@Override
            public boolean canScroll(float deltaY) {
                if (deltaY > 0){ //上滑
                    if (scrollView.isHeaderShow()){
                        return true;
                    }else{
                        return false;
                    }
                }else{ //下滑
                    if (listView.getFirstVisiblePosition() == 0){
                        View child = listView.getChildAt(0);
                        if (child.getTop() == 0){
                            return true;
                        }
                    }
                    return false;
                }
            }

這個方法是在具體的Activity中重寫的,這裡是對ListView的滑動衝突處理。
從程式碼可以看出,分為上滑和下滑兩種情況:

上滑:
如果ScrollView的headerView還顯示在螢幕上,則scrollView攔截事件
否則,不攔截事件

下滑:
如果ListView的第一條Item的top是0,則scrollView攔截事件
否則不攔截。

到這裡,滑動衝突似乎已經解決掉了?
還有一點很重要,就是在ScrollView中的ListView必須通過程式碼來設定具體的高度,否則ScrollView是無法滾動的。這個應該好理解。

3.驗證分析

將專案編譯執行,發現結果並不是我想要的。
雖然ListView和ScrollView都可以正常滑動,但是博主想要的是連續的滑動,比如說一開始ListView想下滑動,當ListView滑動到頂部時,手指繼續下滑則ScrollView繼續下滑。
而按照上述的程式碼,ListView滑動到頂部時,必須擡起手指再次滑動,ScrollView才會下滑。
其實,需要解決的核心問題是,在ACTION_MOVE過程中,切換事件攔截者。

順著上面的canScroll方法捋一捋思路,會發現這個需求已經包含在程式碼邏輯中,但是實際效果卻是必須擡起手指才能切換事件攔截者,這是為什麼呢?

於是,樓主去看了下ScrollView究竟是如何呼叫onInterceptTouchEvent方法的,呼叫時發生在ViewGroup的dispatchTouchEvent方法中,核心程式碼片段如下:

    final boolean intercepted;
    if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
        final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
        if (!disallowIntercept) {
        //此處呼叫攔截方法,前面有3個條件需要滿足
              intercepted = onInterceptTouchEvent(ev);
              ev.setAction(action); // restore action in case it was changed
        } else {
              intercepted = false;
        }
     } else {
        intercepted = true;
     }

可以看到要想呼叫onInterceptTouchEvent()需要滿足至少兩個條件
1.ACTION_DOWN 或者 mFirstTouchTarget != null
2.disallowIntercept為false
下面分別對它們進行說明,
ACTION_DOWN就不必多說了,就是手指按下的動作。
mFirstTouchTarget是什麼呢?
從原始碼中可以發現,當某一個事件被子View消費掉那麼mFirstTouchTarget就會被賦值,並且如果ViewGroup攔截了事件則會將mFirstTouchTarget設定為null。
至於disallowIntercept,就是requestDisallowInterceptTouchEvent()方法設定的。

好了,接下來可以開始分析問題了,我們還是分兩種情況來分析:

a.從ListView滑動切換到ScrollView滑動
此時的手指狀態是ACTION_MOVE所以ACTION_DOWN的條件是無法滿足的,那麼mFIrstTouchTarget呢?ListView是ScrollView的子View,並且ListView處理滑動即消費了Touch事件,所以mFirstTouchTarget不為null,條件滿足。
之所以沒有順利切換,是因為如果ViewGroup不攔截事件,會將disallowIntercept(其實是一個FLAG)設定為true。此時我們只需更改這個Flag即可達到目的。
不過,還是會有一個bug,就是ScrollView的滑動起始點是在ACTION_DOWN時記錄的,在ACTION_MOVE時切換到ScrollView滑動,那麼ScrollView會出現跳變。暫且放一邊。

b.從ScrollView滑動切換到ListView滑動
首先,ACTION_DOWN條件是無法滿足的。
然後,因為ScrollView將事件攔截了,子View無法消費事件,所以mFirstTouchEvent==null。
這… 就難辦了,難道人為的給它設定一個物件?這種做法顯然太不優雅了。
其實分析到這裡,博主已經確認不可能在專案中使用這種方案了,寧願花點力氣去引入Design包中的控制元件。

4.進一步方案

由於從上面的a情況中知道,由於ACTION_DOWN記錄了ScrollView的起始點,在ACTION_MOVE狀態切換回ScrollView滑動,這個滑動距離是ACTION_MOVE的y值 - ACTION_DOWN的y值,所以ScrollView會出現跳變。
而在b情況中,mFirstTouchTarget為null,導致無法進入onInterceptTouchEvent方法重新判斷。
綜合上面兩點,我們提出一個大膽的思路:模擬ACTION_DOWN事件
即當ACTION_MOVE到達某種條件時,強行將ACTION_MOVE更改為ACTION_DOWN。
這樣一來,onInterceptTouchEvent方法就可以進入了,就能正確的分配攔截的權力了。
當然,這樣導致的後果,博主還未深究,暫時沒有不良反應。不過,博主強烈不建議大家這樣做。

具體做法就是,在自定義的ScrollView中,重寫dispatchTouchEvent方法,然後對ACTION_MOVE進行處理,在需要切換事件攔截者時,將ACTION_MOVE更改為ACTION_DOWN,同時修改disallowIntercept標誌。程式碼如下:

@Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_MOVE){
            if (isDangerPoint && scrollDirectState > 0 ){
                isDangerPoint = false;
                ev.setAction(MotionEvent.ACTION_DOWN);
                requestDisallowInterceptTouchEvent(false);
            }else if (isChildScrollTop() && !isChildScrollTop){
                isChildScrollTop = true;
                ev.setAction(MotionEvent.ACTION_DOWN);
                requestDisallowInterceptTouchEvent(false);
            }
        }
        return super.dispatchTouchEvent(ev);
    }