之前,我们介绍了下拉刷新上拉加载RecyclerView的使用,那么现在,我们就来说一下这个下拉刷新是怎么实现的。
在开发过程中,我想了两种方案。一是使用LinearLayout嵌套头部、recyclerview、尾部的方式,如下图:
当recyclerview滑动到顶部时,移动LinearLayout露出头部; 当recyclerview滑动到底部时,移动LinearLayout露出尾部;著名的PullToRefreshListView采用的就是这种方式。
但后来,我放弃了这个方案,为什么呢?
因为多次尝试对recyclerview内部的fling事件进行处理,总是达不到自己想要的效果,我想要的是:
比如当前正在刷新,我向下fling RecyclerView,这时候RecyclerView向上滚动到顶部后,剩余速度继续露出RefreshHeader,而且我不喜欢每次都全露出来,而是要该露多少就露多少。简单地说,就是我想要给人一种刷新头部就是隶属于RecyclerView的、不存在断层的感觉。
恩,懂我意思吗?(刚刚怕表达不清楚,特地把同事叫来看他懂不懂)
总之,这种方案处理的效果我不满意!那怎么办呢?重来吧,删代码(心在滴血)。
于是有了第二种方案:给RecyclerView添加两个头部,分别是:用于造成下拉效果的辅助头部、刷新头部;添加两个尾部,分别是:加载尾部,用于造成上拉效果的辅助尾部。当滑动到顶部时,改变辅助头部的高度,把其他item往下推,造成下拉的感觉;上拉同理。
我还是再画个图吧:
在onLayout中,通过设置RecyclerView的margin,将头部和尾部偏移出屏幕; 辅助头部:初始高度为1px;当RecyclerView滑动到顶部时,通过改变高度,造成下拉效果; 辅助尾部:初始高度为1px;当RecyclerView滑动到底部时,通过改变高度,造成上拉的效果思路就是这样,但在实际的开发过程中,下拉还好,而上拉会遇到各种各样的问题,不过好在解决了这些问题后,实际的效果完美符合我的要求,所以WZMRecyclerView采用了这个方案进行实现。
接下来我们来依次介绍下拉和上拉,以及开发过程中遇到的问题。
其实下拉刷新是比较简单的,PullToRefreshRecyclerView继承于HeaderAndFooterRecyclerView,我们按顺序来一一介绍PullToRefreshRecyclerView中的几个主要方法:
首先介绍下全局变量,免得看代码的时候吃力:// 当前状态 private int mState = STATE_DEFAULT; // 初始 public final static int STATE_DEFAULT = 0; // 正在下拉 public final static int STATE_PULLING = 1; // 松手刷新 public final static int STATE_RELEASE_TO_REFRESH = 2; // 刷新中 public final static int STATE_REFRESHING = 3; // 下拉阻尼系数 private float mPullRatio = 0.5f; // 辅助头部 private View topView; // 刷新头部 private View mRefreshView; // 刷新头部的高度 private int mRefreshViewHeight = 0; // 触摸事件辅助,当RecyclerView滑动到顶部时,记录触摸事件的y轴坐标 private float mFirstY = 0; // 当前是否正在下拉 private boolean mPulling = false; // 是否可以下拉刷新 private boolean mRefreshEnable = true; // 回弹动画 private ValueAnimator valueAnimator; // 刷新监听 private OnRefreshListener mOnRefreshListener; // 刷新头部构造器 private RefreshHeaderCreator mRefreshHeaderCreator;在构造函数中初始化,获得默认的刷新头部:
private void init(Context context) { if (topView == null) { topView = new View(context); // 该view的高度不能为0,否则将无法判断是否已滑动到顶部 topView.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, 1)); // 设置默认LayoutManager setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)); // 初始化默认的刷新头部 mRefreshHeaderCreator = new DefaultRefreshHeaderCreator(); mRefreshView = mRefreshHeaderCreator.getRefreshView(context,this); } }在onLayout方法中,获得刷新头部的高度,并偏移RecyclerView:
/** * 在layout的时候,隐藏刷新头部 */ @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { super.onLayout(changed, l, t, r, b); if (mRefreshView != null && mRefreshViewHeight == 0) { mRefreshViewHeight = mRefreshView.getMeasuredHeight(); ViewGroup.MarginLayoutParams marginLayoutParams = (ViewGroup.MarginLayoutParams) getLayoutParams(); marginLayoutParams.setMargins(marginLayoutParams.leftMargin, marginLayoutParams.topMargin-mRefreshViewHeight-1, marginLayoutParams.rightMargin, marginLayoutParams.bottomMargin); setLayoutParams(marginLayoutParams); } }触摸事件:
@Override public boolean onTouchEvent(MotionEvent e) { // 若是不可以下拉 if (!mRefreshEnable) return super.onTouchEvent(e); // 若刷新头部为空,不处理 if (mRefreshView == null) return super.onTouchEvent(e); // 若回弹动画正在进行,不处理 if (valueAnimator != null && valueAnimator.isRunning()) return super.onTouchEvent(e); switch (e.getAction()) { case MotionEvent.ACTION_MOVE: if (!mPulling) { if (isTop()) { // 当listview滑动到最顶部时,记录当前y坐标 mFirstY = e.getRawY(); } // 若listview没有滑动到最顶部,不处理 else break; } float distance = (int) ((e.getRawY() - mFirstY)*mPullRatio); // 若向上滑动(此时刷新头部已隐藏),不处理 if (distance < 0) break; mPulling = true; // 若刷新中,距离需加上头部的高度 if (mState == STATE_REFRESHING) { distance += mRefreshViewHeight; } // 下拉 setState(distance); return true; case MotionEvent.ACTION_UP: // 回弹 replyPull(); break; } return super.onTouchEvent(e); }判断是否滑动到了顶部:
private boolean isTop() { return !ViewCompat.canScrollVertically(this, -1); }设置当前下拉状态:
private void setState(float distance) { // 刷新中,状态不变 if (mState == STATE_REFRESHING) { } else if (distance == 0) { mState = STATE_DEFAULT; } // 松手刷新 else if (distance >= mRefreshViewHeight) { int lastState = mState; mState = STATE_RELEASE_TO_REFRESH; if (mRefreshHeaderCreator != null) if (!mRefreshHeaderCreator.onReleaseToRefresh(distance,lastState)) return; } // 正在拖动 else if (distance < mRefreshViewHeight) { int lastState = mState; mState = STATE_PULLING; if (mRefreshHeaderCreator != null) if (!mRefreshHeaderCreator.onStartPull(distance,lastState)) return; } // 开始下拉 startPull(distance); }
这里可以看到,当头部构造器的onStartPull和onReleaseToRefresh返回false时,便不再下拉,其实这里也是为了应对类似“超过多少就不再下拉了”这种需求。
改变辅助头部的高度,造成下拉的效果:private void startPull(float distance) { // 辅助头部的高度不能为0,否则将无法判断是否已滑动到顶部 if (distance < 1) distance = 1; if (topView != null) { LayoutParams layoutParams = (LayoutParams) topView.getLayoutParams(); layoutParams.height = (int) distance; topView.setLayoutParams(layoutParams); } }松手回弹,在这个方法中,我们需要判断是直接刷新,还是直接回弹到原来位置:
private void replyPull() { mPulling = false; // 回弹位置 float destinationY = 0; // 判断当前状态 // 若是刷新中,回弹 if (mState == STATE_REFRESHING) { destinationY = mRefreshViewHeight; } // 若是松手刷新,刷新,回弹 else if (mState == STATE_RELEASE_TO_REFRESH) { // 改变状态 mState = STATE_REFRESHING; // 刷新 if (mRefreshHeaderCreator != null) mRefreshHeaderCreator.onStartRefreshing(); if (mOnRefreshListener != null) mOnRefreshListener.onStartRefreshing(); // 若在onStartRefreshing中调用了completeRefresh方法,将不会滚回初始位置,因此这里需加个判断 if (mState != STATE_REFRESHING) return; destinationY = mRefreshViewHeight; } else if (mState == STATE_DEFAULT || mState == STATE_PULLING) { mState = STATE_DEFAULT; } LayoutParams layoutParams = (RecyclerView.LayoutParams) topView.getLayoutParams(); float distance = layoutParams.height; if (distance <= 0) return; valueAnimator = ObjectAnimator.ofFloat(distance, destinationY).setDuration((long) (distance * 0.5)); valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float nowDistance = (float) animation.getAnimatedValue(); startPull(nowDistance); } }); valueAnimator.start(); }完成刷新:
public void completeRefresh() { if (mRefreshHeaderCreator != null) mRefreshHeaderCreator.onStopRefresh(); mState = STATE_DEFAULT; replyPull(); mRealAdapter.notifyDataSetChanged(); }在设置适配器的时候,添加辅助头部和刷新头部:
@Override public void setAdapter(Adapter adapter) { super.setAdapter(adapter); if (mRefreshView != null) { addHeaderView(topView); addHeaderView(mRefreshView); } }设置自定义的头部:
public void setRefreshViewCreator(RefreshHeaderCreator refreshHeaderCreator) { this.mRefreshHeaderCreator = refreshHeaderCreator; mRefreshView = refreshHeaderCreator.getRefreshView(getContext(),this); // 若有适配器,添加到头部 if (mAdapter != null) { addHeaderView(topView); addHeaderView(mRefreshView); } mRealAdapter.notifyDataSetChanged(); }
以上就是PullToRefreshRecyclerView主要的几个方法了,介绍得算比较清楚吧,再加上代码中已经有注释了,就不再累赘了。核心就一句话:拦截触摸事件,改变辅助头部的高度。 就是这么easy~~~~
本来上拉加载我想单独用一篇文章来介绍的,但其实上拉加载的处理和下拉刷新的处理逻辑是一致的,因此在这里便一起介绍了吧,双飞更开心呦客官~~
咳咳,说正经的,上面我们说过上拉加载会遇到各种问题,具体有哪些呢?
我们知道偏移RecyclerView是在onLayout函数中,但是在这个时候,你是拿不到加载尾部的高度的,measure(0,0)都没用,为什么呢?因为这个时候还不到他出场的时候啊,你催他也没用。这时候你就会说了,那我getViewTreeObserver().addOnPreDrawListener呢?嘿嘿,我也试过了,这样的确可以拿到高度,但太晚了,已经来不及偏移了,他已经出现在屏幕中了。
滑动到底部时,继续上拉,改变辅助底部的高度造成上拉的效果,然后现实很骨感,你会发现(通过调试或打印)辅助底部的高度是在改变,但RecyclerView中的item并没有挤上去啊,根本就没有上拉的效果出现。
当你添加FooterView的时候,发现你添加的FooterView居然跑到刷新底部的下面去了,坑了个爹…..
以下是我的解决方法:
在开发下拉刷新的时候,我们并没有这个问题,很明显,因为我们的刷新头部其实是第一二个item,在onLayout的时候,肯定会去测量他的宽高(onMeasure方法在onLayout之前),所以我们可以拿到刷新头部的高度。这么一来的话,我们可以把加载尾部添加到头部中去,等得到了高度,我们再卸磨杀驴,把他remove掉,恩,就是这样。
这个问题我实在没想到什么好办法,因此用了最粗暴的方式:在改变高度后直接调用scrollToPosition滚动到最底部。这样做有什么后果呢?效率肯定是不高的,但为了效果,我可以忍….经过测试,StaggredLayoutManager不会有任何影响,效果溜溜哒。但是但是,LinearLayoutManager上拉时会出现卡顿的现象,这个怎么忍!当然GridLayoutManager也会卡顿,毕竟他是LinearLayoutManager的儿子啊,遗传病。为什么呢?因为LinearLayoutManager对item的layout和StaggredLayoutManager的是不一样的,既然StaggredLayoutManager没问题,那么我们用只有一列的StaggredLayoutManager替代LinearLayoutManager就是最粗暴的方法。当然,更好的方式是直接继承LayoutManager写一个自己的LinearLayoutManager,但由于时间和水平的限制,就……采用StaggredLayoutManager吧。这就是为什么我之前说使用PullToLoadRecyclerView的时候,要用WZMLinearLayout和WZMGridLayoutManager。
这个问题其实最好解决,继承HeaderAndFooterAdapter写一个PullToLoadAdapter就可以啦。
虽然解决方法比较坑爹,但不管黑猫还是白猫,能抓老鼠的就是好猫。当然,这么说有点过分了,所以在这里,希望有大牛有更好的方法,欢迎到github上提交您的代码,共同构建这个项目。
PullToLoadRecyclerView和PullToRefreshRecyclerView的代码逻辑其实基本一致,而PullToLoadAdapter的代码和HeaderAndFooterAdapter也比较像,因此这里就不再展开了,有兴趣的同学可以去github上把项目clone下来看看。
有没有遇到过这种情况,当你辛辛苦苦找到一个需要的库时,却发现他的UI居然不支持自定义!摔!在实际开发中,产品和设计怎么会允许你使用那个库默认的UI设计,这是基本不可能的事。因此,支持自定义的刷新头部和加载尾部是非常非常重要的事!!
之前在介绍使用方法时,我们就已经介绍了如何使用自定义的刷新头部和加载尾部,而通过上面的代码,你应该也已经理解了RefreshHeaderCreator和LoadFooterCreator的工作方式。
其实就是使用这两个抽象类,把刷新头部和加载尾部的UI与RecyclerView进行解耦,交给用户自己去实现,项目中的默认刷新头部和加载尾部就是很好的例子,相信你看完应该就知道怎么去构造自己的刷新头部和加载尾部了。
直接上DefaultRefreshHeaderCreator的代码:
public class DefaultRefreshHeaderCreator extends RefreshHeaderCreator { private View mRefreshView; private ImageView iv; private TextView tv; private int rotationDuration = 200; private int loadingDuration = 1000; private ValueAnimator ivAnim; @Override public boolean onStartPull(float distance,int lastState) { if (lastState == PullToRefreshRecyclerView.STATE_DEFAULT ) { iv.setImageResource(R.drawable.arrow_down); iv.setRotation(0f); tv.setText("下拉刷新"); } else if (lastState == PullToRefreshRecyclerView.STATE_RELEASE_TO_REFRESH) { startArrowAnim(0); tv.setText("下拉刷新"); } return true; } @Override public void onStopRefresh() { if (ivAnim != null) { ivAnim.cancel(); } } @Override public boolean onReleaseToRefresh(float distance,int lastState) { if (lastState == PullToRefreshRecyclerView.STATE_DEFAULT ) { iv.setImageResource(R.drawable.arrow_down); iv.setRotation(-180f); tv.setText("松手立即刷新"); } else if (lastState == PullToRefreshRecyclerView.STATE_PULLING) { startArrowAnim(-180f); tv.setText("松手立即刷新"); } return true; } @Override public void onStartRefreshing() { iv.setImageResource(R.drawable.loading); startLoadingAnim(); tv.setText("正在刷新..."); } @Override public View getRefreshView(Context context, RecyclerView recyclerView) { mRefreshView = LayoutInflater.from(context).inflate(R.layout.layout_ptr_ptl,recyclerView,false); iv = (ImageView) mRefreshView.findViewById(R.id.iv); tv = (TextView) mRefreshView.findViewById(R.id.tv); return mRefreshView; } private void startArrowAnim(float roration) { if (ivAnim != null) { ivAnim.cancel(); } float startRotation = iv.getRotation(); ivAnim = ObjectAnimator.ofFloat(startRotation,roration).setDuration(rotationDuration); ivAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { iv.setRotation((Float) animation.getAnimatedValue()); } }); ivAnim.start(); } private void startLoadingAnim() { if (ivAnim != null) { ivAnim.cancel(); } ivAnim = ObjectAnimator.ofFloat(0,360).setDuration(loadingDuration); ivAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { iv.setRotation((Float) animation.getAnimatedValue()); } }); ivAnim.setRepeatMode(ObjectAnimator.RESTART); ivAnim.setRepeatCount(ObjectAnimator.INFINITE); ivAnim.setInterpolator(new LinearInterpolator()); ivAnim.start(); } }
系不系很简单?
照例上两张用烂了的效果图:
源码地址:https://github.com/whichname/WZMRecyclerView