授人以鱼不如授人以渔,虽然网上有很多这样的现成的组件,但是我们真的了解怎么去实现吗?这篇文章主要讲怎么一步一步的实现这个功能。
本文章详细介绍怎么从0开始实现一个支持上拉刷新下拉加载的recyclerview,这个0到什么地步呢?那就从打开as新建一个项目开始。
先看一波最后实现的效果图
源码地址
前言:在打开as新建项目前,先来构思一下整个思路
如果要实现上拉刷新下拉加载,那么就要在头部和底部添加headView和footView,如果是当作recyclerView的item添加到第一行和最后一行,那么针对一行显示两列item的就不适用了。这里我们就抛弃这个想法,换个方法实现。
利用三个独立view(这样也支持更换不同的headView和footView)来实现,headView ,recyclerView,footView,然后将headView和footView布局到屏幕外边,然后手机拖动的时候,再根据距离来慢慢移动到布局内部。如图:
现在我们考虑用那种方式来实现这个位置的移动。
方案1:利用 父布局的 scrollTo()/ scrollBy() 来实现。
方案2:利用子view的 setTranslationY() 来实现。
方案3:利用子view的 offsetTopAndBottom() 来实现。
方案4:利用子view的 layout() 来实现。
我用经验告诉你们,只有 方案4 是最好的实现。
方案1和方案2会有bug:当处于正在刷新或者加载状态的时候,这个时候你手指向下滑动,recyclerView却是向下滚动的。
方案3:当处于正在刷新或者加载的时候,recyclerView会有一部分处于屏幕外边,这个时候会挡住一部分item。
选了移动方案之后,那么我们根据什么来设置这个移动值呢?
方案1: 新的嵌套滚动机制
方案2:拦截触摸事件,自己计算偏移值
方案3:拦截触摸事件,并把触摸事件托管给GestureDetector
我在用经验告诉你们:这里三种方式都可以,难度也都差不多。
本文我选择了方案3
总结:本次项目实现,拦截触摸事件,并托管给GestureDetector,然后在scroll()回调方法中中重布局headView,recyclerView和footView。
下拉:只需要更改headView的top和bottom以及recyclerView的top
上拉:只需要更改footView的top和bottom以及recyclerView的bottom
正式开始
1.打开As新建一个项目
2.新建一个view类(SwipeRecycler.java)并继承ViewGroup
public class SwipeRecycler extends ViewGroup {
public SwipeRecycler(Context context) {
super(context);
}
public SwipeRecycler(Context context, AttributeSet attrs) {
super(context, attrs);
}
public SwipeRecycler(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
}
}
重写拦截方法
/**
* 最先被拦截,在传给子view之前会调用
* @param ev
* @return super传递给子view
* true/false 不再向下传递,并会调用{@link #onTouchEvent(MotionEvent event)}
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return super.onInterceptTouchEvent(ev);
}
//如果触摸事件没有被子view消耗完,会调用此方法
@Override
public boolean onTouchEvent(MotionEvent event) {
return super.onTouchEvent(event);
}
再做一些准备工作,整体代码如下
public class SwipeRecycler extends ViewGroup {
public SwipeRecycler(Context context) {
super(context);
init();
}
public SwipeRecycler(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public SwipeRecycler(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
//做一些初始化操作
private void init() {
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
}
/**
* 最先被拦截,在传给子view之前会调用
* @param ev
* @return super传递给子view
* true/false 不再向下传递,并会调用{@link #onTouchEvent(MotionEvent event)}
*/
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return super.onInterceptTouchEvent(ev);
}
//如果触摸事件没有被子view消耗完,会调用此方法
@Override
public boolean onTouchEvent(MotionEvent event) {
return super.onTouchEvent(event);
}
}
3.添加recyclerView库,默认是没有的
implementation 'com.android.support:recyclerview-v7:27.1.1'
4.创建headView,footView和recyclerView
//创建headView,注意LayoutParams参数
private Button getHeadView(){
ViewGroup.LayoutParams lp=new LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.WRAP_CONTENT);
Button head=new Button(getContext());
head.setText("下拉刷新");
head.setLayoutParams(lp);
return head;
}
//创建recyclerView,注意LayoutParams参数
private RecyclerView getRecyclerView(){
ViewGroup.LayoutParams lp=new LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.MATCH_PARENT);
RecyclerView rcv=new RecyclerView(getContext());
rcv.setLayoutParams(lp);
return rcv;
}
//创建footView,注意LayoutParams参数
private Button getFootView(){
ViewGroup.LayoutParams lp=new LayoutParams(LayoutParams.MATCH_PARENT,LayoutParams.WRAP_CONTENT);
Button foot=new Button(getContext());
foot.setText("上拉加载");
foot.setLayoutParams(lp);
return foot;
}
init()方法中将view添加到viewGroup中
//做一些初始化操作
private void init() {
//添加view
headView = getHeadView();
footView = getFootView();
recyclerView = getRecyclerView();
addView(headView);
addView(recyclerView);
addView(footView);
}
5.重写测量方法,不然view不会显示
//重写测量方法,不然view不会显示
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
final int childCount=getChildCount();
for (int i = 0; i < childCount; i++) {
final View childView=getChildAt(i);
measureChild(childView,widthMeasureSpec,heightMeasureSpec);
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
6.设置手势监听
实现GestureDetector.OnGestureListener
class SwipeRecycler extends ViewGroup implements GestureDetector.OnGestureListener
重写方法
@Override
public boolean onDown(MotionEvent e) {
return false;
}
@Override
public void onShowPress(MotionEvent e) {
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
return false;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
return false;
}
@Override
public void onLongPress(MotionEvent e) {
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
return false;
}
虽然很多,但是我们需要的只有这个
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY)
其他的什么意思,自行百度,这里就不过多的介绍了,不能失去了重点。
然后在init()设置监听
//设置手势监听器监听
gestureDetector=new GestureDetector(getContext(),this);
在onTouchEvent()中把触摸事件托管给手势监听。
//如果触摸事件没有被子view消耗完,会调用此方法
@Override
public boolean onTouchEvent(MotionEvent event) {
return gestureDetector.onTouchEvent(event);
}
注意:不能在拦截的时候托管,因为这样的话,你的子view就接收不到任何触摸事件,那样的话,你的recyclerview就不能滑动了。
7.布局子view
headView的布局四个位置应该是:
left:0(宽度和父view的宽度一致,左边为0)
top:-headView.getHeight()(上方应该是headView的高度负值)
right:getWidth()(宽度和父view的宽度一致,右边为headView或者父view的宽度)
bottom:0(下边应该紧挨着父view的上方)
footView的布局四个位置应该是:
left:0(宽度和父view的宽度一致,左边为0)
top:getHeight()(上方应该紧挨着父view的bottom)
right:getWidth()(宽度和父view的宽度一致,右边为footView或者父view的宽度)
bottom:getHeight()+footView.getHeight()(下边应该是父view的高度加上footview的高度)
recyclerView的布局四个位置应该是:
left:0(宽度和父view的宽度一致,左边为0)
top:0(上方应该紧挨着父view的top)
right:getWidth()(宽度和父view的宽度一致,右边为recyclerView或者父view的宽度)
bottom:getHeight()(上方应该紧挨着父view的bottom)
现在在layout()中开始布局
由于布局要考虑到padding值,并且布局的时候只能拿到测量高度,实际高度拿不到。所以完整代码如下
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (changed) {
for (int i = 0; i < getChildCount(); i++) {
View childView = getChildAt(i);
if (childView == headView) {//开始布局headView
childView.layout(getPaddingLeft(), -headView.getMeasuredHeight() + getPaddingTop(), r - l - getPaddingRight(), getPaddingTop());
} else if (childView == footView) {//开始布局footV
childView.layout(getPaddingLeft(), b - t - getPaddingBottom(), r - l - getPaddingRight(),
b - t + footView.getMeasuredHeight() - getPaddingBottom());
} else if (childView == recyclerView) {//开始布局recyclerView
childView.layout(getPaddingLeft(), getPaddingTop(), r - l - getPaddingRight(), b - t - getPaddingBottom());
} else {//其他的view,目前是没有的。
childView.layout(0, 0, 0, 0);
}
}
} else {
for (int i = 0; i < getChildCount(); i++) {
View childView = getChildAt(i);
childView.layout(childView.getLeft(), childView.getTop(), childView.getRight(), childView.getBottom());
}
}
}
目前为止,准备工作已经完毕,接下来就是处理触摸事件了。
8.处理拦截事件
这里发生以下情况下才产生拦截:
1.recyclerView划到了头部,并且继续下滑
2.recyclerView划到了底部,并且继续上拉
3.已经发生了下拉,这个时候滑动分为继续下拉和和上划复位
4.已经发生了上拉,这个时候滑动分为继续上拉和下拉复位
这里设置一个变量来保存几种状态
//0正常状态 1触发了下拉刷新 2触发了上拉加载 3正在进行下拉刷新 4正在进行上拉加载
private int pullStatus=0;
通过recyclerView.canScrollVertically()来判断是否滑动到了第一个item或者最后一个item
//-1表示检查是否可以下拉,返回true:还没划到第一个item
recyclerView.canScrollVertically(-1);
//1表示检查是否可以上拉,返回true:还没划到最后一个item
recyclerView.canScrollVertically(1);
开始处理拦截事件
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
//只有处于正常状态才有可能拦截
//一次完整的触摸,如果产生了拦截,就不会再走此方法
if (pullStatus == 0) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
oldTouchY = ev.getY();
break;
case MotionEvent.ACTION_MOVE:
//获取偏移值,offy>0 由上向下滑
float offy = ev.getY() - oldTouchY;
oldTouchY = ev.getY();
//向下划,而且recyclerView已经滑动到了第一个item
if (offy > 0 && !recyclerView.canScrollVertically(-1)) {
//设置状态为触发了下拉
pullStatus = 1;
//返回true,拦截这次事件
return true;
//向上划,而且recyclerView已经滑动到了最好一个item
} else if (offy < 0 && !recyclerView.canScrollVertically(1)) {
//设置状态为触发了上拉
pullStatus = 2;
//返回true,拦截这次事件
return true;
}
break;
}
}
return super.onInterceptTouchEvent(ev);
}
9.处理滑动
这时候就要用到手势监听器了,而且他已经处理好了滑动,只需要这个方法就可以了。
//用户滚动的时候 distanceY<0下拉距离否则上拉距离
int mixThreshold = 10;
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
//为了防止一次跳动太大,当只有距离小于50的时候才重新布局
if (Math.abs(distanceY) < 50) {
if (pullStatus == 1 || pullStatus == 3) {
if (distanceY < 0) {
//这里需要distanceY的相反值,distanceY / 2表示,手指移动100,实际布局只向下移动50
offsetTopOrBottom(-(distanceY / 2));
} else {
int t = recyclerView.getTop();
//由于复位的时候,有时候并不能检测到t是否等于0,当t 0) {
offsetTopOrBottom(-(distanceY / 2));
} else {
int t = recyclerView.getBottom();
//由于复位的时候,有时候并不能检测到t是否等于原来的高度,当getHeight() - t
/**
* 手指拖动的时候重新布局
*
* @param offY 向下或者向上的距离上次布局的距离
*/
private void offsetTopOrBottom(float offY) {
//当偏移量==0 ,不执行下面的操作
if (offY == 0) {
return;
}
int value = (int) offY;
//当处于下拉状态,布局recyclerView和headView
if (pullStatus == 1 || pullStatus == 3) {
int oldTop = recyclerView.getTop();
int newTop = oldTop + value;
recyclerView.layout(recyclerView.getLeft(), newTop, recyclerView.getRight(), recyclerView.getBottom());
headView.layout(headView.getLeft(), newTop - headView.getHeight(), headView.getRight(), newTop);
if (newTop > headView.getHeight()) {//newTop为下拉距离,当大于headView的高度,则达到了刷新条件
headView.setText("松手刷新");
} else {
headView.setText("下拉刷新");
}
} else if (pullStatus == 2 || pullStatus == 4) { //当处于上拉状态,布局recyclerView和footViewView
int oldBottopm = recyclerView.getBottom();
int newBottom = oldBottopm + value
recyclerView.layout(recyclerView.getLeft(), recyclerView.getTop(), recyclerView.getRight(), newBottom);
recyclerView.scrollBy(0, -value);
footView.layout(headView.getLeft(), newBottom, headView.getRight(), newBottom + footView.getHeight());
if (getHeight() - newBottom > footView.getHeight()) {//getHeight() - newBottom为上拉距离,当大于headView的高度,则达到了加载条件
footView.setText("松手加载");
} else {
footView.setText("下拉加载");
}
}
}
写到这里先看下效果吧
先写一个设置适配器的方法给外部调用
public void setAdapter(RecyclerView.Adapter adapter){
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
recyclerView.setAdapter(adapter);
}
然后在Acticity里面引入这个view,然后写个适配器来测试。
测试activity:
public class MainActivity extends AppCompatActivity {
private SwipeRecycler swipeRecycler;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
swipeRecycler = findViewById(R.id.rcv);
swipeRecycler.setAdapter(new MyAdapter());
}
class MyAdapter extends RecyclerView.Adapter {
@NonNull
@Override
public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
TextView tv = new TextView(parent.getContext());
tv.setPadding(50, 50, 50, 50);
return new Holder(tv);
}
@Override
public void onBindViewHolder(@NonNull Holder holder, int position) {
TextView tv = (TextView) holder.itemView;
tv.setText(position + "");
}
@Override
public int getItemCount() {
return 20;
}
class Holder extends RecyclerView.ViewHolder {
public Holder(View itemView) {
super(itemView);
}
}
}
}
这个时候你松开手指,界面是不会动的,接下来就要处理手指松开。
10.处理手指松开
首先在onTouchEvent中监听手指松开
@Override
public boolean onTouchEvent(MotionEvent event) {
if (pullStatus == 0) {
return super.onTouchEvent(event);
}
//手指松开
if (event.getAction() == MotionEvent.ACTION_UP) {
stop();
}
return gestureDetector.onTouchEvent(event);
}
用动画处理手指松开后的重布局
private void stop() {
//处于正常状态
if (pullStatus == 0) {
return;
}
//检测recyclerView是否发生了滑动
if (recyclerView.getTop() == 0 && recyclerView.getBottom() == getHeight()) {
pullStatus = 0;
return;
}
//用属性动画来处理复位
int start = 0;
int end = 0;
if (recyclerView.getTop() == 0) {//上拉
//上拉的距离大于footView的高度,这个时候就达到加载的条件
if (getHeight() - recyclerView.getBottom() > footView.getHeight()) {
pullStatus = 4;//设置当前状态是处于加载
}
start = recyclerView.getBottom();
if (pullStatus == 4) {
footView.setText("正在加载...");
//此时bottom刚好显示出footView
end = getHeight() - footView.getHeight();
} else {
//此时恢复到初始界面
end = getHeight();
}
} else {//下拉
if (recyclerView.getTop() > headView.getHeight()) {
pullStatus = 3;//设置当前状态是处于刷新
}
start = recyclerView.getTop();
if (pullStatus == 3) {
headView.setText("正在刷新...");
end = headView.getHeight();
} else {
end = 0;
}
}
ValueAnimator anim = ValueAnimator.ofInt(start, end);
anim.setDuration(200);
anim.setInterpolator(new LinearInterpolator());
final int finalEnd = end;
anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int vaule = (int) animation.getAnimatedValue();
offsetTopOrBottomBy(vaule);
if (vaule == finalEnd) {
//动画结束的时候,不是刷新或者加载状态,设置为正常状态
if (pullStatus == 1 || pullStatus == 2) {
pullStatus = 0;
}
}
}
});
anim.start();
}
//复位的时候,重新布局
private void offsetTopOrBottomBy(int value) {
if (pullStatus == 1 || pullStatus == 3) {//上拉复位重布局
recyclerView.layout(recyclerView.getLeft(), value, recyclerView.getRight(), recyclerView.getBottom());
headView.layout(headView.getLeft(), value - headView.getHeight(), headView.getRight(), value);
} else if (pullStatus == 2 || pullStatus == 4) {//下拉复位重布局
recyclerView.layout(recyclerView.getLeft(), recyclerView.getTop(), recyclerView.getRight(), value);
footView.layout(footView.getLeft(), value, footView.getRight(), value + footView.getHeight());
}
}
这个时候再写一个对外停止正在刷新或者正在加载的接口
public void stopRefreshOrLoadMore() {
//如果是刷新,停止的时候设置为1,不然系统认为仍是刷新状态,不会执行其他操作
if (pullStatus==3){
pullStatus=1;
}else if (pullStatus==4){
//如果是加载,停止的时候设置为2,不然系统认为仍是加载状态,不会执行其他操作
pullStatus=2;
}
stop();
}
最后:还有很多细节需要自己处理,比如设置刷新时候的监听,加载时候的监听,以及做更华丽的headView/footView等等。
源码地址
目前为止所有基本工作已经完成,看一波最后的效果图