SwipeRefreshLayout源碼分析

SwipeRefreshLayout已經推出許久了,很多App都在使用玄柏,這里對其實現方式做個分析兼呵。下拉刷新控件其實是很好的學習Android的Touch事件傳遞的用例,尤其是其中onInterceptTouchEvent()onTouchEvent()方法的實現驮瞧,對于自定義ViewGroup的事件處理部分有借鑒意義氓扛。

這篇文章分析傳統(tǒng)的基于Touch事件傳遞流程的下拉刷新邏輯。(還有一個邏輯分支是NestedScroll论笔,先留個坑采郎。)

原文地址

總覽

下拉刷新的實現思路并不難,如果了解過Touch事件傳遞的流程狂魔,就不難想到:

  1. 自定義ViewGroup包裹在需要刷新的內容View外層蒜埋。
  2. onInterceptTouchEvent()方法中判斷是否應當觸發(fā)下拉刷新,一般判斷條件都是內容View已經滾動到頂部最楷。
  3. 攔截事件并交給自身的onTouchEvent()方法處理整份。
  4. onTouchEvent()方法中處理Touch事件,包括根據刷新的狀態(tài)更新UI籽孙,觸發(fā)刷新監(jiān)聽器等烈评。

這就是最核心的下拉刷新的邏輯,下面看一下SwipeRefreshLayout是怎么實現的犯建,又有什么值得學習的地方讲冠。

Support包版本為25.1.0

onInterceptTouchEvent(MotionEvent ev)

onInterceptTouchEvent()可以看做是下拉刷新流程的其實位置,Touch事件傳遞到SwipeRefreshLayout中胎挎,會先執(zhí)行onInterceptTouchEvent()方法沟启,通過其返回值決定繼續(xù)向下傳遞還是讓SwipeRefreshLayout作為后續(xù)事件的消費者。

這個方法中包含如下邏輯:

  1. 如果還沒有確定需要刷新的View犹菇,找到刷新的View德迹。

  2. 排除5種不應該刷新的狀態(tài)。(不可用揭芍、正在復位胳搞、子View還可以下拉、正在刷新称杨、處于NestedScroll狀態(tài))

    如果當前正在復位肌毅,并且收到了DOWN事件,則忽略復位狀態(tài)姑原。

  3. 如果是DOWN事件悬而,記錄初始位置和事件的pointerId(手指)。

  4. 如果是MOVE事件锭汛,如果滑動距離超過閾值笨奠,標記進入下拉刷新狀態(tài)袭蝗,將使這個方法返回true,后續(xù)事件由onTouchEvent()處理般婆。

  5. 如果是POINTER_UP事件(非主要手指抬起)到腥,重新記錄pointerId。

  6. 如果是UP事件蔚袍,退出刷新狀態(tài)乡范,清除pointerId記錄。

源碼

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        // 確定刷新的View啤咽,這個View會賦值給mTarget屬性晋辆,后續(xù)判斷是否可以下拉會使用到。
        ensureTarget();

        final int action = MotionEventCompat.getActionMasked(ev);
        int pointerIndex;

        // DOWN事件時忽略復位狀態(tài)
        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
            mReturningToStart = false;
        }

        // 5個條件滿足一個宇整,就不處理事件栈拖,讓事件向下傳遞。
        if (!isEnabled() || mReturningToStart || canChildScrollUp()
                || mRefreshing || mNestedScrollInProgress) {
            // Fail fast if we're not in a state where a swipe is possible
            return false;
        }

        switch (action) {
            case MotionEvent.ACTION_DOWN:
                // 移動CircleView到初始值(方法名用了Target這個單詞没陡,我認為不妥。)
                setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true);
                mActivePointerId = ev.getPointerId(0);
                mIsBeingDragged = false;

                pointerIndex = ev.findPointerIndex(mActivePointerId);
                if (pointerIndex < 0) {
                    return false;
                }
                // 記錄初始按下位置
                mInitialDownY = ev.getY(pointerIndex);
                break;

            case MotionEvent.ACTION_MOVE:
                if (mActivePointerId == INVALID_POINTER) {
                    Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
                    return false;
                }

                pointerIndex = ev.findPointerIndex(mActivePointerId);
                if (pointerIndex < 0) {
                    return false;
                }
                // 當前事件的Y值索赏。
                final float y = ev.getY(pointerIndex);
                // 雖然方法名字叫做startDragging盼玄,但其實里面進行了判斷,是否應該攔截事件潜腻。(方法名不妥)
                startDragging(y);
                break;

            case MotionEventCompat.ACTION_POINTER_UP:
                // 重新標記激活的pointerId
                onSecondaryPointerUp(ev);
                break;

            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                // 停止攔截事件
                mIsBeingDragged = false;
                mActivePointerId = INVALID_POINTER;
                break;
        }

        return mIsBeingDragged;
    }

