Android 簡(jiǎn)單易上手的下拉刷新控件

背景:列表控件在Android App開(kāi)發(fā)中用到的場(chǎng)景很多歇式。在以前我們用ListView掌眠,GradView,現(xiàn)在應(yīng)該大多數(shù)開(kāi)發(fā)者都已經(jīng)在選擇使用RecyclerView了寥闪,谷歌給我們提供了這些方便的列表控件太惠,我們可以很容易的使用它們。但是在實(shí)際的場(chǎng)景中疲憋,我們可能還想要更多的能力凿渊,比如最常見(jiàn)的列表下拉刷新,上拉加載缚柳。上拉刷新和下拉加載應(yīng)該是列表的標(biāo)配吧埃脏,基本上有列表的地方都要具體這個(gè)能力。雖然刷新這個(gè)功能已經(jīng)有各種各樣的第三方框架可以選擇秋忙,但是畢竟不是自己的嘛彩掐,今天我們就來(lái)實(shí)現(xiàn)一個(gè)自己的下拉刷新控件,多動(dòng)手才能更好的理解灰追。

效果圖:

效果圖.gif

原理分析:

在coding之前堵幽,我們先分析一下原理,原理分析出來(lái)之后弹澎,我們才可以確定實(shí)現(xiàn)方案朴下。
先上一張圖,來(lái)個(gè)直觀的認(rèn)識(shí):


布局樣式.png

在列表上面有個(gè)刷新頭裁奇,隨著手指向下拉桐猬,逐漸把頂部不可見(jiàn)的刷新頭拉到屏幕中來(lái)麦撵,用戶能看到刷新的狀態(tài)變化刽肠,達(dá)到下拉刷新的目的溃肪。

通過(guò)分析,我們確定一種實(shí)現(xiàn)方案:我們自定義一個(gè)容器音五,容器里面包含兩個(gè)部分惫撰。
1. 頂部刷新頭。
2. 列表區(qū)域躺涝。

確定好布局容器之后厨钻,我們來(lái)分析刷新頭的幾種狀態(tài),


下拉刷新?tīng)顟B(tài).png

把下拉刷新分為5中狀態(tài)坚嗜,通過(guò)不同狀態(tài)間的切換實(shí)現(xiàn)下拉刷新能力夯膀。
狀態(tài)間的流程圖如下:


刷新流程圖.png

整個(gè)下拉刷新的流程就如圖中所示。

流程清楚了之后苍蔬,接下來(lái)就是編寫(xiě)代碼實(shí)現(xiàn)了诱建。

代碼實(shí)現(xiàn):

/**
 * @author luowang8
 * @date 2020-08-21 10:54
 * @desc 下拉刷新控件
 */
public class PullRefreshView extends LinearLayout {
    
    
    /**
     * 頭部tag
     */
    public static final String HEADER_TAG = "HEADER_TAG";
    
    /**
     * 列表tag
     */
    public static final String LIST_TAG   = "LIST_TAG";
    
    /**
     * tag
     */
    private static final String TAG = "PullRefreshView";
    
    /**
     * 默認(rèn)初始狀態(tài)
     */
    private @State
    int mState = State.INIT;
    
    /**
     * 是否被拖拽
     */
    private boolean mIsDragging = false;
    
    /**
     * 上下文
     */
    private Context mContext;
    
    
    /**
     * RecyclerView
     */
    private RecyclerView mRecyclerView;
    
    /**
     * 頂部刷新頭
     */
    private View mHeaderView;
    
    /**
     * 初始Y的坐標(biāo)
     */
    private int mInitMotionY;
    
    /**
     * 上一次Y的坐標(biāo)
     */
    private int mLastMotionY;
    
    /**
     * 手指觸發(fā)滑動(dòng)的臨界距離
     */
    private int mSlopTouch;
    
    /**
     * 觸發(fā)刷新的臨界值
     */
    private int mRefreshHeight = 200;
    
