下拉刷新、上拉加載更多控件實現原理及解析(一)

之前那個帳號,之後可能不用了,把文章搬過來!!!java

效果預覽

接受hi大頭鬼hi的建議,來一個動態圖,方便你們知道這是個什麼東西。android

demo

動機

項目中,須要一個支持任意View的下拉刷新+上拉加載控件,GitHub上有不少現成的實現,如Android-PullToRefresh, android-Ultra-Pull-To-Refresh等,這些Library都很是優秀,可是Android-PullToRefresh 已經不在維護了,android-Ultra-Pull-To-Refresh自己並不支持上拉加載更多,通過一番糾結後決定本身寫一個。git

原理

    不管是下拉刷新仍是上拉加載更多,原理都是在內容View(ListView、RecyclerView...)不能下拉或者上劃時響應用戶的觸摸事件,在頂部或者底部顯示一個刷新視圖,在程序刷新操做完成後再隱藏掉。github

實現

    既然要在頭部和頂部添加刷新視圖,咱們的控件應該是個ViewGroup,我是直接繼承FrameLayout,這個控件的名字叫NsRefreshLayout。而後咱們須要定義一些屬性,如是否自動觸發上拉加載更多、刷新視圖中的文字顏色等。ide

屬性定義

<declare-styleable name="NsRefreshLayout">
    <!--Loading視圖背景顏色-->
    <attr name="load_view_bg_color" format="color|reference"/>
    <!--進度條顏色-->
    <attr name="progress_bar_color" format="color|reference"/>
    <!--進度條背景色-->
    <attr name="progress_bg_color" format="color|reference"/>
    <!--Loading視圖中文字顏色-->
    <attr name="load_text_color" format="color|reference"/>
    <!--下拉刷新問題描述-->
    <attr name="pull_refresh_text" format="string|reference"/>
    <!--上拉加載文字描述-->
    <attr name="pull_load_text" format="string|reference"/>
    <!--是否自動觸發加載更多-->
    <attr name="auto_load_more" format="boolean"/>
    <!--下拉刷新是否可用-->
    <attr name="pull_refresh_enable" format="boolean"/>
    <!--上拉加載是否可用-->
    <attr name="pull_load_enable" format="boolean"/>
</declare-styleable>

屬性讀取

/**
 * 初始化控件屬性
 */
private void initAttrs(Context context, AttributeSet attrs) {
    if (getChildCount() > 1) {
        throw new RuntimeException("can only have one child");
    }
    loadingViewFinalHeight = NrlUtils.dipToPx(context, LOADING_VIEW_FINAL_HEIGHT_DP);
    loadingViewOverHeight = loadingViewFinalHeight * 2;

    if (isInEditMode() && attrs == null) {
        return;
    }

    int resId;
    TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.NsRefreshLayout);
    Resources resources = context.getResources();

    //LoadView背景顏色
    resId = ta.getResourceId(R.styleable.NsRefreshLayout_load_view_bg_color, -1);
    if (resId == -1) {
        mLoadViewBgColor = ta.getColor(R.styleable.NsRefreshLayout_load_view_bg_color,
                Color.WHITE);
    } else {
        mLoadViewBgColor = resources.getColor(resId);
    }

    //加載文字顏色
    resId = ta.getResourceId(R.styleable.NsRefreshLayout_load_text_color, -1);
    if (resId == -1) {
        mLoadViewTextColor = ta.getColor(R.styleable.NsRefreshLayout_load_text_color,
                Color.BLACK);
    } else {
        mLoadViewTextColor = resources.getColor(resId);
    }

    //進度條背景顏色
    resId = ta.getResourceId(R.styleable.NsRefreshLayout_progress_bg_color, -1);
    if (resId == -1) {
        mProgressBgColor = ta.getColor(R.styleable.NsRefreshLayout_progress_bg_color,
                Color.WHITE);
    } else {
        mProgressBgColor = resources.getColor(resId);
    }

    //進度條顏色
    resId = ta.getResourceId(R.styleable.NsRefreshLayout_progress_bar_color, -1);
    if (resId == -1) {
        mProgressColor = ta.getColor(R.styleable.NsRefreshLayout_progress_bar_color,
                Color.RED);
    } else {
        mProgressColor = resources.getColor(resId);
    }

    //下拉刷新文字描述
    resId = ta.getResourceId(R.styleable.NsRefreshLayout_pull_refresh_text, -1);
    if (resId == -1) {
        mPullRefreshText = ta.getString(R.styleable.NsRefreshLayout_pull_refresh_text);
    } else {
        mPullRefreshText = resources.getString(resId);
    }

    //上拉加載文字描述
    resId = ta.getResourceId(R.styleable.NsRefreshLayout_pull_load_text, -1);
    if (resId == -1) {
        mPullLoadText = ta.getString(R.styleable.NsRefreshLayout_pull_load_text);
    } else {
        mPullLoadText = resources.getString(resId);
    }

    mAutoLoadMore = ta.getBoolean(R.styleable.NsRefreshLayout_auto_load_more, false);
    mPullRefreshEnable = ta.getBoolean(R.styleable.NsRefreshLayout_pull_refresh_enable, true);
    mPullLoadEnable = ta.getBoolean(R.styleable.NsRefreshLayout_pull_load_enable, true);

    ta.recycle();
}