需要注意onSecondaryPointerUp()方法:

    private void onSecondaryPointerUp(MotionEvent ev) {
        final int pointerIndex = MotionEventCompat.getActionIndex(ev);
        final int pointerId = ev.getPointerId(pointerIndex);
        if (pointerId == mActivePointerId) {
            // This was our active pointer going up. Choose a new
            // active pointer and adjust accordingly.
            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
            mActivePointerId = ev.getPointerId(newPointerIndex);
        }
    }

這個方法的實現只支持最多兩個手指的切換埃儿,如果有第三個觸摸點,就會出現bug融涣。相似的邏輯在NestedScrollView中也出現了童番,并且其代碼里面包含TODO:

TODO: Make this decision more intelligent.

onTouchEvent(MotionEvent ev)

這個方法的核心邏輯就是調用moveSpinner方法和finishSpinner方法。這兩個方法中分別對應【手指移動時拖拽CircleView移動并且更新CircleView上面箭頭的樣式】以及【Touch事件結束時判斷復位或者進入刷新狀態(tài)】威鹿。

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        // 省略了一些代碼……
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                // 省略了一些代碼……

            case MotionEvent.ACTION_MOVE: {
                // 省略了一些代碼……
                
                if (mIsBeingDragged) {
                    // mInitialMotionY等于(DOWN事件的坐標 + mTouchSlop)剃斧,DRAG_RATE等于0.5f
                    final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
                    // 是否需要移動CircleView
                    if (overscrollTop > 0) {
                        // 移動CircleView
                        moveSpinner(overscrollTop);
                    } else {
                        return false;
                    }
                }
                break;
            }
            case MotionEventCompat.ACTION_POINTER_DOWN: {
                // 有新手指按下,標記新手指忽你。
                pointerIndex = MotionEventCompat.getActionIndex(ev);
                if (pointerIndex < 0) {
                    Log.e(LOG_TAG,
                            "Got ACTION_POINTER_DOWN event but have an invalid action index.");
                    return false;
                }
                mActivePointerId = ev.getPointerId(pointerIndex);
                break;
            }

            case MotionEventCompat.ACTION_POINTER_UP:
                onSecondaryPointerUp(ev);
                break;

            case MotionEvent.ACTION_UP: {
                // 省略了一些代碼……

                if (mIsBeingDragged) {
                    final float y = ev.getY(pointerIndex);
                    final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
                    mIsBeingDragged = false;
                    // 復位CircleView或者移動到刷新狀態(tài)的位置(getTop() == 64dp)
                    finishSpinner(overscrollTop);
                }
                mActivePointerId = INVALID_POINTER;
                return false;
            }
            case MotionEvent.ACTION_CANCEL:
                return false;
        }

        return true;
    }

moveSpinner()方法實現了CircleView位置的計算以及箭頭屬性的計算幼东,可以跳過。

finishSpinner()方法判斷滑動距離是否超過了閾值科雳,超過的話調用setRefresh(boolean, boolean)方法觸發(fā)刷新回調:

    private void finishSpinner(float overscrollTop) {
        if (overscrollTop > mTotalDragDistance) {
            // 觸發(fā)刷新
            setRefreshing(true, true /* notify */);
        } else {
            // cancel refresh
            mRefreshing = false;
            // 省略一些代碼……
        }
    }

setRefresh()方法:

    private void setRefreshing(boolean refreshing, final boolean notify) {
        if (mRefreshing != refreshing) {
            mNotify = notify;
            ensureTarget();
            mRefreshing = refreshing;
            if (mRefreshing) {
                // 移動到刷新位置根蟹。
                animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener);
            } else {
                // 停止刷新時的處理,執(zhí)行CircleView的縮小動畫糟秘。
                startScaleDownAnimation(mRefreshListener);
            }
        }
    }

