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();
}