Android適用不同子視圖的刷新和加載控件

簡述

其實想法很簡單,為了能夠在絕大部分場景使用一個通用的控件來進(jìn)行上拉加載和下拉刷新,避免因為Listview切換RecyclerView之類的情況導(dǎo)致需要重新寫一套代碼。

效果圖

先看一下基礎(chǔ)的效果,demo做得比較簡陋


效果圖.gif

思路

1.自定義一個ViewGroup
2.ViewGroup里面應(yīng)該有三個控件,一個是下拉刷新的時候要顯示的視圖,一個是內(nèi)容控件,一個是上拉加載的時候要顯示的視圖
3.然后按照一定的方式進(jìn)行視圖的擺放:

視圖擺放.png

4.那么在豎直滑動的時候,需要處理的就是記錄當(dāng)前的偏移量,只要偏移量達(dá)到一定的程度,觸發(fā)一些對應(yīng)的回調(diào)即可

從xml中加載

目前使用的時候希望做的是在xml中使用,因為如果addView的話不符合設(shè)計,在LayoutInflater加載完布局的時候,會進(jìn)行onFinishInflate回調(diào),所以說在這里進(jìn)行判斷,即可知道xml中設(shè)置的子視圖情況。

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        int childCount = getChildCount();
        switch (childCount) {
            case 1://這種時候默認(rèn)只有一個內(nèi)容視圖
                mContentView = getChildAt(0);
                break;
            case 2://默認(rèn)優(yōu)先支持頂部刷新
                mContentView = getChildAt(0);
                mHeaderView = getChildAt(1);
                break;
            case 3:
                mContentView = getChildAt(0);
                mHeaderView = getChildAt(1);
                mFooterView = getChildAt(2);
                break;
            default:
                throw new IllegalArgumentException("必須包括1到3個子視圖");
        }
        checkHeaderAndFooterAndAddListener();
    }

測量

測量的時候主要考慮margin即可,其它方面正常測量即可

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        measureChildWithMargins(mContentView, widthMeasureSpec, 0, heightMeasureSpec, 0);
        MarginLayoutParams lp = null;
        if (null != mHeaderView) {
            measureChildWithMargins(mHeaderView, widthMeasureSpec, 0, heightMeasureSpec, 0);
            lp = (MarginLayoutParams) mHeaderView.getLayoutParams();
            mHeaderHeight = mHeaderView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
        }
        if (null != mFooterView) {
            measureChildWithMargins(mFooterView, widthMeasureSpec, 0, heightMeasureSpec, 0);
            lp = (MarginLayoutParams) mFooterView.getLayoutParams();
            mFooterHeight = mFooterView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
        }
    }

目前不支持wrap_content的模式,因為正常使用來說基本上可以認(rèn)為高度都是固定的。
ViewGroup默認(rèn)的onMeasure只會計算并且設(shè)置自身的測量寬高,所以說需要額外測量ViewGroup里面的子視圖,而在這里,里面最多就三個子視圖。

布局

布局的初始狀態(tài)如上圖,因為布局本身存在滑動的狀態(tài),那么也就是說偏移量本身也要作為布局的考量:

偏移量說明.png

圖中藍(lán)色和灰色部分重疊的部分其實就是偏移量,這個標(biāo)示當(dāng)前ViewGroup從初始位置移動的偏移量。

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int left, top;
        MarginLayoutParams lp;
        lp = (MarginLayoutParams) mContentView.getLayoutParams();
        left = (getPaddingLeft() + lp.leftMargin);
        if (mOption.isContentFixed()) {
            top = (getPaddingTop() + lp.topMargin);
        }else{
            top = (getPaddingTop() + lp.topMargin) + mCurrentOffset;
        }
        mContentView.layout(left, top, left + mContentView.getMeasuredWidth(), top + mContentView.getMeasuredHeight());
        if (null != mHeaderView) {
            lp = (MarginLayoutParams) mHeaderView.getLayoutParams();
            left = (getPaddingLeft() + lp.leftMargin);
            top = (getPaddingTop() + lp.topMargin) - mHeaderHeight + mCurrentOffset;
            mHeaderView.layout(left, top, left + mHeaderView.getMeasuredWidth(), top + mHeaderView.getMeasuredHeight());
        }
        if (null != mFooterView) {
            lp = (MarginLayoutParams) mFooterView.getLayoutParams();
            left = (getPaddingLeft() + lp.leftMargin);
            top = (b - t - getPaddingBottom() + lp.topMargin) + mCurrentOffset;
            mFooterView.layout(left, top, left + mFooterView.getMeasuredWidth(), top + mFooterView.getMeasuredHeight());
        }
    }

