SwipeRefreshLayout源碼分析

SwipeRefreshLayout已經(jīng)推出許久了,很多App都在使用,這里對其實現(xiàn)方式做個分析。下拉刷新控件其實是很好的學(xué)習(xí)Android的Touch事件傳遞的用例,尤其是其中onInterceptTouchEvent()onTouchEvent()方法的實現(xiàn),對于自定義ViewGroup的事件處理部分有借鑒意義。

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

原文地址

總覽

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

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

這就是最核心的下拉刷新的邏輯,下面看一下SwipeRefreshLayout是怎么實現(xiàn)的,又有什么值得學(xué)習(xí)的地方。

Support包版本為25.1.0

onInterceptTouchEvent(MotionEvent ev)

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

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

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

  2. 排除5種不應(yīng)該刷新的狀態(tài)。(不可用、正在復(fù)位、子View還可以下拉、正在刷新、處于NestedScroll狀態(tài))

    如果當(dāng)前正在復(fù)位,并且收到了DOWN事件,則忽略復(fù)位狀態(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事件時忽略復(fù)位狀態(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;
                }
                // 當(dāng)前事件的Y值。
                final float y = ev.getY(pointerIndex);
                // 雖然方法名字叫做startDragging,但其實里面進行了判斷,是否應(yīng)該攔截事件。(方法名不妥)
                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);
        }
    }

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

TODO: Make this decision more intelligent.

onTouchEvent(MotionEvent ev)

這個方法的核心邏輯就是調(diào)用moveSpinner方法和finishSpinner方法。這兩個方法中分別對應(yīng)【手指移動時拖拽CircleView移動并且更新CircleView上面箭頭的樣式】以及【Touch事件結(jié)束時判斷復(fù)位或者進入刷新狀態(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;
                    // 復(fù)位CircleView或者移動到刷新狀態(tài)的位置(getTop() == 64dp)
                    finishSpinner(overscrollTop);
                }
                mActivePointerId = INVALID_POINTER;
                return false;
            }
            case MotionEvent.ACTION_CANCEL:
                return false;
        }

        return true;
    }

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

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

    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);這一行,回調(diào)的邏輯在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) {
                    // 通知回調(diào)
                    if (mListener != null) {
                        mListener.onRefresh();
                    }
                }
                mCurrentTargetOffsetTop = mCircleView.getTop();
            } else {
                reset();
            }
        }
    };

當(dāng)執(zhí)行mListener.onRefresh()方法時,就是執(zhí)行我們熟悉的回調(diào)方法了。

刷新結(jié)束之后,調(diào)用setRefreshing(false);方法時,也會執(zhí)行到上面兩個參數(shù)的setRefreshing(false, false)方法,執(zhí)行縮小動畫。

canChildScrollUp

下拉刷新邏輯中的一個關(guān)鍵判斷就是判斷子View是否已經(jīng)滑動到最頂端,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;
        }
    }

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

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

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

上圖中,mScrollY的值實際上內(nèi)容坐標系中View顯示區(qū)域的偏移量。圖中的mScrollY的符號位正。也就是我們通常所說的“上拉”對應(yīng)mScrollY的值為正值,反之負值就對應(yīng)“下拉”了,也就是上文提到的UP。

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

總之,這里確實有點繞。

關(guān)于Draw

SwipeRefreshLayout中,還有幾個和繪制相關(guān)的點,值得關(guān)注一下。

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

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

ViewGroup在繪制子View時,如果之前調(diào)用了setChildrenDrawingOrderEnabled()設(shè)置為true,會調(diào)用getChildDrawingOrder()重新確定每個子View的繪制順序,也就可以實現(xiàn)將某個View的順序放置到頂層了。SwipeRefreshLayout的實現(xiàn)如下:

    @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;
        }
    }

解釋一下,第一個參數(shù)很好理解,第二個參數(shù)是迭代位置,返回值是子View的index,這個方法的作用可以理解為:第i次應(yīng)該繪制哪個子View,默認實現(xiàn)是return i;。也就是按照子View的順序繪制。針對上面的實現(xiàn),假設(shè)mCircleViewIndex的值為2,childCount的值為6,那么會得到如下結(jié)果。

是不是很有趣?

Measure 和 Layout:Measure的過程中,對于mTarget,忽略LayoutParams參數(shù),直接設(shè)置為填滿父控件的值。Layout過程中,只對mCircleView和mTarget兩個View進行布局。這些都是常用的行之有效的處理方法。

總結(jié)

以上就是對SwipeRefreshLayout的分析,當(dāng)然開頭提到了,只是Touch事件的邏輯分支,NestedScroll相關(guān)的內(nèi)容,就留到下次啦。

原文地址

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容