RecyclerView 上拉加载 PullToRefreshRecyclerView

设计思路

ListView 上拉加载很容易实现,监听 ListView 滑动到底部,显示 FootView 即可,但是 RecyclerView 没有 addFootView(View view) 这个方法,有一种方案是在 adapter 中加一个 itemView 用于显示 FootView,但是这样做就得每一个使用到 RecyclerView 的地方都写一遍重复代码,而且不能适配多种类型的 LayoutManager,所以 RecyclerView 就需要另外一种思路来实现,我的解决方案是:自定义 PullToRefreshRecyclerView 继承 FrameLayout,将 RecyclerView 与 FootView 添加到 PullToRefreshRecyclerView 中,上拉时显示 FootView。

具体方案

布局文件中很简单,一个 FrameLayout 内部放一个 RecyclerView 和 一个 FootView,布局文件如下:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:id="@+id/root_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

    <LinearLayout
        android:id="@+id/footer_view"
        android:layout_width="match_parent"
        android:layout_height="80dp"
        android:orientation="horizontal"
        android:gravity="center"
        android:layout_gravity="bottom">
        <ProgressBar
            android:id="@+id/progress"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:visibility="gone"
            android:layout_marginRight="5dp"/>
        <ImageView
            android:id="@+id/img_arrow"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:scaleType="centerInside"
            android:src="@mipmap/arrow"/>
        <TextView
            android:id="@+id/tv_load_tag"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:layout_marginLeft="5dp"
            android:text="上拉加载数据"/>
    </LinearLayout>
</FrameLayout>

这个布局的效果如下图:</br>


图一

</br>
可以看到有个很明显的问题,FootView 是一直在屏幕内部的,即使隐藏掉滑动到底部在显示效果也很差劲,我的解决方案是重写 PullToRefreshRecyclerView 的 onMeasure 方法,将 PullToRefreshRecyclerView 高度设置为屏幕高度加上 FootView 的高度,上拉时使用 scrollTo 方法显示 FootView。
onMeasure 方法如下:

@Override
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    final int height = getMeasuredHeight() + footViewHeight;
    setMeasuredDimension(getMeasuredWidth(), height);

    LayoutParams rootLP = (LayoutParams) rootView.getLayoutParams();
    rootLP.height = height;
    rootView.setLayoutParams(rootLP);//设置 FootView的高度

    LayoutParams recyclerLp = (LayoutParams) recyclerView.getLayoutParams();
    recyclerLp.height = height - footViewHeight;
    recyclerView.setLayoutParams(recyclerLp);//将 RecyclerView 的高度设置为屏幕高度
}

这样基本布局就完成了,然后就是对滑动事件的拦截与分发,如果滑动到底部则拦截事件,并且根据滑动距离来计算 scrollTo 的移动距离,具体代码如下:


