Android 简单易上手的下拉刷新控件

背景:列表控件在Android App开发中用到的场景很多。在以前我们用ListView,GradView,现在应该大多数开发者都已经在选择使用RecyclerView了,谷歌给我们提供了这些方便的列表控件,我们可以很容易的使用它们。但是在实际的场景中,我们可能还想要更多的能力,比如最常见的列表下拉刷新,上拉加载。上拉刷新和下拉加载应该是列表的标配吧,基本上有列表的地方都要具体这个能力。虽然刷新这个功能已经有各种各样的第三方框架可以选择,但是毕竟不是自己的嘛,今天我们就来实现一个自己的下拉刷新控件,多动手才能更好的理解。

效果图:

效果图.gif

原理分析:

在coding之前,我们先分析一下原理,原理分析出来之后,我们才可以确定实现方案。
先上一张图,来个直观的认识:


布局样式.png

在列表上面有个刷新头,随着手指向下拉,逐渐把顶部不可见的刷新头拉到屏幕中来,用户能看到刷新的状态变化,达到下拉刷新的目的。

通过分析,我们确定一种实现方案:我们自定义一个容器,容器里面包含两个部分。
1. 顶部刷新头。
2. 列表区域。

确定好布局容器之后,我们来分析刷新头的几种状态,


下拉刷新状态.png

把下拉刷新分为5中状态,通过不同状态间的切换实现下拉刷新能力。
状态间的流程图如下:


刷新流程图.png

整个下拉刷新的流程就如图中所示。

流程清楚了之后,接下来就是编写代码实现了。

代码实现:

/**
 * @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";
    
    /**
     * 默认初始状态
     */
    private @State
    int mState = State.INIT;
    
    /**
     * 是否被拖拽
     */
    private boolean mIsDragging = false;
    
    /**
     * 上下文
     */
    private Context mContext;
    
    
    /**
     * RecyclerView
     */
    private RecyclerView mRecyclerView;
    
    /**
     * 顶部刷新头
     */
    private View mHeaderView;
    
    /**
     * 初始Y的坐标
     */
    private int mInitMotionY;
    
    /**
     * 上一次Y的坐标
     */
    private int mLastMotionY;
    
    /**
     * 手指触发滑动的临界距离
     */
    private int mSlopTouch;
    
    /**
     * 触发刷新的临界值
     */
    private int mRefreshHeight = 200;
    
    /**
     * 滑动时长
     */
    private int mDuring = 300;
    
    /**
     * 用户刷新监听器
     */
    private OnRefreshListener mOnRefreshListener;
    
    /**
     * 刷新文字提示
     */
    private TextView mRefreshTip;
    
    /**
     * 是否可拖拽, 因为在刷新头自由滑动和刷新状态的时候,
     * 我们应该保持界面不被破坏
     */
    private boolean mIsCanDrag = true;
    
    /**
     * 头部布局
     */
    private LayoutParams mHeaderLayoutParams;
    
    /**
     * 列表布局
     */
    private LayoutParams mListLayoutParams;
    
    /**
     * 属性动画
     */
    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;
    }
    
    /**
     * 设置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);
    }
    
    /**
     * 设置自定义刷新头部
     * @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、添加内容RecyclerView */
        mRecyclerView = new RecyclerView(context);
        mRecyclerView.setTag(LIST_TAG);
        mListLayoutParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
        this.addView(mRecyclerView, mListLayoutParams);
        
        /** 3、一开始的时候要让Header看不见,设置向上的负paddingTop */
        setPadding(0, -DensityUtil.dip2px(mContext, 500), 0, 0);
        
        ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
        mSlopTouch = viewConfiguration.getScaledTouchSlop();
        
        setState(State.INIT);
        
    }
    
    /**
     * 设置状态,每个状态下,做不同的事情
     *
     * @param state 状态
     */
    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;
    }
    
    /**
     * 处理初始化状态方法
     */
    private void initState() {
        
        // 只有在初始状态时,恢复成可拖拽
        mIsCanDrag = true;
        mIsDragging = false;
        mRefreshTip.setText("下拉刷新");
    }
    
    /**
     * 处理拖拽时方法
     */
    private void dragState() {
        mIsDragging = true;
    }
    
    /**
     * 拖拽距离超过header高度时,如何处理
     */
    private void readyState() {
        mRefreshTip.setText("松手刷新");
    }
    
    /**
     * 用户刷新时,如何处理
     */
    private void refreshState() {
        if (mOnRefreshListener != null) {
            mOnRefreshListener.onRefresh();
        }
        
        mIsCanDrag = false;
        mRefreshTip.setText("正在刷新,请稍后...");
    }
    
    /**
     * 自由滚动时,如何处理
     */
    private void flingState() {
        mIsDragging = false;
        mIsCanDrag = false;
        
        /** 自由滚动状态可以从两个状态进入:
         *  1、READY状态。
         *  2、其他状态。
         *
         *  !滑动均需要平滑滑动
         *  */
        if (mState == State.READY) {
            
            Log.e(TAG, "flingState: 从Ready状态开始自由滑动");
            // 从准备状态进入,刷新头滑到 200 的位置
            
            smoothScroll(getScrollY(), -mRefreshHeight);
        }
        else {
            
            Log.e(TAG, "flingState: 松手后,从其他状态开始自由滑动");
            // 从刷新状态进入,刷新头直接回到最初默认的位置
            // 即: 滑出界面,ScrollY 变成 0
            smoothScroll(getScrollY(), 0);
        }
        
    }
    
    /**
     *  光滑滚动
     * @param startPos 开始位置
     * @param targetPos 结束位置
     */
    private void smoothScroll(int startPos, final int targetPos) {
        
        // 如果有动画正在播放,先停止
        if (mValueAnimator != null && mValueAnimator.isRunning()) {
            mValueAnimator.cancel();
            mValueAnimator.end();
            mValueAnimator = null;
        }
        
        // 然后开启动画
        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();
    }
    
    /**
     * 是否准备好触发下拉的状态了
     */
    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;
    }
    
    /**
     * 下拉移动界面,拉出刷新头
     */
    private void pullScroll() {
        /** 滚动值 = 初始值 - 结尾值 */
        int scrollValue = (mInitMotionY - mLastMotionY) / 3;
        
        if (scrollValue > 0) {
            scrollTo(0, 0);
            return;
        }
        
        if (Math.abs(scrollValue) > mRefreshHeight
                && mState == State.DRAGGING) {
            // 约定:如果偏移量超过 200(这个值,表示是否可以启动刷新的临界值,可任意定),
            // 那么状态变成 State.READY
            Log.e(TAG, "pullScroll: 超过了触发刷新的临界值");
            setState(State.READY);
        }
        
        scrollTo(0, scrollValue);
    }
    
    /**
     * 刷新完成,需要调用方主动发起,才能完成将刷新头收起
     */
    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 {
        
        /**
         * 初始状态
         */
        int INIT = 1;
        
        /**
         * 手指拖拽状态
         */
        int DRAGGING = 2;
        
        /**
         * 就绪状态,松开手指后,可以刷新
         */
        int READY = 3;
        
        /**
         * 刷新状态,这个状态下,用户用于发起刷新请求
         */
        int REFRESHING = 4;
        
        /**
         * 松开手指,顶部自然回弹的状态,有两种表现
         * 1、手指释放时的高度大于刷新头的高度。
         * 2、手指释放时的高度小于刷新头的高度。
         */
        int FLING = 5;
    }
    
    /**
     * 用户刷新状态的操作
     */
    public interface OnRefreshListener {
        void onRefresh();
    }
    
}

实现的逻辑并不复杂,新手都能看懂,先理解了整个流程,代码就是水到渠成的事。
思想第一,最后代码。

完整DEMO直通车:https://github.com/wwluo14/PullToRefresh

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,258评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,335评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,225评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,126评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,140评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,098评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,018评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,857评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,298评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,518评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,400评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,993评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,638评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,661评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352