屬性使用

    在內容View佈局完成後(onFinishInflate),根據設置的屬性,來肯定是否須要添加下拉刷新視圖、上拉加載更多視圖,以及視圖中的文字顏色、進度條顏色等。函數

@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    mContentView = getChildAt(0);
    setupViews();
}

private void setupViews() {
    //下拉刷新視圖
    LayoutParams lp;
    if (mPullRefreshEnable) {
        lp = new LayoutParams(LayoutParams.MATCH_PARENT, 0);
        headerView = new LoadView(getContext());
        headerView.setLoadText(TextUtils.isEmpty(mPullRefreshText) ?
                getContext().getString(R.string.default_pull_refresh_text) : mPullRefreshText);
        headerView.setStartEndTrim(0, 0.75f);
        headerView.setBackgroundColor(mLoadViewBgColor);
        headerView.setLoadTextColor(mLoadViewTextColor);
        headerView.setProgressBgColor(mProgressBgColor);
        headerView.setProgressColor(mProgressColor);
        addView(headerView, lp);
    }

    if (mPullLoadEnable) {
        //上拉加載更多視圖
        lp = new LayoutParams(LayoutParams.MATCH_PARENT, 0);
        lp.gravity = Gravity.BOTTOM;
        footerView = new LoadView(getContext());
        footerView.setLoadText(TextUtils.isEmpty(mPullLoadText) ?
                getContext().getString(R.string.default_pull_load_text) : mPullLoadText);
        footerView.setStartEndTrim(0.5f, 1.25f);
        footerView.setBackgroundColor(mLoadViewBgColor);
        footerView.setLoadTextColor(mLoadViewTextColor);
        footerView.setProgressBgColor(mProgressBgColor);
        footerView.setProgressColor(mProgressColor);
        addView(footerView, lp);
    }
}

動態響應用戶配置變化

    有這樣一種需求,一個列表分頁加載,每一頁10條,若是在上拉加載更多後只返回8條,說明已經沒有更多數據了,因此在列表達到底部,用戶再次上劃時就不須要觸發上拉加載更多了。基於這種需求,我設計了一個接口NsRefreshLayoutController。佈局

public interface NsRefreshLayoutController {
    /**
     * 當前下拉刷新是否可用
     */
    boolean isPullRefreshEnable();

    /**
     * 當前上拉加載是否可用,好比列表已無更多數據,可禁用上拉加載功能
     */
    boolean isPullLoadEnable();
}

使用時,實現這個接口,根據當前數據的狀況返回True或者False啓用或者禁用兩個功能了。控件內部,咱們在用戶每次觸發觸摸事件的時候獲取接口返回值。post

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    if (refreshLayoutController != null) {
        mPullRefreshEnable = refreshLayoutController.isPullRefreshEnable();
        mPullLoadEnable = refreshLayoutController.isPullLoadEnable();
    }
    return super.onInterceptTouchEvent(ev);
}

處理Touch事件

    咱們須要作到對Touch事件的處理不影響內容視圖的功能,因此咱們只處理Touch事件,不消耗Touch事件,一個合適的回調很重要,找來找去我選擇了dispatchTouchEvent,官方文檔對這個函數的描述以下:動畫

    處理Touch事件的流程以下,ACTION_DOWN、ACTION_MOVE時記錄Touch的位置,ACTION_MOVE時用當前Touch的位置減去上次DOWN或者MOVE的位置,獲得手指滑動的距離,用這個距離來控制內容視圖、刷新視圖的顯示位置,當達到觸發刷新的位置後,提示用戶鬆手觸發刷新,用戶鬆手後開始刷新動畫並通知程序開始刷新。代碼以下:ui