    /**
     * 滑動(dòng)時(shí)長(zhǎng)
     */
    private int mDuring = 300;
    
    /**
     * 用戶刷新監(jiān)聽(tīng)器
     */
    private OnRefreshListener mOnRefreshListener;
    
    /**
     * 刷新文字提示
     */
    private TextView mRefreshTip;
    
    /**
     * 是否可拖拽, 因?yàn)樵谒⑿骂^自由滑動(dòng)和刷新?tīng)顟B(tài)的時(shí)候,
     * 我們應(yīng)該保持界面不被破壞
     */
    private boolean mIsCanDrag = true;
    
    /**
     * 頭部布局
     */
    private LayoutParams mHeaderLayoutParams;
    
    /**
     * 列表布局
     */
    private LayoutParams mListLayoutParams;
    
    /**
     * 屬性動(dòng)畫(huà)
     */
    private ValueAnimator mValueAnimator;
    
    
    
    /////////////////////// 分割 ///////////////////////
    
    
    /**
     * @param context
     */
    public PullRefreshView(Context context) {
        this(context, null);
    }
    
    /**
     * @param context
     * @param attrs
     */
    public PullRefreshView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }
    
    /**
     * @param context
     * @param attrs
     * @param defStyleAttr
     */
    public PullRefreshView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        
        mContext = context;
        
        initView();
    }
    
    public RecyclerView getRecyclerView() {
        return mRecyclerView;
    }
    
    /**
     * 設(shè)置RecyclerView
     *
     * @param recyclerView
     */
    public void addRecyclerView(RecyclerView recyclerView) {
        
        if (recyclerView == null) {
            return;
        }
        
        View view = findViewWithTag(LIST_TAG);
        if (view != null) {
            removeView(view);
        }
        
        this.mRecyclerView = recyclerView;
        this.mRecyclerView.setTag(LIST_TAG);
        addView(recyclerView, mListLayoutParams);
    }
    
    /**
     * 設(shè)置自定義刷新頭部
     * @param headerView
     */
    public void addHeaderView(View headerView) {
        
        if (headerView == null) {
            return;
        }
        
        View view = findViewWithTag(HEADER_TAG);
        if (view != null) {
            removeView(view);
        }
        
        this.mHeaderView = headerView;
        this.mHeaderView.setTag(HEADER_TAG);
        addView(mHeaderView, mHeaderLayoutParams);
    }
    
    /**
     * @param onRefreshListener
     */
    public void setOnRefreshListener(OnRefreshListener onRefreshListener) {
        mOnRefreshListener = onRefreshListener;
    }
    
    /**
     * 初始化View
     */
    private void initView() {
        
        setOrientation(LinearLayout.VERTICAL);
        
        Context context = getContext();
        /** 1碟绑、添加刷新頭Header */
        mHeaderView = LayoutInflater.from(context).inflate(R.layout.layout_refresh_header, null);
        mHeaderView.setTag(HEADER_TAG);
        mRefreshTip = mHeaderView.findViewById(R.id.content);
        mHeaderLayoutParams = new LayoutParams(LayoutParams.MATCH_PARENT,
                DensityUtil.dip2px(mContext, 500)
        );
        this.addView(mHeaderView, mHeaderLayoutParams);
        
        /** 2俺猿、添加內(nèi)容RecyclerView */
        mRecyclerView = new RecyclerView(context);
        mRecyclerView.setTag(LIST_TAG);
        mListLayoutParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
        this.addView(mRecyclerView, mListLayoutParams);
        
        /** 3、一開(kāi)始的時(shí)候要讓Header看不見(jiàn)格仲,設(shè)置向上的負(fù)paddingTop */
        setPadding(0, -DensityUtil.dip2px(mContext, 500), 0, 0);
        
        ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
        mSlopTouch = viewConfiguration.getScaledTouchSlop();
        
        setState(State.INIT);
        
    }
    
    /**
     * 設(shè)置狀態(tài)押袍,每個(gè)狀態(tài)下,做不同的事情
     *
     * @param state 狀態(tài)
     */
    private void setState(@State int state) {
        
        switch (state) {
            case State.INIT:
                initState();
                break;
            
            case State.DRAGGING:
                dragState();
                break;
            
            case State.READY:
                readyState();
                break;
            
            case State.REFRESHING:
                refreshState();
                break;
            
            case State.FLING:
                flingState();
                break;
            
            default:
                break;
        }
        
        mState = state;
    }
    
    /**
     * 處理初始化狀態(tài)方法
     */
    private void initState() {
        
        // 只有在初始狀態(tài)時(shí)凯肋,恢復(fù)成可拖拽
        mIsCanDrag = true;
        mIsDragging = false;
        mRefreshTip.setText("下拉刷新");
    }
    
    /**
     * 處理拖拽時(shí)方法
     */
    private void dragState() {
        mIsDragging = true;
    }
    
    /**
     * 拖拽距離超過(guò)header高度時(shí)谊惭,如何處理
     */
    private void readyState() {
        mRefreshTip.setText("松手刷新");
    }
    
    /**
     * 用戶刷新時(shí),如何處理
     */
    private void refreshState() {
        if (mOnRefreshListener != null) {
            mOnRefreshListener.onRefresh();
        }
        
        mIsCanDrag = false;
        mRefreshTip.setText("正在刷新,請(qǐng)稍后...");
    }
    
    /**
     * 自由滾動(dòng)時(shí)侮东,如何處理
     */
    private void flingState() {
        mIsDragging = false;
        mIsCanDrag = false;
        
        /** 自由滾動(dòng)狀態(tài)可以從兩個(gè)狀態(tài)進(jìn)入:
         *  1午笛、READY狀態(tài)。
         *  2苗桂、其他狀態(tài)药磺。
         *
         *  !滑動(dòng)均需要平滑滑動(dòng)
         *  */
        if (mState == State.READY) {
            
            Log.e(TAG, "flingState: 從Ready狀態(tài)開(kāi)始自由滑動(dòng)");
            // 從準(zhǔn)備狀態(tài)進(jìn)入煤伟,刷新頭滑到 200 的位置
            
            smoothScroll(getScrollY(), -mRefreshHeight);
        }
        else {
            
            Log.e(TAG, "flingState: 松手后癌佩,從其他狀態(tài)開(kāi)始自由滑動(dòng)");
            // 從刷新?tīng)顟B(tài)進(jìn)入,刷新頭直接回到最初默認(rèn)的位置
            // 即: 滑出界面便锨,ScrollY 變成 0
            smoothScroll(getScrollY(), 0);
        }
        
    }
    
    /**
     *  光滑滾動(dòng)
     * @param startPos 開(kāi)始位置
     * @param targetPos 結(jié)束位置
     */
    private void smoothScroll(int startPos, final int targetPos) {
        
        // 如果有動(dòng)畫(huà)正在播放围辙,先停止
        if (mValueAnimator != null && mValueAnimator.isRunning()) {
            mValueAnimator.cancel();
            mValueAnimator.end();
            mValueAnimator = null;
        }
        
        // 然后開(kāi)啟動(dòng)畫(huà)
        mValueAnimator = ValueAnimator.ofInt(getScrollY(), targetPos);
        mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                int value = (int) valueAnimator.getAnimatedValue();
                scrollTo(0, value);
                
                if (getScrollY() == targetPos) {
                    if (targetPos != 0) {
                        setState(State.REFRESHING);
                    }
                    else {
                        setState(State.INIT);
                    }
                }
            }
        });
        
        mValueAnimator.setDuration(mDuring);
        mValueAnimator.start();
    }
    
    /**
     * 是否準(zhǔn)備好觸發(fā)下拉的狀態(tài)了
     */
    private boolean isReadyToPull() {
        
        if (mRecyclerView == null) {
            return false;
        }
        
        LinearLayoutManager manager = (LinearLayoutManager) mRecyclerView.getLayoutManager();
        
        if (manager == null) {
            return false;
        }
        
        if (mRecyclerView != null && mRecyclerView.getAdapter() != null) {
            View child  = mRecyclerView.getChildAt(0);
            int  height = child.getHeight();
            if (height > mRecyclerView.getHeight()) {
                return child.getTop() == 0 && manager.findFirstVisibleItemPosition() == 0;
            }
            else {
                return manager.findFirstCompletelyVisibleItemPosition() == 0;
            }
        }
        
        return false;
    }
    
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        
        int action = ev.getAction();
        
        Log.e(TAG, "onInterceptTouchEvent: action = " + action);
        
        if (!mIsCanDrag) {
            return true;
        }
        
        if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
            mIsDragging = false;
            return false;
        }
        
        if (mIsDragging && action == MotionEvent.ACTION_MOVE) {
            return true;
        }
        
        switch (action) {
            case MotionEvent.ACTION_MOVE:
                int diff = (int) (ev.getY() - mLastMotionY);
                if (Math.abs(diff) > mSlopTouch && diff > 1 && isReadyToPull()) {
                    mLastMotionY = (int) ev.getY();
                    mIsDragging = true;
                }
                break;
            
            case MotionEvent.ACTION_DOWN:
                if (isReadyToPull()) {
                    setState(State.INIT);
                    mInitMotionY = (int) ev.getY();
                    mLastMotionY = (int) ev.getY();
                }
                break;
            
            default:
                break;
        }
        
        return mIsDragging;
    }
    
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        
        int action = event.getAction();
        
        Log.e(TAG, "onTouchEvent: action = " + action);
        
        if (!mIsCanDrag) {
            return false;
        }
        
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                if (isReadyToPull()) {
                    setState(State.INIT);
                    mInitMotionY = (int) event.getY();
                    mLastMotionY = (int) event.getY();
                }
                break;
            
            case MotionEvent.ACTION_MOVE:
                
                if (mIsDragging) {
                    mLastMotionY = (int) event.getY();
                    setState(State.DRAGGING);
                    
                    pullScroll();
                    return true;
                }
                
                break;
            
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                mIsDragging = false;
                setState(State.FLING);
                break;
            
            default:
                break;
            
        }
        
        return true;
    }
    
    /**
     * 下拉移動(dòng)界面,拉出刷新頭
     */
    private void pullScroll() {
        /** 滾動(dòng)值 = 初始值 - 結(jié)尾值 */
        int scrollValue = (mInitMotionY - mLastMotionY) / 3;
        
        if (scrollValue > 0) {
            scrollTo(0, 0);
            return;
        }
        
        if (Math.abs(scrollValue) > mRefreshHeight
                && mState == State.DRAGGING) {
            // 約定:如果偏移量超過(guò) 200(這個(gè)值放案,表示是否可以啟動(dòng)刷新的臨界值姚建,可任意定),
            // 那么狀態(tài)變成 State.READY
            Log.e(TAG, "pullScroll: 超過(guò)了觸發(fā)刷新的臨界值");
            setState(State.READY);
        }
        
        scrollTo(0, scrollValue);
    }
    
    /**
     * 刷新完成,需要調(diào)用方主動(dòng)發(fā)起吱殉,才能完成將刷新頭收起
     */
    public void refreshComplete() {
        mRefreshTip.setText("刷新完成掸冤!");
        setState(State.FLING);
    }
    
    @IntDef({
                    State.INIT
                    , State.DRAGGING
                    , State.READY
                    , State.REFRESHING
                    , State.FLING,
            })
    @Retention(RetentionPolicy.SOURCE)
    public @interface State {
        
        /**
         * 初始狀態(tài)
         */
        int INIT = 1;
        
        /**
         * 手指拖拽狀態(tài)
         */
        int DRAGGING = 2;
        
        /**
         * 就緒狀態(tài)厘托,松開(kāi)手指后,可以刷新
         */
        int READY = 3;
        
        /**
         * 刷新?tīng)顟B(tài)稿湿,這個(gè)狀態(tài)下铅匹,用戶用于發(fā)起刷新請(qǐng)求
         */
        int REFRESHING = 4;
        
        /**
         * 松開(kāi)手指,頂部自然回彈的狀態(tài)饺藤,有兩種表現(xiàn)
         * 1包斑、手指釋放時(shí)的高度大于刷新頭的高度。
         * 2涕俗、手指釋放時(shí)的高度小于刷新頭的高度罗丰。
         */
        int FLING = 5;
    }
    
    /**
     * 用戶刷新?tīng)顟B(tài)的操作
     */
    public interface OnRefreshListener {
        void onRefresh();
    }
    
}

