1 概述
最近在做项目的时候,需要实现列表的下拉刷新和上拉加载更多的功能,由于项目周期问题,下拉刷新就直接使用了系统提供的SwipeRefreshLayout类,但是SwipeRefreshLayout的实现效果真的是太low了而且无法达到视觉工程师的要求;上拉加载更多则通过在列表的最后添加一个提示上拉加载的item来实现,虽然实现效果达到了视觉工程师的要求但是会使列表的实现变得复杂。最近又被一个同学问起上面的功能有没有简单的实现方式,趁着现在闲暇就通过自定义View(RefreshLayout)实现了上面的功能。
2 RefreshLayout的使用
首先看一下实现效果:
下图是对上图中的下拉刷新和上拉加载更多的流程的概括:
上图中的列表是通过RecyclerView实现的,实现该列表的下拉刷新和上拉加载更多的功能是通过在RecyclerView之上嵌套我自定义的RefreshLayout实现的,使用RefreshLayout的代码如下:
布局activity_test_refresh.xml:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.cytmxk.customview.refresh.RefreshLayout
android:id="@+id/refreshlayout_test"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerview_test_refresh"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</com.cytmxk.customview.refresh.RefreshLayout>
</LinearLayout>
public class TestRefreshActivity extends AppCompatActivity implements RefreshLayout.OnRefreshStatusListener {
private RefreshLayout testRefreshLayout;
private RecyclerView testRefreshRV;
private MyAdapter adapter;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_test_refresh);
testRefreshLayout = (RefreshLayout) findViewById(R.id.refreshlayout_test);
testRefreshLayout.setPullDownPromptLayout(new SamplePullDownPromptLayout(this));
testRefreshLayout.setPullUpPromptLayout(new SamplePullUpPromptLayout(this));
testRefreshLayout.setSupportPullUp(true);
testRefreshLayout.setOnRefreshStatusListener(this);
testRefreshRV = (RecyclerView) findViewById(R.id.recyclerview_test_refresh);
testRefreshRV.setLayoutManager(new LinearLayoutManager(getApplicationContext(), LinearLayoutManager.VERTICAL, false));
adapter = new MyAdapter();
testRefreshRV.setAdapter(adapter);
}
@Override
public void onPullDownRefresh() {
testRefreshLayout.postDelayed(new Runnable() {
@Override
public void run() {
adapter.temp = "人间正道是沧桑";
adapter.notifyDataSetChanged();
testRefreshLayout.refreshFinish(0);
}
}, 2000);
}
@Override
public void onPullUpRefresh() {
testRefreshLayout.postDelayed(new Runnable() {
@Override
public void run() {
adapter.itemCount += 10;
adapter.notifyDataSetChanged();
testRefreshLayout.refreshFinish(1);
}
}, 2000);
}
public class MyAdapter extends RecyclerView.Adapter {
private String temp = "天若有情天亦老";
private int itemCount = 20;
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
return new MyViewHolder(new TextView(parent.getContext()));
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
((MyViewHolder)holder).update(temp + position);
}
@Override
public int getItemCount() {
return itemCount;
}
}
public class MyViewHolder extends RecyclerView.ViewHolder {
public MyViewHolder(View itemView) {
super(itemView);
}
public void update(String data) {
((TextView)itemView).setTextSize(30);
((TextView)itemView).setText(data);
}
}
}
可以看到通过RefreshLayout实现RecyclerView下拉刷新和上拉加载更多的功能是很简单的。
3 RefreshLayout的实现
首先通过下图理解RefreshLayout的层次结构:
可以看到RefreshLayout是由三部分组成的(下拉刷新提示项、列表和上拉加载更多提示项)并且这三部分是垂直的线性布局,因此RefreshLayout直接就继承LinearLayout;上图是app运行的初始状态,手机屏幕默认只会显示列表部分,下拉刷新提示项和上拉加载更多提示项部分超出了屏幕的显示区域,因此在RefreshLayout的onMeasure回调方法中我会将下拉刷新提示项部分向上移动该部分的高度,代码如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (null != this.pullDownPromptLayout) {
measureChildWithMargins(this.pullDownPromptLayout, widthMeasureSpec, 0, heightMeasureSpec, 0);
MarginLayoutParams layoutParams = (MarginLayoutParams) this.pullDownPromptLayout.getLayoutParams();
layoutParams.topMargin = -this.pullDownPromptLayout.getMeasuredHeight();
RELEASE_TO_REFRESH_DOWN_HEIGHT = this.pullDownPromptLayout.getHeight();
}
if (null != this.pullUpPromptLayout) {
measureChildWithMargins(this.pullUpPromptLayout, widthMeasureSpec, 0, heightMeasureSpec, 0);
RELEASE_TO_REFRESH_UP_HEIGHT = this.pullUpPromptLayout.getMeasuredHeight();
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
上面代码中pullDownPromptLayout代表下拉刷新提示项,pullUpPromptLayout代表上拉加载更多提示项,RELEASE_TO_REFRESH_DOWN_HEIGHT代表下拉刷新时可以释放刷新的最低高度,RELEASE_TO_REFRESH_UP_HEIGHT代表上拉加载更多时可以释放加载的最低高度,代码很简单不在赘叙了。
上面完成了RefreshLayout的布局,接下来就是要完成RefreshLayout的滑动,这就会涉及到事件传递和处理流程,大家可以参考Android中的事件传递与事件处理机制。
当手指滑动屏幕时,产生的滑动事件要么传递给RefreshLayout处理,要么传递给RefreshLayout中的列表处理,根据图2中可以得出在如下4种情况下滑动事件应该传递给RefreshLayout处理(即对滑动事件进行拦截),反之传递给RefreshLayout中的列表处理:
1> 当app处于图2中第1幅图的状态、RefreshLayout中的列表不能向下滑动并且手指将要向下滑动
2> 当app处于图2中第4幅图的状态并且手指将要向上滑动
3> 当app处于图2中第6幅图的状态、RefreshLayout中的列表不能向上滑动并且手指将要向上滑动
4> 当app处于图2中第9幅图的状态并且手指将要向下滑动
滑动事件是从外层向内层传递的,即滑动事件会先传递给RefreshLayout,如果RefreshLayout不拦截,就会传递给RefreshLayout中的列表,RefreshLayout继承至LinearLayout而LinearLayout默认不会拦截滑动事件,因此要想将滑动事件传递给RefreshLayout就必须重写onInterceptTouchEvent方法对滑动事件进行拦截,代码如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int action = ev.getAction();
float y = ev.getY();
switch (action) {
case MotionEvent.ACTION_DOWN: {
lastY = lastYIntercept = y;
intercepted = false;
break;
}
case MotionEvent.ACTION_MOVE: {
float offsetY = y - lastYIntercept;
if (null == onRefreshStatusListener) {
intercepted = false;
break;
}
if (0 == getScrollY()) {
intercepted = (offsetY >= 0 && !childScrollView.canScrollVertically(-1) && null!= this.pullDownPromptLayout && supportPullDown)
|| (offsetY < 0 && !childScrollView.canScrollVertically(1) && null != this.pullUpPromptLayout && supportPullUp);
} else if (getScrollY() < 0) {
intercepted = (offsetY < 0);
} else if (getScrollY() > 0) {
intercepted = (offsetY > 0);
}
break;
}
case MotionEvent.ACTION_UP: {
intercepted = false;
break;
}
}
lastYIntercept = y;
return intercepted;
}
上面代码中childScrollView代表RefreshLayout中的列表,在MotionEvent.ACTION_MOVE的case中,getScrollY()等于0代表RefreshLayout中的内容没有发生滑动(即当前app处于图2中第1、6幅图的状态),getScrollY()小于0代表RefreshLayout中的内容向下发生了滑动(即当前app处于图2中第2、3、4幅图的状态),getScrollY()大于0代表RefreshLayout中的内容向上发生了滑动(即当前app处于图2中第7、8、9幅图的状态);offsetY大于等于0表示手指向下滑动,反之手指向上滑动;childScrollView.canScrollVertically(-1)为true代表childScrollView可以向下滑动,反之不行,childScrollView.canScrollVertically(1)为true代表childScrollView可以向上滑动,反之不行;再来看一下上面的代码,其实就是当满足上面提到的滑动事件传递给RefreshLayout处理的4种情况的某一条,就对滑动事件进行拦截交由RefreshLayout处理,否则就传递给RefreshLayout中的列表处理。
上面完成了对滑动事件的传递,下面就来看看滑动事件是如何处理的,RefreshLayout继承至LinearLayout而LinearLayout默认不会处理滑动事件,因此要处理滑动事件就必须重写onTouchEvent方法对滑动事件进行处理,代码如下:
// RefreshLayout中当前的刷新类型,要么是下拉刷新(0),要么是上拉刷新(1)
private int currentRefreshType = 0;
private float lastY;
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
float y = event.getY();
switch (action) {
case MotionEvent.ACTION_DOWN: {
break;
}
case MotionEvent.ACTION_MOVE: {
float offsetY = (y - lastY) / 2;
if (null == onRefreshStatusListener) {
break;
}
if (0 == getScrollY()) {
if (offsetY >= 0) {
if (childScrollView.canScrollVertically(-1)) {
childScrollView.scrollBy(0, (int) -offsetY);
} else {
scrollBy(0, (int) -offsetY);
currentRefreshType = 0;
}
} else {
if (childScrollView.canScrollVertically(1)) {
childScrollView.scrollBy(0, (int) -offsetY);
} else {
scrollBy(0, (int) -offsetY);
currentRefreshType = 1;
}
}
} else if (getScrollY() < 0) {
if (offsetY >= 0) {
scrollBy(0, (int) -offsetY);
tryChangeRefreshStatus();
} else {
if ((getScrollY() - offsetY) > 0) {
scrollTo(0, 0);
if (childScrollView.canScrollVertically(-1)) {
childScrollView.scrollBy(0, (int) (getScrollY() - offsetY));
}
} else {
scrollBy(0, (int) -offsetY);
tryChangeRefreshStatus();
}
}
} else if (getScrollY() > 0) {
if (offsetY >= 0) {
if ((getScrollY() - offsetY) > 0) {
scrollBy(0, (int) -offsetY);
tryChangeRefreshStatus();
} else {
scrollTo(0, 0);
if (childScrollView.canScrollVertically(1)) {
childScrollView.scrollBy(0, (int) (getScrollY() - offsetY));
}
}
} else {
scrollBy(0, (int) -offsetY);
tryChangeRefreshStatus();
}
}
break;
}
case MotionEvent.ACTION_UP: {
tryEnterRefreshStatus();
break;
}
}
lastY = y;
return true;
}
处理滑动事件的过程也就是实现滑动的过程,当一个事件序列(关于事件序列大家可以参考Android中的事件传递与事件处理机制)满足了上面提到的4种情况的一种,整个事件序列就都会被传递给上面代码中的onTouchEvent方法处理,上面代码中用到的scrollTo和scrollBy方法大家可以参考Android中实现滑动效果。
上面代码中主要针对ACTION_MOVE和ACTION_UP类型的滑动事件进行处理,因为ACTION_DOWN类型的滑动事件没有拦截
1> 对于ACTION_UP类型的滑动事件,代表手指离开屏幕,此时当滑动的距离低于最低高度时就会关闭下拉刷新提示项或者上拉加载更多提示项,否则进入加载状态,tryEnterRefreshStatus方法源码:
private void tryEnterRefreshStatus() {
int currentOffset = Math.abs(getScrollY());
switch (currentRefreshType) {
case 0: {
if (currentOffset >= RELEASE_TO_REFRESH_DOWN_HEIGHT) {
this.pullDownIRPL.changeRefreshStatus(IRefreshPromptLayout.RefreshStatus.REFRESHING);
if (null != onRefreshStatusListener) {
onRefreshStatusListener.onPullDownRefresh();
}
scroller.startScroll(0, getScrollY(), 0, -getScrollY() - RELEASE_TO_REFRESH_DOWN_HEIGHT);
} else {
scroller.startScroll(0, getScrollY(), 0, -getScrollY());
}
invalidate();
break;
}
case 1: {
if (currentOffset >= RELEASE_TO_REFRESH_UP_HEIGHT) {
this.pullUpIPRL.changeRefreshStatus(IRefreshPromptLayout.RefreshStatus.REFRESHING);
if (null != onRefreshStatusListener) {
onRefreshStatusListener.onPullUpRefresh();
}
scroller.startScroll(0, getScrollY(), 0, -getScrollY() + RELEASE_TO_REFRESH_UP_HEIGHT);
} else {
scroller.startScroll(0, getScrollY(), 0, -getScrollY());
}
invalidate();
break;
}
}
}
@Override
public void computeScroll() {
super.computeScroll();
if (scroller.computeScrollOffset()) {
scrollTo(0, scroller.getCurrY());
invalidate();
}
}
上面代码中用到的scroller大家可以参考Android中实现滑动效果。
2> 对于ACTION_MOVE类型的滑动事件,代表手指在屏幕上移动,在ACTION_MOVE case中,首先通过getScrollY()、offsetY和childScrollView.canScrollVertically得出什么时候RefreshLayout的内容滑动,什么时候RefreshLayout中的列表(childScrollView)滑动,然后通过scrollBy或者scrollTo实现滑动,在滑动的时候,当滑动距离在最低距离上下波动时,下拉刷新提示项或者上拉加载更多提示项中的箭头和提示语句就会发生变化,该变化就是通过tryChangeRefreshStatus方法实现的:
// 代表下拉刷新提示项
private IRefreshPromptLayout pullDownIRPL= null;
private View pullDownPromptLayout = null;
// 代表上拉加载更多提示项
private IRefreshPromptLayout pullUpIPRL = null;
private View pullUpPromptLayout = null;
public void setPullDownPromptLayout(IRefreshPromptLayout iRefreshPromptLayout) {
if (null == iRefreshPromptLayout) {
return;
}
if (null != this.pullDownPromptLayout) {
this.removeView(this.pullDownPromptLayout);
}
this.pullDownIRPL = iRefreshPromptLayout;
this.pullDownPromptLayout = iRefreshPromptLayout.getRefreshPromptLayout();
this.addView(this.pullDownPromptLayout, 0);
}
public void setPullUpPromptLayout(IRefreshPromptLayout iRefreshPromptLayout) {
if (null == iRefreshPromptLayout) {
return;
}
if (null != this.pullUpPromptLayout) {
this.removeView(this.pullUpPromptLayout);
}
this.pullUpIPRL = iRefreshPromptLayout;
this.pullUpPromptLayout = iRefreshPromptLayout.getRefreshPromptLayout();
this.addView(this.pullUpPromptLayout);
}
private void tryChangeRefreshStatus () {
int currentOffset = Math.abs(getScrollY());
switch (currentRefreshType) {
case 0: {
if (currentOffset >= RELEASE_TO_REFRESH_DOWN_HEIGHT) {
this.pullDownIRPL.changeRefreshStatus(IRefreshPromptLayout.RefreshStatus.RELEASE_TO_REFRESH);
} else {
this.pullDownIRPL.changeRefreshStatus(IRefreshPromptLayout.RefreshStatus.PULL_TO_REFRESH);
}
break;
}
case 1: {
if (currentOffset >= RELEASE_TO_REFRESH_UP_HEIGHT) {
this.pullUpIPRL.changeRefreshStatus(IRefreshPromptLayout.RefreshStatus.RELEASE_TO_REFRESH);
} else {
this.pullUpIPRL.changeRefreshStatus(IRefreshPromptLayout.RefreshStatus.PULL_TO_REFRESH);
}
break;
}
}
}
上面代码中的IRefreshPromptLayout是一个提供下拉刷新提示项或者上拉加载更多提示项的接口,如下所示:
public interface IRefreshPromptLayout {
enum RefreshStatus {
PULL_TO_REFRESH(0), // 表示下拉或者上拉可以刷新的状态
RELEASE_TO_REFRESH(1), // 表示释放立即刷新的状态
REFRESHING(2); // 代表正在刷新状态
private int value;
RefreshStatus(int value) {
this.value = value;
}
public int getValue() {
return value;
}
public static RefreshStatus valueOf(int value) {
RefreshStatus ret = PULL_TO_REFRESH;
for (RefreshStatus refreshStatus : RefreshStatus.values()) {
if (refreshStatus.getValue() == value) {
ret = refreshStatus;
break;
}
}
return ret;
}
}
void changeRefreshStatus(RefreshStatus refreshStatus);
View getRefreshPromptLayout();
}