@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    if (recyclerView == null || recyclerView.getChildCount() == 0)
        return super.onInterceptTouchEvent(ev);
    if(isLoading) return true;//如果正在加载中则拦截滑动事件
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            lastDownY = (int) ev.getY();
            break;
        case MotionEvent.ACTION_MOVE:
            int lastPosition = -1;
            //以下代码用于获取当前 RecyclerView 中显示的最后一个 position
            RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
            if (layoutManager instanceof GridLayoutManager) {
                lastPosition = ((GridLayoutManager) layoutManager).findLastVisibleItemPosition();
            } else if (layoutManager instanceof LinearLayoutManager) {
                lastPosition = ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition();
            } else if (layoutManager instanceof StaggeredGridLayoutManager) {
                int[] lastPositions = new int[((StaggeredGridLayoutManager) layoutManager).getSpanCount()];
                ((StaggeredGridLayoutManager) layoutManager).findLastVisibleItemPositions(lastPositions);
                lastPosition = findMax(lastPositions);
            }

            int offerY = (int) ev.getY() - lastDownY;
            if (offerY < 0) {
              //如果正在上拉
                View lastView = recyclerView.getChildAt(recyclerView.getChildCount() - 1);
                if (lastView != null && lastView.getBottom() + footViewHeight >= getHeight() && lastPosition == recyclerView.getLayoutManager().getItemCount() - 1) {
                  //如果滑动到最底部则拦截事件并设置标志位
                    canScroll = true;
                    return true;
                } else {
                    canScroll = false;
                }
            } else {
                canScroll = false;
            }
            break;
    }
    return super.onInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent ev) {
    if (recyclerView == null || recyclerView.getChildCount() == 0)
        return super.onInterceptTouchEvent(ev);
    int offerY;
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            lastDownY = (int) ev.getY();
            break;
        case MotionEvent.ACTION_MOVE:
            if (canScroll) {
                offerY = (int) ev.getY() - lastDownY;
                lastOfferY = offerY;
                if(footView.getVisibility() == GONE) footView.setVisibility(VISIBLE);
                //将 PullToRefreshRecyclerView 内部跟着手指向上移动,移动距离为手指滑动距离的一半
                scrollTo(getScrollX(), -offerY / 2);
                imgArrow.setVisibility(VISIBLE);
                if (Math.abs(offerY) / 2 < footViewHeight) {
                    progressBar.setVisibility(GONE);
                    tvLoadTag.setText("上拉加载数据");
                    if (!arrowIsTop) {
                        imgArrow.startAnimation(topAnimation);
                        arrowIsTop = true;
                    }
                    canLoad = false;
                } else {
                    progressBar.setVisibility(GONE);
                    tvLoadTag.setText("松手加载更多");
                    if (arrowIsTop) {
                        imgArrow.startAnimation(bottomAnimation);
                        arrowIsTop = false;
                    }
                    canLoad = true;
                }
            }
            break;
        case MotionEvent.ACTION_UP:
            if (canScroll) {
                if (!canLoad) {
                  //手指松开后如果滑动距离小于设定距离则回到初始状态
                    scrollTo(getScrollX(), 0);
                } else {
                  //如果滑动距离大于设定距离则加载数据并回弹到加载状态
                    mScroller.startScroll(getScrollX(), getScrollY(), getScrollX(), -(Math.abs(lastOfferY) / 2 - footViewHeight), 500);
                    lastOfferY = 0;
                    loadData();
                }
                canScroll = false;
            }
            break;
    }
    return super.onTouchEvent(ev);
}

以上便是事件的拦截与分发,另外需要注意的是:如果与 SwipeRefreshLayout 结合使用会出现一个小问题,就是当处于上拉加载状态时如果下拉可能会出现滑动冲突,当然了也有解决方案,自定义的这个 PullToRefreshRecyclerView 类实现了 SwipeRefreshLayout.OnChildScrollUpCallback 接口,当 SwipeRefreshLayout 判断当前 View 是否处于可下拉刷新状态时会首先使用这个接口来判断,我这里做了相应的方法,使用时只需要将 PullToRefreshRecyclerView 对象传给 SwipeRefreshLayout.OnChildScrollUpCallback 接口即可,如下:

swipeRefresh.setOnChildScrollUpCallback(pullToRefreshRecyclerView);

下面放上 PullToRefreshRecyclerView 源码:

package com.zhangke.widget;

import android.content.Context;
import android.support.annotation.Nullable;
import android.support.v4.view.ViewPager;
import android.support.v4.widget.SwipeRefreshLayout;
import android.support.v7.widget.GridLayoutManager;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.StaggeredGridLayoutManager;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewParent;
import android.view.animation.Animation;
import android.view.animation.RotateAnimation;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.Scroller;
import android.widget.TextView;

import com.seagetech.ptduser.test.R;


/**
 * Created by 张可 on 2017/7/5.
 */

public class PullToRefreshRecyclerView extends FrameLayout implements SwipeRefreshLayout.OnChildScrollUpCallback {