實(shí)現(xiàn)的邏輯并不復(fù)雜,新手都能看懂再姑,先理解了整個(gè)流程丸卷,代碼就是水到渠成的事。
思想第一询刹,最后代碼谜嫉。

完整DEMO直通車(chē):https://github.com/wwluo14/PullToRefresh

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市凹联,隨后出現(xiàn)的幾起案子沐兰,更是在濱河造成了極大的恐慌,老刑警劉巖蔽挠,帶你破解...
    沈念sama閱讀 221,635評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件住闯,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡澳淑,警方通過(guò)查閱死者的電腦和手機(jī)比原,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,543評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)杠巡,“玉大人量窘,你說(shuō)我怎么就攤上這事∏庥担” “怎么了蚌铜?”我有些...
    開(kāi)封第一講書(shū)人閱讀 168,083評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)嫩海。 經(jīng)常有香客問(wèn)我冬殃,道長(zhǎng),這世上最難降的妖魔是什么叁怪? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 59,640評(píng)論 1 296
  • 正文 為了忘掉前任审葬,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘涣觉。我一直安慰自己痴荐,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,640評(píng)論 6 397
  • 文/花漫 我一把揭開(kāi)白布旨枯。 她就那樣靜靜地躺著,像睡著了一般混驰。 火紅的嫁衣襯著肌膚如雪攀隔。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 52,262評(píng)論 1 308
  • 那天栖榨,我揣著相機(jī)與錄音昆汹,去河邊找鬼。 笑死婴栽,一個(gè)胖子當(dāng)著我的面吹牛满粗,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播愚争,決...
    沈念sama閱讀 40,833評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼映皆,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了轰枝?” 一聲冷哼從身側(cè)響起捅彻,我...
    開(kāi)封第一講書(shū)人閱讀 39,736評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎鞍陨,沒(méi)想到半個(gè)月后步淹,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,280評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡诚撵,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,369評(píng)論 3 340
  • 正文 我和宋清朗相戀三年缭裆,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片寿烟。...
    茶點(diǎn)故事閱讀 40,503評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡澈驼,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出筛武,到底是詐尸還是另有隱情盅藻,我是刑警寧澤,帶...
    沈念sama閱讀 36,185評(píng)論 5 350
  • 正文 年R本政府宣布畅铭,位于F島的核電站氏淑,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏硕噩。R本人自食惡果不足惜假残,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,870評(píng)論 3 333
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧辉懒,春花似錦阳惹、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 32,340評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至颠印,卻和暖如春纲岭,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背线罕。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,460評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工止潮, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人钞楼。 一個(gè)月前我還...
    沈念sama閱讀 48,909評(píng)論 3 376
  • 正文 我出身青樓喇闸,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親询件。 傳聞我的和親對(duì)象是個(gè)殘疾皇子燃乍,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,512評(píng)論 2 359