其實從一些場景上面考慮,比方說SwipeRefreshLayout這種視圖內(nèi)容不變,有刷新組件進(jìn)入的情況也是非常常見的,所以說一共支持兩種布局方式。
1.布局固定模式,這種模式下偏移量不影響內(nèi)容視圖的布局位置即可,偏移量只會影響頭部和底部視圖。
2.布局跟隨滑動模式,這種模式下偏移量會影響所有子視圖的布局。

手勢攔截

先看事件的攔截,因為我認(rèn)為這個布局一般位于頂層,所以我沒有默認(rèn)添加禁止父布局?jǐn)r截事件,需要的可以自行添加。
默認(rèn)的情況下我也沒有支持fling操作,因為我認(rèn)為絕大多數(shù)情況下滑動的主體應(yīng)該是內(nèi)容視圖。
對于這個控件來說,重點只是是處理滑動的手勢,其實攔截事件的邏輯也非常簡單,結(jié)合當(dāng)前視圖能否上拉/下拉和當(dāng)前滑動手勢的方向判斷即可。

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        if (!isEnabled() || !hasHeaderOrFooter() || isRefreshing || isLoading || isNestedScrolling) {
            return false;
        }
        switch (MotionEventCompat.getActionMasked(event)) {
            case MotionEvent.ACTION_MOVE:
                int x = (int) event.getX();
                int y = (int) event.getY();
                int deltaY = (y - mLastPoint.y);
                int dy = Math.abs(deltaY);
                int dx = Math.abs(x - mLastPoint.x);
                Log.d(getClass().getSimpleName(), "dx-->" + dx + "--dy-->" + dy + "--touchSlop-->" + mTouchSlop);
                if (dy > mTouchSlop && dy >= dx) {
                    canUp = mOption.canUpToDown();
                    canDown = mOption.canDownToUp();
                    Log.d(getClass().getSimpleName(), "canUp-->" + canUp + "--canDown-->" + canDown + "--deltaY-->" + deltaY);
                    canUpIntercept = (deltaY > 0 && canUp);
                    canDownIntercept = (deltaY < 0 && canDown);
                    return canUpIntercept || canDownIntercept;
                }
                return false;
        }
        mLastPoint.set((int) event.getX(), (int) event.getY());
        return false;
    }

手勢處理

當(dāng)確定要攔截手勢之后,接著就是處理手勢,從功能上面來說,主要就是滑動的時候移動視圖和松手的時候嘗試觸發(fā)刷新這兩塊。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!isEnabled() || !hasHeaderOrFooter() || isRefreshing || isLoading || isNestedScrolling) {
            return false;
        }
        switch (MotionEventCompat.getActionMasked(event)) {
            case MotionEvent.ACTION_MOVE:
                isOnTouch = true;
                updatePos((int) (event.getY() - mLastPoint.y));
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                isOnTouch = false;
                if (mCurrentOffset > 0) {
                    tryPerformRefresh();
                } else if(mCurrentOffset < 0){
                    tryPerformLoading();
                }
                break;
        }
        mLastPoint.set((int) event.getX(), (int) event.getY());
        return true;
    }

可以看到,主要就是在MOVE的時候進(jìn)行了視圖位置變化的處理,接著看邏輯:

private void updatePos(int deltaY) {
        if (!hasHeaderOrFooter() || deltaY == 0) {//不需要偏移
            return;
        }
        if (isOnTouch) {
            if (!canUp && (mCurrentOffset + deltaY > 0)) {//此時偏移量不應(yīng)該>0
                deltaY = (0 - mCurrentOffset);
            } else if (!canDown && (mCurrentOffset + deltaY < 0)) {//此時偏移量不應(yīng)該<0
                deltaY = (0 - mCurrentOffset);
            }
        }
        mPrevOffset = mCurrentOffset;
        mCurrentOffset += deltaY;
        mCurrentOffset = Math.max(Math.min(mCurrentOffset, mOption.getMaxDownOffset()), mOption.getMaxUpOffset());
        deltaY = mCurrentOffset - mPrevOffset;
        if (deltaY == 0) {//不需要偏移
            return;
        }
        callUIPositionChangedListener(mPrevOffset, mCurrentOffset);
        if (mCurrentOffset >= mOption.getRefreshOffset()) {
            callCanRefreshListener();
        } else if (mCurrentOffset <= mOption.getLoadMoreOffset()) {
            callCanLoadMoreListener();
        }
        if (!mOption.isContentFixed()) {
            mContentView.offsetTopAndBottom(deltaY);
        }
        if (null != mHeaderView) {
            mHeaderView.offsetTopAndBottom(deltaY);
        }
        if (null != mFooterView) {
            mFooterView.offsetTopAndBottom(deltaY);
        }
        invalidate();
    }

其實主要是做幾件事情:
1.確定當(dāng)前視圖最多可以的滑動距離,從而得出實際滑動距離
2.進(jìn)行一系列的回調(diào),這個后面會說
3.通過View的offsetTopAndBottom來進(jìn)行豎直方向的移動,如果是內(nèi)容固定模式,則內(nèi)容視圖不應(yīng)該移動
這樣就可以實現(xiàn)視圖的上下滑動

回調(diào)

實際上這個控件的核心應(yīng)該是在回調(diào)處理當(dāng)中,因為無論是怎么滑動,最重要的就是實現(xiàn)滑動過程中的交互和觸發(fā)刷新之類的回調(diào),這樣可以讓使用的人實現(xiàn)不同的效果。
首先看回調(diào)接口

public interface IRefreshListener {
    void onBeforeRefresh();//當(dāng)前偏移量沒有達(dá)到刷新的標(biāo)準(zhǔn)時松手,然后頭部開始回彈的回調(diào)
    void onRefreshBegin();//開始刷新的回調(diào)
    void onUIPositionChanged(int oldOffset, int newOffset, int refreshOffset);//視圖滑動過程中的回調(diào)
    void onRefreshComplete();//刷新完成的回調(diào)
    void onCanRefresh();//當(dāng)前偏移量已經(jīng)超過刷新的標(biāo)準(zhǔn)的時候,還在滑動的話會觸發(fā)的回調(diào)
}

這里是頂部刷新的回調(diào),底部加載的回調(diào)和這個類似。
使用的時候主要是頂部和底部視圖通過實現(xiàn)這個接口,根據(jù)不同的狀態(tài)實現(xiàn)樣式上面的變化即可。

刷新處理

因為刷新回調(diào)處理和底部其實是類似的,這里只說明刷新
重點看一下頂部刷新回調(diào)觸發(fā)的條件:

    private void tryPerformRefresh() {
        if (isOnTouch || isRefreshing || isNestedScrolling) {//觸摸中或者刷新中不進(jìn)行回調(diào)
            return;
        }
        if (mCurrentOffset >= mOption.getRefreshOffset()) {
            startRefreshing();
        } else {//沒有達(dá)到刷新條件,還原狀態(tài)
            mScroller.trySmoothScrollToOffset(0);
            if(mCurrentOffset > 0) {
                callBeforeRefreshListener();
            }
        }
    }

其實就是當(dāng)前偏移量超過刷新觸發(fā)標(biāo)準(zhǔn)的時候開始刷新處理,然后在里面進(jìn)行回調(diào)
默認(rèn)的情況下頂部刷新和底部加載同時只能回調(diào)一個,并且回彈的時候默認(rèn)是通過Scroller來進(jìn)行緩慢滑動

    private void startRefreshing() {
        isRefreshing = true;
        callRefreshBeginListener();
        mScroller.trySmoothScrollToOffset(mOption.getRefreshOffset());
    }

1.標(biāo)記當(dāng)前刷新中
2.進(jìn)行刷新開始回調(diào)
3.因為當(dāng)前偏移量可能大于刷新觸發(fā)的大小,這里會通過緩慢滑動回到刷新觸發(fā)的位置
在一般的場景中,這里的回調(diào)會進(jìn)行網(wǎng)絡(luò)請求,然后在請求完成后要恢復(fù)原狀,那么這個時候應(yīng)該手動通知視圖

    public void refreshComplete() {
        if (!isRefreshing) {
            return;
        }
        callRefreshCompleteListener();
        postDelayed(new Runnable() {
            @Override
            public void run() {
                if(null != getContext()) {
                    isRefreshing = false;
                    mScroller.trySmoothScrollToOffset(0);
                }
            }
        },mOption.getRefreshCompleteDelayed());
    }