@Override
public boolean dispatchTouchEvent(MotionEvent event) {
    if (!mPullRefreshEnable && !mPullLoadEnable) {
        return super.dispatchTouchEvent(event);
    }

    if (isRefreshing) {
        return super.dispatchTouchEvent(event);
    }

    switch (event.getActionMasked()) {
        case MotionEvent.ACTION_DOWN: {
            preY = event.getY();
            preX = event.getX();
            break;
        }

        case MotionEvent.ACTION_MOVE: {
            float currentY = event.getY();
            float currentX = event.getX();
            float dy = currentY - preY;
            float dx = currentX - preX;
            preY = currentY;
            preX = currentX;
            if (!actionDetermined) {
                //判斷是下拉刷新仍是上拉加載更多
                if (dy > 0 && !canChildScrollUp() && mPullRefreshEnable) {
                    mCurrentAction = ACTION_PULL_DOWN_REFRESH;
                    actionDetermined = true;
                } else if (dy < 0 && !canChildScrollDown() && mPullLoadEnable) {
                    mCurrentAction = ACTION_PULL_UP_LOAD_MORE;
                    actionDetermined = true;
                }
            }
            handleScroll(dy);
            observerArriveBottom();
            break;
        }

        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL: {
            //用戶鬆手後須要判斷當前的滑動距離是否知足觸發刷新的條件
            if (releaseTouch()) {
                MotionEvent cancelEvent = MotionEvent.obtain(event);
                cancelEvent.setAction(MotionEvent.ACTION_CANCEL);
                return super.dispatchTouchEvent(cancelEvent);
            }
            break;
        }
    }

    return super.dispatchTouchEvent(event);
}

/**
 * 處理滾動
 */
private boolean handleScroll(float distanceY) {
    if (!canChildScrollUp() && mCurrentAction == ACTION_PULL_DOWN_REFRESH &&
            mPullRefreshEnable) {
        //下拉刷新
        LayoutParams lp = (LayoutParams) headerView.getLayoutParams();
        lp.height += distanceY;
        if (lp.height < 0) {
            lp.height = 0;
        } else if (lp.height > loadingViewOverHeight) {
            lp.height = (int) loadingViewOverHeight;
        }
        headerView.setLayoutParams(lp);
        if (lp.height < loadingViewOverHeight) {
            headerView.setLoadText(TextUtils.isEmpty(mPullRefreshText) ?
                    getContext().getString(R.string.default_pull_refresh_text) : mPullRefreshText);
        } else {
            headerView.setLoadText(getContext().getString(R.string.release_to_refresh));
        }
        headerView.setProgressRotation(lp.height / loadingViewOverHeight);
        adjustContentViewHeight(lp.height);
        return true;

    } else if (!canChildScrollDown() && mCurrentAction == ACTION_PULL_UP_LOAD_MORE && mPullLoadEnable) {
        //上拉加載更多
        LayoutParams lp = (LayoutParams) footerView.getLayoutParams();
        lp.height -= distanceY;
        if (lp.height < 0) {
            lp.height = 0;
        } else if (lp.height > loadingViewOverHeight) {
            lp.height = (int) loadingViewOverHeight;
        }
        footerView.setLayoutParams(lp);
        if (lp.height < loadingViewOverHeight) {
            footerView.setLoadText(TextUtils.isEmpty(mPullLoadText) ?
                    getContext().getString(R.string.default_pull_load_text) : mPullLoadText);
        } else {
            footerView.setLoadText(getContext().getString(R.string.release_to_load));
        }
        footerView.setProgressRotation(lp.height / loadingViewOverHeight);
        adjustContentViewHeight(-lp.height);
        return true;
    }
    return false;
}

private void adjustContentViewHeight(float h) {
    mContentView.setTranslationY(h);
    //下面的方式能夠看到完整內容,可是有掉幀現象
    /*if (mCurrentAction == ACTION_PULL_DOWN_REFRESH) {
        mContentView.setTranslationY(h);
    }
    LayoutParams lp = (LayoutParams) mContentView.getLayoutParams();
    lp.height = (int) (getMeasuredHeight() - Math.abs(h));
    mContentView.setLayoutParams(lp);*/

}

