天天看點

滑動沖突研究之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 > ){ //上滑
                    if (scrollView.isHeaderShow()){
                        return true;
                    }else{
                        return false;
                    }
                }else{ //下滑
                    if (listView.getFirstVisiblePosition() == ){
                        View child = listView.getChildAt();
                        if (child.getTop() == ){
                            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) != ;
        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 >  ){
                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);
    }