1.進(jìn)行刷新完成回調(diào)
2.根據(jù)設(shè)置的參數(shù)進(jìn)行延遲完成回調(diào),這里可以用于在刷新完成之后顯示1s的刷新完成提示之類的需求
3.最終的處理就是標(biāo)志當(dāng)前未處于刷新中,并且通過緩慢移動將視圖恢復(fù)原樣

緩慢滑動

緩慢滑動的意思就是在一定時間間隔內(nèi)從某一個位置滑動到另一個位置,這樣對于用戶的體驗會好很多。
在Android中一般通過Scroller作為計算器,通過Scroller可以比較方便的進(jìn)行分段和計算,那么需要做的就是在一些特定的時機(jī)里面進(jìn)行處理。
實際上就是把一段時間的移動分割成為非常多的一小段的移動,至于移動多少,這個在Scroller里面有計算。

    private class ScrollerWorker implements Runnable {
        public static final int DEFAULT_SMOOTH_TIME = 400;//ms
        public static final int AUTO_REFRESH_SMOOTH_TIME = 200;//ms,自動刷新和自動加載時布局彈出時間
        private int mSmoothScrollTime;
        private int mLastY;//上次的Y坐標(biāo)偏移量
        private Scroller mScroller;//間隔計算執(zhí)行者
        private Context mContext;//上下文
        private boolean isRunning;//當(dāng)前是否運(yùn)行中

        public ScrollerWorker(Context mContext) {
            this.mContext = mContext;
            mScroller = new Scroller(mContext);
            mSmoothScrollTime = DEFAULT_SMOOTH_TIME;
        }

        public void setSmoothScrollTime(int mSmoothScrollTime) {
            this.mSmoothScrollTime = mSmoothScrollTime;
        }

        @Override
        public void run() {
            boolean isFinished = (!mScroller.computeScrollOffset() || mScroller.isFinished());
            if (isFinished) {
                if(mScroller.getCurrY() != mLastY){//Scroller會在一些情況下突然結(jié)束,這里就是處理這個情況
                    checkScrollerAndRun();
                }
                end();
            } else {
                checkScrollerAndRun();
            }
        }

        private void checkScrollerAndRun(){
            int y = mScroller.getCurrY();
            int deltaY = (y - mLastY);
            boolean isDown = ((mPrevOffset == mOption.getRefreshOffset()) && deltaY > 0);
            boolean isUp = ((mPrevOffset == mOption.getLoadMoreOffset()) && deltaY < 0);
            if (isDown || isUp) {//不需要進(jìn)行多余的滑動
                end();
                return;
            }
            updatePos(deltaY);
            mLastY = y;
            post(this);
        }

        /**
         * 嘗試緩慢滑動到指定偏移量
         *
         * @param targetOffset 需要滑動到的偏移量
         */
        public void trySmoothScrollToOffset(int targetOffset) {
            if (!hasHeaderOrFooter()) {
                return;
            }
            endScroller();
            removeCallbacks(this);
            mLastY = 0;
            int deltaY = (targetOffset - mCurrentOffset);
            mScroller.startScroll(0, 0, 0, deltaY, mSmoothScrollTime);
            isRunning = true;
            post(this);
        }

        /**
         * 結(jié)束Scroller
         */
        private void endScroller() {
            if (!mScroller.isFinished()) {
                mScroller.forceFinished(true);
            }
            mScroller.abortAnimation();
        }

        /**
         * 停止并且還原滑動工作
         */
        public void end() {
            removeCallbacks(this);
            endScroller();
            isRunning = false;
            mLastY = 0;
        }

    }

這里就是通過post不斷的進(jìn)行回調(diào),然后Scroller不斷的進(jìn)行計算,在有效滑動的過程中,通過之前的滑動方法來進(jìn)行一小段距離的滑動,最后產(chǎn)生的效果就是一段時間的緩慢移動。

嵌套滑動