private boolean releaseTouch() {
    boolean result = false;
    LayoutParams lp;
    if (mPullRefreshEnable && mCurrentAction == ACTION_PULL_DOWN_REFRESH) {
        lp = (LayoutParams) headerView.getLayoutParams();
        if (lp.height >= loadingViewOverHeight) {
            //觸發下拉刷新
            startPullDownRefresh(lp.height);
            result = true;
        } else if (lp.height > 0) {
            //未知足下拉刷新觸發條件,重置狀態
            resetPullDownRefresh(lp.height);
            result = lp.height >= CLICK_TOUCH_DEVIATION;
        } else {
            resetPullRefreshState();
        }
    }

    if (mPullLoadEnable && mCurrentAction == ACTION_PULL_UP_LOAD_MORE) {
        lp = (LayoutParams) footerView.getLayoutParams();
        if (lp.height >= loadingViewOverHeight) {
            //觸發上拉加載更多
            startPullUpLoadMore(lp.height);
            result = true;
        } else if (lp.height > 0) {
            //未知足上拉加載更多觸發條件,重置狀態
            resetPullUpLoadMore(lp.height);
            result = lp.height >= CLICK_TOUCH_DEVIATION;
        } else {
            resetPullLoadState();
        }
    }
    return result;
}

private void startPullDownRefresh(int headerViewHeight) {
    isRefreshing = true;
    ValueAnimator animator = ValueAnimator.ofFloat(headerViewHeight, loadingViewFinalHeight);
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            LayoutParams lp = (LayoutParams) headerView.getLayoutParams();
            lp.height = (int) ((Float) animation.getAnimatedValue()).floatValue();
            headerView.setLayoutParams(lp);
            adjustContentViewHeight(lp.height);
        }
    });
    animator.addListener(new SimpleAnimatorListener() {
        @Override
        public void onAnimationEnd(Animator animation) {
            headerView.start();
            headerView.setLoadText(getContext().getString(R.string.refresh_text));

            if (refreshLayoutListener != null) {
                refreshLayoutListener.onRefresh();
            }
        }
    });
    animator.setDuration(300);
    animator.start();
}

/**
 * 重置下拉刷新狀態
 *
 * @param headerViewHeight 當前下拉刷新視圖的高度
 */
private void resetPullDownRefresh(int headerViewHeight) {
    headerView.stop();
    //headerView.setStartEndTrim(0, 0.75f);
    ValueAnimator animator = ValueAnimator.ofFloat(headerViewHeight, 0);
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            LayoutParams lp = (LayoutParams) headerView.getLayoutParams();
            lp.height = (int) ((Float) animation.getAnimatedValue()).floatValue();
            headerView.setLayoutParams(lp);
            adjustContentViewHeight(lp.height);
        }
    });
    animator.addListener(new SimpleAnimatorListener() {
        @Override
        public void onAnimationEnd(Animator animation) {
            resetPullRefreshState();

        }
    });
    animator.setDuration(300);
    animator.start();
}

private void resetPullRefreshState() {
    //重置動畫結束纔算徹底完成刷新動做
    isRefreshing = false;
    actionDetermined = false;
    mCurrentAction = -1;
    headerView.setLoadText(TextUtils.isEmpty(mPullRefreshText) ?
            getContext().getString(R.string.default_pull_refresh_text) : mPullRefreshText);
}

private void startPullUpLoadMore(int headerViewHeight) {
    isRefreshing = true;
    ValueAnimator animator = ValueAnimator.ofFloat(headerViewHeight, loadingViewFinalHeight);
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            LayoutParams lp = (LayoutParams) footerView.getLayoutParams();
            lp.height = (int) ((Float) animation.getAnimatedValue()).floatValue();
            footerView.setLayoutParams(lp);
            adjustContentViewHeight(-lp.height);
        }
    });
    animator.addListener(new SimpleAnimatorListener() {
        @Override
        public void onAnimationEnd(Animator animation) {
            footerView.start();
            footerView.setLoadText(getContext().getString(R.string.load_text));

            if (refreshLayoutListener != null) {
                refreshLayoutListener.onLoadMore();
            }
        }
    });
    animator.setDuration(300);
    animator.start();
}

/**
 * 重置下拉刷新狀態
 *
 * @param headerViewHeight 當前下拉刷新視圖的高度
 */