    private static final String TAG = "PullToRefreshRecycler";

    private View rootView;
    private RecyclerView recyclerView;
    private View footView;
    private ProgressBar progressBar;
    private TextView tvLoadTag;
    private ImageView imgArrow;

    private int footViewHeight = 100;

    private int lastDownY;

    private boolean canScroll = false;
    private boolean canLoad = false;
    private boolean isLoading = false;//是否正在加载,正在加载时拦截滑动事件
    /**
     * 箭头方向是否向上
     */
    private boolean arrowIsTop = true;

    private RotateAnimation bottomAnimation;//箭头由上到下的动画
    private RotateAnimation topAnimation;//箭头由下到上的动画

    private Scroller mScroller;

    private OnPullToBottomListener onPullToBottomListener;

    private int lastOfferY = 0;

    public PullToRefreshRecyclerView(Context context) {
        super(context);
        init();
    }

    public PullToRefreshRecyclerView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    private void init() {
        inflate(getContext(), R.layout.view_pull_to_refresh_recycler, this);

        rootView = findViewById(R.id.root_view);

        recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_view);
        footView = findViewById(R.id.footer_view);
        progressBar = (ProgressBar) findViewById(R.id.progress);
        tvLoadTag = (TextView) findViewById(R.id.tv_load_tag);
        imgArrow = (ImageView) findViewById(R.id.img_arrow);

        footViewHeight = dip2px(getContext(), 80);