有的時候可能內(nèi)容視圖是RecyclerView、NestedScrollView這些支持嵌套滑動的視圖,那么作為父視圖可能也要接受嵌套滑動會好一點。

    @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        //只接收豎直方向上面的嵌套滑動
        boolean isVerticalScroll = (nestedScrollAxes == ViewCompat.SCROLL_AXIS_VERTICAL);
        boolean canTouchMove = isEnabled() && hasHeaderOrFooter();
        return !disabledNestedScrolling && isVerticalScroll && canTouchMove;
    }

    @Override
    public void onStopNestedScroll(View child) {
        if(disabledNestedScrolling){
            return;
        }
        mParentHelper.onStopNestedScroll(child);
        if (isNestedScrolling) {
            isNestedScrolling = false;
            isOnTouch = false;
            if (mCurrentOffset >= mOption.getRefreshOffset()) {
                startRefreshing();
            } else if(mCurrentOffset <= mOption.getLoadMoreOffset()){
                startLoading();
            } else {//沒有達(dá)到刷新條件,還原狀態(tài)
                mScroller.trySmoothScrollToOffset(0);
                if(mCurrentOffset < 0){
                    callBeforeLoadMoreListener();
                }else if(mCurrentOffset > 0){
                    callBeforeRefreshListener();
                }
            }
        }
    }

    @Override
    public void onNestedScrollAccepted(View child, View target, int axes) {
        mParentHelper.onNestedScrollAccepted(child, target, axes);
    }

    @Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
        if(disabledNestedScrolling){
            return;
        }
        if (isNestedScrolling) {
            canUp = mOption.canUpToDown();
            canDown = mOption.canDownToUp();
            int minOffset = canDown?mOption.getMaxUpOffset():0;
            int maxOffset = canUp?mOption.getMaxDownOffset():0;
            int nextOffset = (mCurrentOffset - dy);
            int sureOffset = Math.min(Math.max(minOffset,nextOffset),maxOffset);
            int deltaY = sureOffset - mCurrentOffset;
            consumed[1] = (-deltaY);
            updatePos(deltaY);
        }
        dispatchNestedPreScroll(dx, dy, consumed, null);
    }

    @Override
    public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
        if(disabledNestedScrolling){
            return;
        }
        boolean canTouch = !isLoading && !isRefreshing && !isOnTouch;
        if (dyUnconsumed != 0 && canTouch) {
            canUp = mOption.canUpToDown();
            canDown = mOption.canDownToUp();
            boolean canUpToDown = (canUp && dyUnconsumed < 0);
            boolean canDownToUp = (canDown && dyUnconsumed > 0);
            if(canUpToDown || canDownToUp){
                isOnTouch = true;
                isNestedScrolling = true;
                updatePos(-dyUnconsumed);
                dyConsumed = dyUnconsumed;
                dyUnconsumed = 0;
            }
        }
        dispatchNestedScroll(dxConsumed,dxUnconsumed,dyConsumed,dyUnconsumed,null);
    }

這里自定義了一個標(biāo)記來作為是否可以進(jìn)行嵌套滑動(并沒有使用系統(tǒng)的)
1.只處理豎直方向的嵌套滑動
2.當(dāng)前只有在子視圖還有沒消費(fèi)的偏移量的前提下,并且當(dāng)前可以觸發(fā)刷新的條件下才進(jìn)行嵌套滑動,比方說RecyclerView滑動到頂部,然后接著滑,此時頂部視圖就會出現(xiàn)
3.一旦開始嵌套滑動,后續(xù)子視圖在滑動之前,滑動偏移量都會被當(dāng)前視圖先使用
4.嵌套滑動結(jié)束后,要進(jìn)行刷新回調(diào)等的判斷,從而進(jìn)行一些回調(diào)或者緩慢滑動回一些位置的操作,類似手指松開

總結(jié)

目前個人已經(jīng)在項目中使用,主要是兩個場景:
1.首頁的頂部刷新,首頁本身是一個沉浸式交互的頁面,內(nèi)容主題是FrameLayout,但是其實重點是里面的RecyclerView,這個時候使用這個視圖就非常方便
2.列表加載,用于在加載完成后直接在頭部提示用戶當(dāng)前加載了多少新的數(shù)據(jù)
可能還有很多細(xì)節(jié)沒有說到以及一些實現(xiàn)上細(xì)節(jié)的漏洞,有點興趣的可以去看源碼以及源碼里面的demo
最后附上源碼地址:https://github.com/dda135/PullRefreshLayout

最后編輯于
?著作權(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)容