private void resetPullUpLoadMore(int headerViewHeight) {
    footerView.stop();
    //footerView.setStartEndTrim(0.5f, 1.25f);
    ValueAnimator animator = ValueAnimator.ofFloat(headerViewHeight, 0);
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            LayoutParams lp = (LayoutParams) footerView.getLayoutParams();
            lp.height = (int) ((Float) animation.getAnimatedValue()).floatValue();
            footerView.setLayoutParams(lp);
            adjustContentViewHeight(-lp.height);
        }
    });
    animator.addListener(new SimpleAnimatorListener() {
        @Override
        public void onAnimationEnd(Animator animation) {
            resetPullLoadState();

        }
    });
    animator.setDuration(300);
    animator.start();
}

private void resetPullLoadState() {
    //重置動畫結束纔算徹底完成刷新動做
    isRefreshing = false;
    actionDetermined = false;
    mCurrentAction = -1;
    footerView.setLoadText(TextUtils.isEmpty(mPullLoadText) ?
            getContext().getString(R.string.default_pull_load_text) : mPullLoadText);
}

/**
 * @return 子視圖是否能夠下拉
 */
public boolean canChildScrollUp() {
    if (mContentView == null) {
        return false;
    }
    if (Build.VERSION.SDK_INT < 14) {
        if (mContentView instanceof AbsListView) {
            final AbsListView absListView = (AbsListView) mContentView;
            return absListView.getChildCount() > 0
                    && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
                    .getTop() < absListView.getPaddingTop());
        } else {
            return ViewCompat.canScrollVertically(mContentView, -1) || mContentView.getScrollY() > 0;
        }
    } else {
        return ViewCompat.canScrollVertically(mContentView, -1);
    }
}

/**
 * @return 子視圖是否能夠上劃
 */
public boolean canChildScrollDown() {
    if (mContentView == null) {
        return false;
    }
    if (Build.VERSION.SDK_INT < 14) {
        if (mContentView instanceof AbsListView) {
            final AbsListView absListView = (AbsListView) mContentView;
            if (absListView.getChildCount() > 0) {
                int lastChildBottom = absListView.getChildAt(absListView.getChildCount() - 1)
                        .getBottom();
                return absListView.getLastVisiblePosition() == absListView.getAdapter().getCount() - 1
                        && lastChildBottom <= absListView.getMeasuredHeight();
            } else {
                return false;
            }

        } else {
            return ViewCompat.canScrollVertically(mContentView, 1) || mContentView.getScrollY() > 0;
        }
    } else {
        return ViewCompat.canScrollVertically(mContentView, 1);
    }
}

public void setRefreshLayoutListener(NsRefreshLayoutListener refreshLayoutListener) {
    this.refreshLayoutListener = refreshLayoutListener;
}

    上面代碼中有一個變量CLICK_TOUCH_DEVIATION,這個變量表示對用戶點擊事件的容錯值,用戶進行點擊動做時,會產生很小的滑動距離,若是不作容錯處理會出現刷新視圖抖動出現的問題。

    另外還有一個observerArriveBottom(); 這個函數就是處理自動加載更多的關鍵。該函數在Touch事件產生滑動距離後,採起相似輪詢的機制,判斷滑動是否已經中止,滑動事件中止後,根據內容控件當前狀態、用戶配置來肯定是否觸發加載更多事件。代碼以下:

private void observerArriveBottom() {
    if (isRefreshing || !mAutoLoadMore || !mPullLoadEnable) {
        return;
    }
    mContentView.getViewTreeObserver().addOnScrollChangedListener(
            new ViewTreeObserver.OnScrollChangedListener() {

                @Override
                public void onScrollChanged() {
                    mContentView.removeCallbacks(flingRunnable);
                    mContentView.postDelayed(flingRunnable, 6);
                }
            });
}

private Runnable flingRunnable = new Runnable() {
    @Override
    public void run() {
        if (isRefreshing || !mAutoLoadMore || !mPullLoadEnable) {
            return;
        }

        if (!canChildScrollDown()) {
            mCurrentAction = ACTION_PULL_UP_LOAD_MORE;
            isRefreshing = true;
            startPullUpLoadMore(0);
        }
    }
};

對外接口

public interface NsRefreshLayoutListener {
    void onRefresh();

    void onLoadMore();
}

搞定

    整個控件實現就是這樣的,代碼我已經放到GitHub上了,歡迎你們拍磚。https://github.com/xiaolifan/NsRefreshLayout

相關文章
相關標籤/搜索