在使用列表组件展示数据的时候,更新数据的交互曾经是一个没有定论的问题,有留一个刷新按钮的,有按时自动刷新的,还有根本不刷新的。但是随着移动平台的普及,移动应用的用户群越来越大,数据刷新的交互就慢慢固定下来了,而在各种交互方式中脱颖而出的一种就是人们熟悉的“下拉刷新”。
下拉刷新是个很简单也很友好的交互方式,列表滚动到顶端后可以强制下拉一段,拉出来的多余部分会显示一些提示,随后用户松手让列表回弹,刷新即刻开始。这个交互方式来自iPhone上的一款应用Tweetie,随后被大量用到iOS平台上的各种应用中,其方便快捷,所见所得的交互方式迅速在用户中确立了几乎不可动摇的地位,时至今日依然长盛不衰。
如此好用的交互手段,安卓应用当然应该试着引进,事实也是如此,随着下拉刷新模式的普及,安卓开发者们也找到了属于自己的解决方案,大量的第三方开源库或是拓展原有组件或是自己重新定义,最终都实现了下拉刷新的功能。
如果是商业开发,那么直接使用现成的第三方库是最好的选择,现在的下拉刷新组件已经很成熟了,比如XListView系列,直接拓展原生ListView,代码简单易读,BUG也很少,大部分情况下可以满足需求。
但如果一方面想清楚地了解下拉刷新功能是如何实现的,另一方面也希望当有特殊需求出现的时候能应付得来,那么自己实现一遍下拉刷新是非常不错的学习方式。
下拉刷新一般是针对列表的,那么用最简单直接的思路,自定义一个列表组件是否可行呢?答案是肯定的,有名的XListView系列就是用的这种思路,自定义实现一个ListView来获得下拉刷新的功能。
顺着这个思路往下,我们首先要考虑实现功能的手段。所谓下拉刷新,其实就是要求组件能捕捉到用户的动作,如果列表已经到顶了,用户依然下拉,则判断这是刷新的信号,等到用户松手列表回弹,即可开始刷新流程。
那么为了实现这些需要些什么?ListView是肯定需要的,还需要一个Header,显示在用户强行下拉的空白处,整体逻辑在View的onTouchEvent方法中编写,这样也不必实现更多其它的接口,保留下来方便别的需求。
一步步来,首先是Header,虽然我们只需要一个很简单的Header,但它不能被直接写到XML文件中,使用过ListView后都应该知道其Header只能通过代码设置。针对这个问题XListView采用的解决方案是自定义View,自定义一个HeaderView然后在ListView的初始化过程中就设置好Header。
因此先声明一个Header类
public class HeaderView extends LinearLayout {
private Context context;
private LinearLayout container;
private TextView tvIndicator;
private int headerState = RefreshListView.STATE_IDLE;
public HeaderView(Context context) {
super(context);
this.context = context;
initView();
}
public HeaderView(Context context,
@Nullable AttributeSet attrs) {
super(context, attrs);
this.context = context;
initView();
}
public HeaderView(Context context,
@Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.context = context;
initView();
}
private void initView() {
container = new LinearLayout(context);
LinearLayout.LayoutParams param = new LinearLayout.LayoutParams(ViewGroup.LayoutParams
.MATCH_PARENT, 0);
addView(container, param);
setGravity(Gravity.BOTTOM);
tvIndicator = new TextView(context);
param = new LinearLayout.LayoutParams(ViewGroup
.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
tvIndicator.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16);
tvIndicator.setText("下拉刷新");
tvIndicator.setGravity(Gravity.CENTER);
container.addView(tvIndicator, param);
}
void setState(int state) {
if(headerState != state) {
if(state == RefreshListView.STATE_REFRESH) {
tvIndicator.setTextColor(0xFF2323EE);
} else {
tvIndicator.setTextColor(0xFF999999);
}
switch (state) {
case RefreshListView.STATE_IDLE:
tvIndicator.setText("下拉刷新");
break;
case RefreshListView.STATE_READY:
if(headerState != RefreshListView.STATE_READY) {
tvIndicator.setText("松开刷新数据");
}
break;
case RefreshListView.STATE_REFRESH:
tvIndicator.setText("正在加载...");
break;
default:
break;
}
headerState = state;
}
}
void setVisiableHeight(int height) {
if(height < 0) {
height = 0;
}
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) container
.getLayoutParams();
params.height = height;
container.setLayoutParams(params);
}
int getVisiableHeight() {
return container.getHeight();
}
}
然后需要一个回调接口,方便灵活地编写刷新逻辑
public interface IRefreshListViewListener {
void onRefresh();
}
RefreshListView的完整代码如下
public class RefreshListView extends ListView {
public final static int STATE_IDLE = 0; // 正常状态
public final static int STATE_READY = 1; // 准备刷新状态
public final static int STATE_REFRESH = 2; // 正在刷新状态
private final int SCROLL_DURATION = 400; // 刷新头回弹的时间长度,时间长则回弹慢
private final float OFFSET_RATIO = 1.8f; // 下拉阻尼系数,使得下拉时呈现出弹簧效果
private Scroller extraScroller; // 刷新头回弹所用的辅助Scroller
private IRefreshListViewListener refreshListViewListener; // 刷新回调
private HeaderView headerView; // 刷新头布局
private int headerHeight; // 头布局高度
private boolean isPullRefreshEnable = false; // 下拉刷新开关
private boolean isRefreshing = false; // 正在刷新标志
private float lastY = -1; // 滑动事件坐标记录
public RefreshListView(Context context) {
super(context);
initListView(context);
}
public RefreshListView(Context context, AttributeSet attrs) {
super(context, attrs);
initListView(context);
}
public RefreshListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initListView(context);
}
private void initListView(Context context) {
extraScroller = new Scroller(context, new DecelerateInterpolator());
headerView = new HeaderView(context);
addHeaderView(headerView);
// 添加布局渲染回调,用于获取头布局的高度
headerView.container.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
headerHeight = headerView.container.getHeight();
Log.i("Header Height", "Value:"+headerHeight);
getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});
}
// 处理滑动事件检测是否需要刷新
@Override
public boolean onTouchEvent(MotionEvent ev) {
if(lastY == -1) {
lastY = ev.getRawY();
}
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
lastY = ev.getRawY();
break;
case MotionEvent.ACTION_MOVE:
final float deltaY = ev.getRawY() - lastY;
lastY = ev.getRawY();
if(getFirstVisiblePosition() == 0
&& (headerView.getVisiableHeight() > 0 || deltaY > 0)) {
updateHeaderHeight(deltaY / OFFSET_RATIO);
}
break;
case MotionEvent.ACTION_UP:
lastY = -1;
if(getFirstVisiblePosition() == 0) {
if(isPullRefreshEnable
&& headerView.getVisiableHeight() > headerHeight) {
isRefreshing = true;
headerView.setState(STATE_REFRESH);
if(refreshListViewListener != null) {
refreshListViewListener.onRefresh();
}
}
resetHeaderHeight();
}
break;
}
return super.onTouchEvent(ev);
}
@Override
public void computeScroll() {
if(extraScroller.computeScrollOffset()) {
headerView.setVisiableHeight(extraScroller.getCurrY());
postInvalidate();
}
super.computeScroll();
}
// 更新刷新头布局的高度
private void updateHeaderHeight(float delta) {
headerView.setVisiableHeight((int)delta + headerView.getVisiableHeight());
if(isPullRefreshEnable && !isRefreshing) {
if(headerView.getVisiableHeight() > headerHeight) {
headerView.setState(STATE_READY);
} else {
headerView.setState(STATE_IDLE);
}
}
setSelection(0);
}
// 重设刷新头高度,启动回弹
private void resetHeaderHeight() {
int height = headerView.getVisiableHeight();
if(height != 0) {
if(!isRefreshing || height > headerHeight) {
int finalHeight = 0;
if(isRefreshing) {
finalHeight = headerHeight;
}
extraScroller.startScroll(0, height, 0, finalHeight - height,
SCROLL_DURATION);
invalidate();
}
}
}
public void setRefreshListViewListener(
IRefreshListViewListener refreshListViewListener) {
this.refreshListViewListener = refreshListViewListener;
}
public void setPullRefreshEnable(boolean enable) {
isPullRefreshEnable = enable;
}
// 停止刷新,重设状态,用于在刷新回调中完成工作后调用
public void stopRefresh() {
if (isRefreshing) {
isRefreshing = false;
resetHeaderHeight();
}
}
}
使用RefreshListView只要预先设置回调接口即可按照需求进行下拉刷新了
protected void onCreate(Bundle savedInstanceState) {
rlvContent = (RefreshListView) findViewById(R.id.rlvContent);
rlvAdapter = new MyRefreshListAdapter();
rlvContent.setAdapter(rlvAdapter);
rlvContent.setPullRefreshEnable(true);
rlvContent.setRefreshListViewListener(new RefreshListView.IRefreshListViewListener() {
@Override
public void onRefresh() {
refreshData();
}
});
}
public void refreshData() {
// 获取数据并刷新
rlvContent.stopRefresh(); // 通知RefreshListView停止刷新,重设状态
}
这种方法简单好用,而且自定义程度高,能很方便地适配各种不同的需求。但它也有明显的缺点,需要自定义多种View来方便使用,比如ExpandableListView就要自定义一个,GridView也要自定义一个,还不能使用ScrollView来进行自定义,因为ScrollView没有Header可以设置。所以,需要考虑一个新的方案。
这个名字仅仅是对接下来介绍的做法的一种描述,在前面的“自定义View”方案无法满足要求的时候,可以尝试一个新的思路,那就是能不能把下拉刷新做成一个容器,只要将符合标准的列表或者滑动组件放进去便可以使用?
答案是肯定的,比如为人熟知的PullToRefreshLayout框架就是采用的这种思路,做了一个通用的下拉刷新容器,只需要在容器中按需放入ListView或者ExpandableListView之类的组件即可使用。
谷歌官方在v4兼容包中也提供了类似的SwipeRefreshLayout方便开发下拉刷新的页面,需要Support Library 19.1以上,其使用方法也是在SwipeRefreshLayout中包裹一个目标Layout来进行下拉刷新。
这种方式的思路跟之前的简单想法差不太多,只不过把目标改成了一个Layout,而不是一个ListView或者ExpandableListView之类的组件,顺着这个类似的思路便可以尝试写出一个简单可用的PullToRefreshLayout框架了。
首先需要选择一个基础布局组件来进行改造,一般而言选择RelativeLayout是个非常不错的选择,不但可以实现功能,还能提供自定义刷新头这类增加实用性的功能;但现在只是简单尝试,那么可以使用固定刷新头的方案来减少重写的代码量。
选定了基础布局后就可以先确定使用方式了,因为外接式下拉刷新不比前文提到的重写ListView那种方法只能通过添加Header来加入刷新头,它的刷新头可以在XML布局文件中写好,可以在初始化组件时添加,也可以在使用前自定义。
既然是简单尝试,可以考虑最简单的方案,直接写在XML文件中。
<com.game.personal.exampleproj.PullToRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
"@+id/refreshHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
>
"@+id/tvHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
<com.game.personal.exampleproj.PullableListView
android:id="@+id/plvContent"
android:layout_width="match_parent"
android:layout_height="match_parent">
com.game.personal.exampleproj.PullableListView>
com.game.personal.exampleproj.PullToRefreshLayout>
XML的结构大约如图所示,refreshHeader可以像例子中这样直接写好也可以使用include标签来引用另外的XML
决定了这个使用方式,接下来看代码该如何组织。自定义组件继承RelativeLayout自不必多说,考虑下拉刷新功能都需要至少知道当前是否处于“已经下拉”的状态,在前文重写ListView时通过getFirstVisibleView能方便地判断出来,但现在重写RelativeLayout却不再有这样的方法可以使用,因此需要找到一个方法用来判断当前情况。
既然前文中ListView能方便地判断出所需的状态,那么一个很自然的想法就是用接口,将ListView里判断得到的状态引出来交由RelativeLayout处理。
因此定义一个接口
public interface IPullToRefresh {
boolean canPullDown(); // 判断当前是否可以下拉的方法
}
简单明了,就是用来返回当前是否处于可以下拉状态,如果返回true则可以将刷新头拉出,进行刷新,如果是false则不可以拉出刷新头,说明List还在可以滑动的状态.
有了这个接口,后面的设计也就顺理成章了,首先简单重写一下ListView类。
public class PullableListView extends ListView implements IPullToRefresh {
private boolean canPullDown = true;
public PullableListView(Context context) {
super(context);
}
public PullableListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public PullableListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setCanPullDown(boolean canPullDown) {
this.canPullDown = canPullDown;
}
@Override
public boolean canPullDown() {
View firstView = getChildAt(0);
return canPullDown && firstView != null && firstView.getTop() == 0;
}
}
非常简单地添加了一个开关标志来判定当前是否处于可以下拉出刷新头的状态中。
然后来看PullToRefreshLayout类的定义。
public class PullToRefreshLayout extends RelativeLayout {
// 初始状态
public static final int INIT = 0;
// 释放刷新
public static final int RELEASE_TO_REFRESH = 1;
// 正在刷新
public static final int REFRESHING = 2;
// 操作完毕
public static final int DONE = 5;
// 刷新成功
public static final int SUCCEED = 0;
// 刷新失败
public static final int FAIL = 1;
// 当前状态
private int state = INIT;
// 刷新回调接口
private OnRefreshListener onRefreshListener;
// 按下Y坐标,上一个事件点Y坐标
private float downY, lastY;
// 下拉的距离。注意:pullDownY和pullUpY不可能同时不为0
public float pullDownY = 0;
// 上拉的距离
private float pullUpY = 0;
// 释放刷新的距离
private float refreshDist = 200;
// 释放加载的距离
private float loadmoreDist = 200;
// 回滚速度
public float MOVE_SPEED = 8;
// 第一次执行布局
private boolean isLayout = false;
// 在刷新过程中滑动操作
private boolean isTouch = false;
// 手指滑动距离与下拉头的滑动距离比,中间会随正切函数变化
private float radio = 2;
// 下拉头
private View refreshView;
// 刷新结果:成功或失败
private TextView refreshStateTextView;
// 实现了Pullable接口的View
private View pullableView;
// 过滤多点触碰
private int mEvents;
// 这两个变量用来控制pull的方向,如果不加控制,当情况满足可上拉又可下拉时没法下拉
private boolean canPullDown = true;
private boolean canPullUp = true;
private Context resContext;
public PullToRefreshLayout(Context context) {
super(context);
resContext = context;
}
public PullToRefreshLayout(Context context, AttributeSet attrs) {
super(context, attrs);
resContext = context;
}
public PullToRefreshLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
resContext = context;
}
public void setOnRefreshListener(
OnRefreshListener onRefreshListener) {
this.onRefreshListener = onRefreshListener;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (!isLayout) {
// 这里是第一次进来的时候做一些初始化
refreshView = getChildAt(0);
pullableView = getChildAt(1);
isLayout = true;
initView();
refreshDist = ((ViewGroup) refreshView).getChildAt(0).getMeasuredHeight();
}
// 改变子控件的布局,这里直接用(pullDownY + pullUpY)作为偏移量,这样就可以不对当前状态作区分
refreshView.layout(0, (int) (pullDownY + pullUpY) - refreshView
.getMeasuredHeight(), refreshView.getMeasuredWidth(),
(int) (pullDownY + pullUpY));
pullableView.layout(0, (int) (pullDownY + pullUpY), pullableView
.getMeasuredWidth(), (int) (pullDownY + pullUpY) + pullableView
.getMeasuredHeight());
}
private void initView() {
// 初始化下拉布局
refreshStateTextView = (TextView) refreshView.findViewById(R.id.tvHeader);
}
private void releasePull() {
canPullDown = true;
canPullUp = true;
}
private void changeState(int to) {
state = to;
switch (state) {
case INIT:
// 下拉布局初始状态
refreshStateTextView.setText("下拉可以刷新");
refreshStateTextView.setTextColor(0xFF999999);
break;
case RELEASE_TO_REFRESH:
// 释放刷新状态
refreshStateTextView.setText("松开进行刷新");
refreshStateTextView.setTextColor(0xFF999999);
break;
case REFRESHING:
// 正在刷新状态
refreshStateTextView.setText("正在刷新...");
refreshStateTextView.setTextColor(0xFF111199);
break;
case DONE:
// 刷新或加载完毕,啥都不做
break;
}
}
public void refreshFinish(int refreshResult) {
switch (refreshResult) {
case SUCCEED:
// 刷新成功
refreshStateTextView.setText("刷新完毕");
break;
case FAIL:
default:
// 刷新失败
refreshStateTextView.setText("刷新失败");
break;
}
if (pullDownY > 0) {
// 刷新结果停留1秒
new Handler() {
@Override
public void handleMessage(Message msg) {
changeState(DONE);
hide();
}
}.sendEmptyMessageDelayed(0, 400);
} else {
changeState(DONE);
hide();
}
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
downY = ev.getY();
lastY = downY;
mEvents = 0;
releasePull();
break;
case MotionEvent.ACTION_POINTER_DOWN:
case MotionEvent.ACTION_POINTER_UP:
// 过滤多点触碰
mEvents = -1;
break;
case MotionEvent.ACTION_MOVE:
if (mEvents == 0) {
if (pullDownY > 0
|| (((IPullToRefresh) pullableView).canPullDown()
&& canPullDown)) {
// 可以下拉
// 对实际滑动距离做缩小,造成用力拉的感觉
pullDownY = pullDownY + (ev.getY() - lastY) / radio;
if (pullDownY < 0) {
pullDownY = 0;
canPullDown = false;
canPullUp = true;
}
if (pullDownY > getMeasuredHeight())
pullDownY = getMeasuredHeight();
if (state == REFRESHING) {
// 正在刷新的时候触摸移动
isTouch = true;
}
} else
releasePull();
} else
mEvents = 0;
lastY = ev.getY();
// 根据下拉距离改变比例
radio = (float) (2 + 2 * Math.tan(Math.PI / 2 / getMeasuredHeight() * (pullDownY + Math.abs(pullUpY))));
if (pullDownY > 0 || pullUpY < 0)
requestLayout();
if (pullDownY > 0) {
if (pullDownY <= refreshDist && (state == RELEASE_TO_REFRESH || state == DONE)) {
// 如果下拉距离没达到刷新的距离且当前状态是释放刷新,改变状态为下拉刷新
changeState(INIT);
}
if (pullDownY >= refreshDist && state == INIT) {
// 如果下拉距离达到刷新的距离且当前状态是初始状态刷新,改变状态为释放刷新
changeState(RELEASE_TO_REFRESH);
}
}
// 因为刷新和加载操作不能同时进行,所以pullDownY和pullUpY不会同时不为0,因此这里用(pullDownY +
// Math.abs(pullUpY))就可以不对当前状态作区分了
if ((pullDownY + Math.abs(pullUpY)) > 8) {
// 防止下拉过程中误触发长按事件和点击事件
ev.setAction(MotionEvent.ACTION_CANCEL);
}
break;
case MotionEvent.ACTION_UP:
if (pullDownY > refreshDist || -pullUpY > loadmoreDist)
// 正在刷新时往下拉(正在加载时往上拉),释放后下拉头(上拉头)不隐藏
{
isTouch = false;
}
if (state == RELEASE_TO_REFRESH) {
changeState(REFRESHING);
// 刷新操作
if (onRefreshListener != null)
onRefreshListener.onRefresh(this);
}
hide();
default:
break;
}
// 事件分发交给父类
super.dispatchTouchEvent(ev);
return true;
}
private void hide() {
pullDownY = 0;
requestLayout();
}
public interface OnRefreshListener {
// 刷新操作
void onRefresh(PullToRefreshLayout pullToRefreshLayout);
}
}
初始化要放到onLayout重写中,因为Header已经写在XML里了;主要的部分在于重写的dispatchTouchEvent方法。
重写dispatchTouchEvent而非onTouchEvent的原因主要在于当前方案使用的是嵌套,重写的类是父组件而ListView是子组件,重写dispatchTouchEvent有助于在必要的时候返回true来拦截事件进行控制。
使用这个组件的方法如下
pullToRefreshLayout = (PullToRefreshLayout) findViewById(R.id.refreshLayout);
contentList = (PullableListView) findViewById(R.id.plvContent);
rlvAdapter = new MyRefreshListAdapter();
pullToRefreshLayout.setOnRefreshListener(new PullToRefreshLayout.OnRefreshListener() {
@Override
public void onRefresh(PullToRefreshLayout pullToRefreshLayout) {
refreshData();
pullToRefreshLayout.refreshFinish(PullToRefreshLayout.SUCCEED);
}
});
public void refreshData() {
// 刷新数据
rlvAdapter.notifyDataSetChanged();
}
该方案通用性很强,只要实现了IPullToRefresh接口的组件都可以放在其中做下拉刷新功能,并不局限于ListView或者GridView,ScrollView乃至一般的LinearLayout都可以成为下拉刷新的内容。
说完了下拉刷新,接着就必须提一提加载了。在一个页面显示的数据量比较小的时候,一般的方案都是直接从远程端获取全部的数据一次性展示,只要展示用的ListView等具有重用特性的组件能做好重用代码,再多的数据都能无障碍地展现出来。
但这并不表示一次性展示所有数据就一定适用于所有的场景,有些情况下一次性展示对用户体验的影响巨大,比如当数据量过于庞大,单是返回数据和处理数据就非常耗时;或者时服务器承受不起大批量的对大量数据返回的请求,强行请求会造成卡顿乃至失去响应的情况时,优化方案势在必行。
而优化的思路非常简单,最基本的一种算法思路就可以解决这个问题,那就是分治法。
分治法旨在通过将待解决的问题分割为小问题分别解决并最后整合来快速解决问题,放到大量数据这样的场景下其实就是要将大批量的数据拆分成小块并分别返回,最后本地整合成列表予以显示,这说的其实就是现在很常见的一种数据访问方法——分页数据。
后台将数据按照访问参数分好页,每次返回指定页码的数据,接收端只需要按照自己定义的分页标准读取并展示数据即可,有效地解决了大批量数据访问的问题。
但同时,另一个问题也浮出水面,分页加载是很好,但什么时候加载下一页呢?按照传统思维,放个按钮,点击一下加载一页。这样固然能解决问题,但这个交互性比较低,现在依然有些应用使用了这种方案,其最大的优势就在于简单,完全不需要任何第三方框架或者重写组件之类的,只要使用ListView的FootView就能解决问题。
那么除了这个简单的方案之外还有哪些交互性相对高一些的方案呢?从下拉刷新可以得到提示,既然列表到顶再下拉就是刷新,那么反过来列表到底再上拉让它加载下一页不就好了?
这种想法完全没有问题,而且也是现实中已经存在的解决方案,实现也不难,根据前文提到的下拉刷新方案,增加一种状态和一个Footer来表示加载即可,接口中也多一个方法用来加载下一页。
public class PullToRefreshLayout extends RelativeLayout {
public static final String TAG = "PullToRefreshLayout";
// 初始状态
public static final int INIT = 0;
// 释放刷新
public static final int RELEASE_TO_REFRESH = 1;
// 正在刷新
public static final int REFRESHING = 2;
// 释放加载
public static final int RELEASE_TO_LOAD = 3;
// 正在加载
public static final int LOADING = 4;
// 操作完毕
public static final int DONE = 5;
// 当前状态
private int state = INIT;
// 刷新回调接口
private OnRefreshListener mListener;
// 刷新成功
public static final int SUCCEED = 0;
// 刷新失败
public static final int FAIL = 1;
// 按下Y坐标,上一个事件点Y坐标
private float downY, lastY;
// 下拉的距离。注意:pullDownY和pullUpY不可能同时不为0
public float pullDownY = 0;
// 上拉的距离
private float pullUpY = 0;
// 释放刷新的距离
private float refreshDist = 200;
// 释放加载的距离
private float loadmoreDist = 200;
// 回滚速度
public float MOVE_SPEED = 8;
// 第一次执行布局
private boolean isLayout = false;
// 在刷新过程中滑动操作
private boolean isTouch = false;
// 手指滑动距离与下拉头的滑动距离比,中间会随正切函数变化
private float radio = 2;
// 下拉头
private View refreshView;
// 刷新结果:成功或失败
private TextView refreshStateTextView;
// 上拉头
private View loadmoreView;
// 加载结果:成功或失败
private TextView loadStateTextView;
// 实现了Pullable接口的View
private View pullableView;
// 过滤多点触碰
private int mEvents;
// 这两个变量用来控制pull的方向,如果不加控制,当情况满足可上拉又可下拉时没法下拉
private boolean canPullDown = true;
private boolean canPullUp = true;
private Context mContext;
public void setOnRefreshListener(OnRefreshListener listener) {
mListener = listener;
}
public PullToRefreshLayout(Context context) {
super(context);
initView(context);
}
public PullToRefreshLayout(Context context, AttributeSet attrs) {
super(context, attrs);
initView(context);
}
public PullToRefreshLayout(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initView(context);
}
private void initView(Context context) {
mContext = context;
}
/**
* 完成刷新操作,显示刷新结果。注意:刷新完成后一定要调用这个方法
**/
public void refreshFinish(int refreshResult) {
switch (refreshResult) {
case SUCCEED:
// 刷新成功
refreshStateTextView.setText("刷新成功");
break;
case FAIL:
default:
// 刷新失败
refreshStateTextView.setText("刷新失败");
break;
}
if (pullDownY > 0) {
// 刷新结果停留1秒
new Handler() {
@Override
public void handleMessage(Message msg) {
changeState(DONE);
hide();
}
}.sendEmptyMessageDelayed(0, 400);
} else {
changeState(DONE);
hide();
}
}
/**
* 加载完毕,显示加载结果。注意:加载完成后一定要调用这个方法
*
**/
public void loadmoreFinish(int refreshResult) {
switch (refreshResult) {
case SUCCEED:
// 加载成功
loadStateTextView.setText("加载成功");
break;
case FAIL:
default:
// 加载失败
loadStateTextView.setText("加载失败");
break;
}
if (pullUpY < 0) {
// 刷新结果停留1秒
new Handler() {
@Override
public void handleMessage(Message msg) {
changeState(DONE);
hide();
}
}.sendEmptyMessageDelayed(0, 1000);
} else {
changeState(DONE);
hide();
}
}
private void changeState(int to) {
state = to;
switch (state) {
case INIT:
// 下拉布局初始状态
refreshStateTextView.setText("下拉可以刷新");
// 上拉布局初始状态
loadStateTextView.setText("上拉可以加载");
break;
case RELEASE_TO_REFRESH:
// 释放刷新状态
refreshStateTextView.setText("松开进行刷新");
break;
case REFRESHING:
// 正在刷新状态
refreshStateTextView.setText("正在刷新...");
break;
case RELEASE_TO_LOAD:
// 释放加载状态
loadStateTextView.setText("松开进行加载");
break;
case LOADING:
// 正在加载状态
loadStateTextView.setText("正在加载...");
break;
case DONE:
// 刷新或加载完毕,啥都不做
break;
}
}
/**
* 不限制上拉或下拉
*/
private void releasePull() {
canPullDown = true;
canPullUp = true;
}
/*
* (非 Javadoc)由父控件决定是否分发事件,防止事件冲突
*
* @see android.view.ViewGroup#dispatchTouchEvent(android.view.MotionEvent)
*/
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
downY = ev.getY();
lastY = downY;
mEvents = 0;
releasePull();
break;
case MotionEvent.ACTION_POINTER_DOWN:
case MotionEvent.ACTION_POINTER_UP:
// 过滤多点触碰
mEvents = -1;
break;
case MotionEvent.ACTION_MOVE:
if (mEvents == 0) {
if (pullDownY > 0 || (((IPullToRefresh) pullableView).canPullDown() && canPullDown && state != LOADING)) {
// 可以下拉,正在加载时不能下拉
// 对实际滑动距离做缩小,造成用力拉的感觉
pullDownY = pullDownY + (ev.getY() - lastY) / radio;
if (pullDownY < 0) {
pullDownY = 0;
canPullDown = false;
canPullUp = true;
}
if (pullDownY > getMeasuredHeight())
pullDownY = getMeasuredHeight();
if (state == REFRESHING) {
// 正在刷新的时候触摸移动
isTouch = true;
}
} else if (pullUpY < 0 || (((IPullToRefresh) pullableView).canPullUp() && canPullUp && state != REFRESHING)) {
// 可以上拉,正在刷新时不能上拉
pullUpY = pullUpY + (ev.getY() - lastY) / radio;
if (pullUpY > 0) {
pullUpY = 0;
canPullDown = true;
canPullUp = false;
}
if (pullUpY < -getMeasuredHeight())
pullUpY = -getMeasuredHeight();
if (state == LOADING) {
// 正在加载的时候触摸移动
isTouch = true;
}
} else
releasePull();
} else
mEvents = 0;
lastY = ev.getY();
// 根据下拉距离改变比例
radio = (float) (2 + 2 * Math.tan(Math.PI / 2 / getMeasuredHeight() * (pullDownY + Math.abs(pullUpY))));
if (pullDownY > 0 || pullUpY < 0)
requestLayout();
if (pullDownY > 0) {
if (pullDownY <= refreshDist && (state == RELEASE_TO_REFRESH || state == DONE)) {
// 如果下拉距离没达到刷新的距离且当前状态是释放刷新,改变状态为下拉刷新
changeState(INIT);
}
if (pullDownY >= refreshDist && state == INIT) {
// 如果下拉距离达到刷新的距离且当前状态是初始状态刷新,改变状态为释放刷新
changeState(RELEASE_TO_REFRESH);
}
} else if (pullUpY < 0) {
// 下面是判断上拉加载的,同上,注意pullUpY是负值
if (-pullUpY <= loadmoreDist && (state == RELEASE_TO_LOAD || state == DONE)) {
changeState(INIT);
}
// 上拉操作
if (-pullUpY >= loadmoreDist && state == INIT) {
changeState(RELEASE_TO_LOAD);
}
}
// 因为刷新和加载操作不能同时进行,所以pullDownY和pullUpY不会同时不为0,因此这里用(pullDownY +
// Math.abs(pullUpY))就可以不对当前状态作区分了
if ((pullDownY + Math.abs(pullUpY)) > 8) {
// 防止下拉过程中误触发长按事件和点击事件
ev.setAction(MotionEvent.ACTION_CANCEL);
}
break;
case MotionEvent.ACTION_UP:
if (pullDownY > refreshDist || -pullUpY > loadmoreDist)
// 正在刷新时往下拉(正在加载时往上拉),释放后下拉头(上拉头)不隐藏
{
isTouch = false;
}
if (state == RELEASE_TO_REFRESH) {
changeState(REFRESHING);
// 刷新操作
if (mListener != null)
mListener.onRefresh(this);
} else if (state == RELEASE_TO_LOAD) {
changeState(LOADING);
// 加载操作
if (mListener != null)
mListener.onLoadMore(this);
}
hide();
default:
break;
}
// 事件分发交给父类
super.dispatchTouchEvent(ev);
return true;
}
private void initView() {
// 初始化下拉布局
refreshStateTextView = (TextView) refreshView.findViewById(R.id.tvHeader);
// 初始化上拉布局
loadStateTextView = (TextView) loadmoreView.findViewById(R.id.tvFooter);
}
public int getState() {
return state;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (!isLayout) {
// 这里是第一次进来的时候做一些初始化
refreshView = getChildAt(0);
pullableView = getChildAt(1);
loadmoreView = getChildAt(2);
isLayout = true;
initView();
refreshDist = ((ViewGroup) refreshView).getChildAt(0).getMeasuredHeight();
loadmoreDist = ((ViewGroup) loadmoreView).getChildAt(0).getMeasuredHeight();
}
// 改变子控件的布局,这里直接用(pullDownY + pullUpY)作为偏移量,这样就可以不对当前状态作区分
refreshView.layout(0, (int) (pullDownY + pullUpY) - refreshView.getMeasuredHeight(), refreshView.getMeasuredWidth(), (int) (pullDownY + pullUpY));
pullableView.layout(0, (int) (pullDownY + pullUpY), pullableView.getMeasuredWidth(), (int) (pullDownY + pullUpY) + pullableView.getMeasuredHeight());
loadmoreView.layout(0, (int) (pullDownY + pullUpY) + pullableView.getMeasuredHeight(), loadmoreView.getMeasuredWidth(), (int) (pullDownY + pullUpY) + pullableView.getMeasuredHeight() + loadmoreView.getMeasuredHeight());
}
private void hide() {
pullDownY = 0;
pullUpY = 0;
requestLayout();
}
/**
* 刷新加载回调接口
*
*/
public interface OnRefreshListener {
/**
* 刷新操作
*/
void onRefresh(PullToRefreshLayout pullToRefreshLayout);
/**
* 加载操作
*/
void onLoadMore(PullToRefreshLayout pullToRefreshLayout);
}
}
这样的实现固然很简单,但交互性依然一般,用户需要拉到底部再强行上拉才能加载下一页的数据,这样看上去似乎还有提升的空间,能不能让列表智能化一点,只要到达底部自动就加载下一页数据呢?
答案是肯定的,而且这样的解决方案并不罕见,它不但能很好地契合分页数据的访问方式,在交互性方面也是深得人心,毕竟不用操心数据怎么来的,只要往下拖动列表就行的操作方式人们都喜欢。
安卓要实现这个方案非常简单,比下拉刷新还要简单,因为只是在滑动过程中判断是否需要加载而不涉及任何状态切换,因此OnScrollListener足以应付这个需求,甚至都不需要重写任何组件。
class ScrollToLoadImpl implements AbsListView.OnScrollListener {
@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
//
}
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
if(view.getLastVisiblePosition() == totalItemCount) {
// 在此处载入数据
}
}
}
使用getLastVisiblePosition是个比较粗糙的方案,虽然一样能实现功能,但实际上加载是在最后一个项目出现的时候开始而非触底,因此在某些时候也可以使用getChildAt来获取更为精确的位置信息。
至此,简单的下拉刷新,上拉加载以及触底加载(无限列表)的实现方案就介绍完毕,在实际生产环境中这些方案都会根据实际需求有所变化,因此仅供参考。
更多关于谷歌官方SwipeRefreshLayout以及第三方下拉刷新框架的信息请参考如下文章资料。