注意animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener);這一行简逮,回調的邏輯在mRefreshLinstener里面:

    private Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() {
        // 省略一些代碼
        @SuppressLint("NewApi")
        @Override
        public void onAnimationEnd(Animation animation) {
            if (mRefreshing) {
                // Make sure the progress view is fully visible
                mProgress.setAlpha(MAX_ALPHA);
                mProgress.start();
                if (mNotify) {
                    // 通知回調
                    if (mListener != null) {
                        mListener.onRefresh();
                    }
                }
                mCurrentTargetOffsetTop = mCircleView.getTop();
            } else {
                reset();
            }
        }
    };

當執(zhí)行mListener.onRefresh()方法時,就是執(zhí)行我們熟悉的回調方法了尿赚。

刷新結束之后散庶,調用setRefreshing(false);方法時蕉堰,也會執(zhí)行到上面兩個參數的setRefreshing(false, false)方法,執(zhí)行縮小動畫督赤。

canChildScrollUp

下拉刷新邏輯中的一個關鍵判斷就是判斷子View是否已經滑動到最頂端嘁灯,SwipeRefreshLayout使用canChildScrollUp()方法進行這個判斷:

    public boolean canChildScrollUp() {
        if (mChildScrollUpCallback != null) {
            return mChildScrollUpCallback.canChildScrollUp(this, mTarget);
        }
        if (android.os.Build.VERSION.SDK_INT < 14) {
            if (mTarget instanceof AbsListView) {
                final AbsListView absListView = (AbsListView) mTarget;
                return absListView.getChildCount() > 0
                        && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
                                .getTop() < absListView.getPaddingTop());
            } else {
                return ViewCompat.canScrollVertically(mTarget, -1) || mTarget.getScrollY() > 0;
            }
        } else {
            return ViewCompat.canScrollVertically(mTarget, -1);
        }
    }

除了SDK版本14以下對于ListView的特殊處理,都使用ViewCompat.canScrollVertically(mTarget, -1);這個方法進行判斷躲舌。最終會執(zhí)行下面的判斷邏輯:

    private boolean canScrollingViewScrollVertically(ScrollingView view, int direction) {
        final int offset = view.computeVerticalScrollOffset();
        final int range = view.computeVerticalScrollRange() -
                view.computeVerticalScrollExtent();
        if (range == 0) return false;
        if (direction < 0) {
            return offset > 0;
        } else {
            return offset < range - 1;
        }
    }

其中的關鍵數值offset最終還是會從View的mScrollY屬性獲取丑婿,和getScrollY()獲取到的是同一個值。

這里需要注意的問題是direction的認定没卸。ViewCompat.canScrollVertically(mTarget, -1);這個方法的參數-1以及canChildScrollUp()的方法名羹奉,都包含了UP這個方向,但是我們判斷是否到頂了不應該是判斷【是否能向下滾動】嗎约计,為什么是相反的呢诀拭?

原因要從mScrollY這個參數上找,mScrollY的含義其實是View相對于內容的偏移量:

上圖中煤蚌,mScrollY的值實際上內容坐標系中View顯示區(qū)域的偏移量耕挨。圖中的mScrollY的符號位正。也就是我們通常所說的“上拉”對應mScrollY的值為正值尉桩,反之負值就對應“下拉”了筒占,也就是上文提到的UP

-1還可以理解為使mScrollY減小的方向蜘犁,自然也就是“下拉”了翰苫。

總之,這里確實有點繞这橙。

關于Draw

SwipeRefreshLayout中奏窑,還有幾個和繪制相關的點,值得關注一下屈扎。

setWillNotDraw(boolean):這個方法關聯(lián)到ViewGroup的一個flag埃唯,默認情況下為true,也就是自身不需要進行繪制鹰晨,底層會根據這個flag進行優(yōu)化筑凫。需要繪制的話,需要將flag置為true并村。

ViewCompat.setChildrenDrawingOrderEnabled(ViewGroup viewGroup, boolean enable):通常我們自定義ViewGroup時需要將某個View在頂層繪制巍实,都是調用View.bringToFront();方法將其移動到最頂層,但是這個方法有一個副作用哩牍,后面會提到棚潦。而ViewCompat的這個方法提供了另一種解決方案。

ViewGroup在繪制子View時膝昆,如果之前調用了setChildrenDrawingOrderEnabled()設置為true丸边,會調用getChildDrawingOrder()重新確定每個子View的繪制順序叠必,也就可以實現將某個View的順序放置到頂層了。SwipeRefreshLayout的實現如下:

    @Override
    protected int getChildDrawingOrder(int childCount, int i) {
        if (mCircleViewIndex < 0) {
            return i;
        } else if (i == childCount - 1) {
            // Draw the selected child last
            return mCircleViewIndex;
        } else if (i >= mCircleViewIndex) {
            // Move the children after the selected child earlier one
            return i + 1;
        } else {
            // Keep the children before the selected child the same
            return i;
        }
    }