        bottomAnimation = new RotateAnimation(0, 180, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
        bottomAnimation.setDuration(200);
        bottomAnimation.setFillAfter(true);

        topAnimation = new RotateAnimation(180, 0, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
        topAnimation.setDuration(200);
        topAnimation.setFillAfter(true);

        mScroller = new Scroller(getContext());
    }

    public void setLayoutManager(RecyclerView.LayoutManager layout) {
        recyclerView.setLayoutManager(layout);
    }

    public void setAdapter(RecyclerView.Adapter adapter) {
        recyclerView.setAdapter(adapter);
        adapter.notifyDataSetChanged();
    }

    public RecyclerView getRecyclerView() {
        return recyclerView;
    }

    @Override
    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        final int height = getMeasuredHeight() + footViewHeight;
        setMeasuredDimension(getMeasuredWidth(), height);

        LayoutParams rootLP = (LayoutParams) rootView.getLayoutParams();
        rootLP.height = height;
        rootView.setLayoutParams(rootLP);

        LayoutParams recyclerLp = (LayoutParams) recyclerView.getLayoutParams();
        recyclerLp.height = height - footViewHeight;
        recyclerView.setLayoutParams(recyclerLp);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (recyclerView == null || recyclerView.getChildCount() == 0)
            return super.onInterceptTouchEvent(ev);
        if(isLoading) return true;
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastDownY = (int) ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                int lastPosition = -1;

                RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
                if (layoutManager instanceof GridLayoutManager) {
                    lastPosition = ((GridLayoutManager) layoutManager).findLastVisibleItemPosition();
                } else if (layoutManager instanceof LinearLayoutManager) {
                    lastPosition = ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition();
                } else if (layoutManager instanceof StaggeredGridLayoutManager) {
                    int[] lastPositions = new int[((StaggeredGridLayoutManager) layoutManager).getSpanCount()];
                    ((StaggeredGridLayoutManager) layoutManager).findLastVisibleItemPositions(lastPositions);
                    lastPosition = findMax(lastPositions);
                }

                int offerY = (int) ev.getY() - lastDownY;
                if (offerY < 0) {
                    View lastView = recyclerView.getChildAt(recyclerView.getChildCount() - 1);
                    if (lastView != null && lastView.getBottom() + footViewHeight >= getHeight() && lastPosition == recyclerView.getLayoutManager().getItemCount() - 1) {
                        canScroll = true;
                        return true;
                    } else {
                        canScroll = false;
                    }
                } else {
                    canScroll = false;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        if (recyclerView == null || recyclerView.getChildCount() == 0)
            return super.onInterceptTouchEvent(ev);
        int offerY;
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastDownY = (int) ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                if (canScroll) {
                    offerY = (int) ev.getY() - lastDownY;
                    lastOfferY = offerY;
                    if(footView.getVisibility() == GONE) footView.setVisibility(VISIBLE);
                    scrollTo(getScrollX(), -offerY / 2);
                    imgArrow.setVisibility(VISIBLE);
                    if (Math.abs(offerY) / 2 < footViewHeight) {
                        progressBar.setVisibility(GONE);
                        tvLoadTag.setText("上拉加载数据");
                        if (!arrowIsTop) {
                            imgArrow.startAnimation(topAnimation);
                            arrowIsTop = true;
                        }
                        canLoad = false;
                    } else {
                        progressBar.setVisibility(GONE);
                        tvLoadTag.setText("松手加载更多");
                        if (arrowIsTop) {
                            imgArrow.startAnimation(bottomAnimation);
                            arrowIsTop = false;
                        }
                        canLoad = true;
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                if (canScroll) {
                    if (!canLoad) {
                        scrollTo(getScrollX(), 0);
                    } else {
                        mScroller.startScroll(getScrollX(), getScrollY(), getScrollX(), -(Math.abs(lastOfferY) / 2 - footViewHeight), 500);
                        lastOfferY = 0;
                        loadData();
                    }
                    canScroll = false;
                }
                break;
        }
        return super.onTouchEvent(ev);
    }

    @Override
    public boolean canChildScrollUp(SwipeRefreshLayout parent, @Nullable View child) {
        if(recyclerView.getChildCount() > 0 && recyclerView.getChildAt(0).getTop() < recyclerView.getPaddingTop()){
            Log.e(TAG, "canChildScrollUp return true");
            return true;
        }
        Log.e(TAG, "canChildScrollUp return false");
        return false;
    }

    private void loadData() {
        isLoading = true;
        imgArrow.clearAnimation();
        imgArrow.setVisibility(GONE);
        progressBar.setVisibility(VISIBLE);
        tvLoadTag.setText("正在加载...");
        if (this.onPullToBottomListener != null) {
            postDelayed(new Runnable() {
                @Override
                public void run() {
                    PullToRefreshRecyclerView.this.onPullToBottomListener.onPullToBottom();
                }
            }, 500);
        } else {
            progressBar.setVisibility(GONE);
            scrollTo(getScrollX(), 0);
        }
    }

    /**
     * 设置是否正在加载,一般来书,在加载完毕之后应该调用此方法
     *
     * @param loading
     */
    public void setLoading(final boolean loading) {
        isLoading = loading;
        if (!isLoading) {
            post(new Runnable() {
                @Override
                public void run() {
                    if (!loading) {
                        progressBar.setVisibility(GONE);
                        scrollTo(getScrollX(), 0);
                    }
                }
            });
        }
    }

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            invalidate();
        }
    }

    //找到数组中的最大值
    private int findMax(int[] lastPositions) {
        int max = lastPositions[0];
        for (int value : lastPositions) {
            if (value > max) {
                max = value;
            }
        }
        return max;
    }

    public void setOnPullToBottomListener(OnPullToBottomListener onPullToBottomListener) {
        this.onPullToBottomListener = onPullToBottomListener;
    }

    public interface OnPullToBottomListener {
        void onPullToBottom();
    }

    /**
     * 将dip或dp值转换为px值,保证尺寸大小不变
     *
     * @param dipValue
     * @param dipValue (DisplayMetrics类中属性density)
     * @return
     */
    public static int dip2px(Context context, float dipValue) {
        final float scale = context.getResources().getDisplayMetrics().density;
        return (int) (dipValue * scale + 0.5f);
    }
}

点次查看源码及资源文件

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

推荐阅读更多精彩内容