解釋一下妹窖,第一個參數很好理解纬朝,第二個參數是迭代位置,返回值是子View的index骄呼,這個方法的作用可以理解為:第i次應該繪制哪個子View共苛,默認實現是return i;。也就是按照子View的順序繪制蜓萄。針對上面的實現隅茎,假設mCircleViewIndex的值為2,childCount的值為6嫉沽,那么會得到如下結果辟犀。

是不是很有趣?

Measure 和 Layout:Measure的過程中绸硕,對于mTarget堂竟,忽略LayoutParams參數,直接設置為填滿父控件的值玻佩。Layout過程中跃捣,只對mCircleView和mTarget兩個View進行布局。這些都是常用的行之有效的處理方法夺蛇。

總結

以上就是對SwipeRefreshLayout的分析,當然開頭提到了酣胀,只是Touch事件的邏輯分支刁赦,NestedScroll相關的內容,就留到下次啦闻镶。

原文地址

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末甚脉,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子铆农,更是在濱河造成了極大的恐慌牺氨,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,366評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件墩剖,死亡現場離奇詭異猴凹,居然都是意外死亡肉津,警方通過查閱死者的電腦和手機樟氢,發(fā)現死者居然都...
    沈念sama閱讀 93,521評論 3 395
  • 文/潘曉璐 我一進店門盼产,熙熙樓的掌柜王于貴愁眉苦臉地迎上來炕横,“玉大人恩脂,你說我怎么就攤上這事∷鹛担” “怎么了葡盗?”我有些...
    開封第一講書人閱讀 165,689評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長购对。 經常有香客問我猾昆,道長,這世上最難降的妖魔是什么骡苞? 我笑而不...
    開封第一講書人閱讀 58,925評論 1 295
  • 正文 為了忘掉前任垂蜗,我火速辦了婚禮,結果婚禮上烙如,老公的妹妹穿的比我還像新娘么抗。我一直安慰自己,他們只是感情好亚铁,可當我...
    茶點故事閱讀 67,942評論 6 392
  • 文/花漫 我一把揭開白布蝇刀。 她就那樣靜靜地躺著,像睡著了一般徘溢。 火紅的嫁衣襯著肌膚如雪吞琐。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,727評論 1 305
  • 那天然爆,我揣著相機與錄音站粟,去河邊找鬼。 笑死曾雕,一個胖子當著我的面吹牛奴烙,可吹牛的內容都是我干的。 我是一名探鬼主播剖张,決...
    沈念sama閱讀 40,447評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼切诀,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了搔弄?” 一聲冷哼從身側響起幅虑,我...
    開封第一講書人閱讀 39,349評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎顾犹,沒想到半個月后倒庵,有當地人在樹林里發(fā)現了一具尸體,經...
    沈念sama閱讀 45,820評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡炫刷,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,990評論 3 337
  • 正文 我和宋清朗相戀三年擎宝,在試婚紗的時候發(fā)現自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片浑玛。...
    茶點故事閱讀 40,127評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡认臊,死狀恐怖,靈堂內的尸體忽然破棺而出锄奢,到底是詐尸還是另有隱情失晴,我是刑警寧澤剧腻,帶...
    沈念sama閱讀 35,812評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站涂屁,受9級特大地震影響书在,放射性物質發(fā)生泄漏。R本人自食惡果不足惜拆又,卻給世界環(huán)境...
    茶點故事閱讀 41,471評論 3 331
  • 文/蒙蒙 一儒旬、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧帖族,春花似錦栈源、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,017評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至涣雕,卻和暖如春艰亮,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背挣郭。 一陣腳步聲響...
    開封第一講書人閱讀 33,142評論 1 272
  • 我被黑心中介騙來泰國打工迄埃, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人兑障。 一個月前我還...
    沈念sama閱讀 48,388評論 3 373
  • 正文 我出身青樓侄非,卻偏偏與公主長得像,于是被迫代替她去往敵國和親流译。 傳聞我的和親對象是個殘疾皇子逞怨,可洞房花燭夜當晚...
    茶點故事閱讀 45,066評論 2 355

推薦閱讀更多精彩內容