下拉刷新的 ListView,是非常常见的组件。一般情况下,我们会用第三方框架,比如:Android-PullToRefresh、xListView 等实现。第三方框架用起来方便,但如果想个性化定制,需要搞懂其原理。今天,我们自己实现一个支持上拉刷新和下拉加载更多的自定义 ListView,了解其中的原理。
向 ListView 头部和尾部分别添加 HeaderView、FooterView,默认使其隐藏。下拉时,逐渐使 HeaderView 显示,并不断改变 HeaderView 上的文字为“下拉刷新”、“松开刷新”和“正在加载”,当状态改为正在加载时,调接口加载数据,加载完成恢复原状。上拉时,当 ListView 滑动到最底部并且松开手指,马上显示 FooterView ,调接口加载数据,完成加载恢复默认。
Gif 录屏工具:LICEcap
首先,照例,定义 class 继承 ListView public class RefreshListView extends ListView
,然后对用到的全部成员进行定义
/* 监听接口 */
private OnRefreshListener onRefreshListener;
public interface OnRefreshListener {
void onRefresh(RefreshListView listView);
void onLoad(RefreshListView listView);
}
/* 头部View、高度 */
private View mHeaderView;
private int mHeaderHeight;
/* 尾部View、高度 */
private View mFooterView;
private int mFooterHeight;
/* HeaderView中的控件 */
private ImageView mIvArrow; // 箭头
private ProgressBar mPbRotate; // 进度条
private TextView mTvStatus; // 状态
private TextView mTvTime; // 时间
/* 向上动画、向下动画 */
private RotateAnimation upRotateAnimation;
private RotateAnimation downRotateAnimation;
/* 当前状态 */
private RefreshState currState = RefreshState.PULL;
/* 状态 枚举 */
public enum RefreshState {
LOADING, // 正在加载
PULL, // 下拉刷新
RELEASE, // 松开加载
}
监听接口:包含 onRefresh 和 onLoad,作用是:当刷新和加载时回调。
头部、尾部 View:分别来自俩布局文件,作用是:用作头部和尾部
向上、下动画:旋转动画,分别作用于 HeaderView 的箭头,使其转动
状态:分为 LOADING(正在加载)、PULL(下拉刷新)、RELEASE(松开加载),作用是:作为依据,切换 HeaderView 的 UI 显示
有了最基本的成员,需要初始化,我们在 onSizeChanged 对其初始化,至于为什么用 onSizeChanged,请看我另一篇《对 ViewGroup 生命周期执行顺序的理解》
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// 初始化头部
initHeaderView();
// 初始化动画
initAnim();
// 设置滚动事件
setOnScrollListener(this);
// 初始化尾部
initFooterView();
}
初始化 HeaderView
/* 初始化头部 */
private void initHeaderView() {
mHeaderView = View.inflate(getContext(), R.layout.layout_headerview,null);
mHeaderView.measure(0, 0);
mHeaderHeight = mHeaderView.getMeasuredHeight();
mHeaderView.setPadding(0, -mHeaderHeight, 0, 0);
// 这里省略,初始化headerView 的成员
// mIvArrow(箭头)、mPbRotate(进度)、mTvStatus(状态)、mTvTime(最后更新时间) 略
addHeaderView(mHeaderView);
}
其他具体的代码和 xml 代码,这里不再贴了,都是非常简单的,全部都在 Demo 里。但是,要特别注意这几点:
尺寸测量
本例中的 headerView 和 footerView,均来自 inflate。需要手动调用 measure(0,0) 通知系统主动测量,第一个参数表示测量尺寸,第二个参数表示测量模式。测量的知识,请参考《自定义控件:onMeasure 方法和测量原理的理解》
隐藏技巧
隐藏 headerView 和 footerView 是通过设置他们的 paddingTop 为负数实现的。
隐藏 headerView:
mHeaderView.setPadding(0, -mHeaderHeight, 0, 0);
隐藏 footerView:
mFooterView.setPadding(0, -mFooterHeight, 0, 0);
mHeaderHeight、mFooterHeight 分别为对应 View 的宽高
动画角度
/* 初始化动画 */
private void initAnim() {
upRotateAnimation = new RotateAnimation(0f, -180f,
Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF,
0.5f);
upRotateAnimation.setDuration(300);
// 动画结束,保持状态
upRotateAnimation.setFillAfter(true);
downRotateAnimation = new RotateAnimation(-180f, -360f,
Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF,
0.5f);
downRotateAnimation.setDuration(300);
// 动画结束,保持状态
downRotateAnimation.setFillAfter(true);
}
我们将状态从下拉刷新改为释放刷新,箭头逆时针180度(0 ~ -180);状态从释放刷新改为下拉刷新,箭头再次逆时针旋转180度(-180 ~ -360)回到原点。即:RotateAnimation 执行后,角度值依然在,第二次执行必须从原来的角度开始。
完成了定义和初始化,接下来是整个 RefreshListView 的核心,这里是最关键也是最坑的,需要在做之前充分分析需求、考虑各种情况,并且调试要耐心。
以下是监听触摸的全部代码,注释中有标号的,下边对用有说明
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
startY = (int) ev.getY();
break;
case MotionEvent.ACTION_MOVE:
int dy = (int) (ev.getY() - startY);
// (dy / 2)阻尼效果
int newPaddingTop = -mHeaderHeight + dy / 2;
// (一)正在刷新时,不允许改变继续往下拉
if (currState == RefreshState.LOADING){
break;
}
mHeaderView.setPadding(0, newPaddingTop, 0, 0);
// (二)状态的切换
if (newPaddingTop >= 0 && currState == RefreshState.PULL) {
// 进入松开刷新
currState = RefreshState.RELEASE;
// 根据状态,更新UI
refreshHeaderView();
} else if (newPaddingTop < 0 && currState == RefreshState.RELEASE) {
// 进入下拉刷新
currState = RefreshState.PULL;
// 根据状态,更新UI
refreshHeaderView();
}
// (三)判断事件是否要交给 ListView 处理
if (dy > 0 && getFirstVisiblePosition() == 0) {
return true;
}
break;
case MotionEvent.ACTION_UP:
int currPaddingTop = mHeaderView.getPaddingTop();
// (四)松开手指,再根据状态,修改 UI
if ( currPaddingTop <= 0 && currState == RefreshState.PULL){
mHeaderView.setPadding(0, -mHeaderHeight, 0, 0);
} else if (currPaddingTop > 0 && currState == RefreshState.RELEASE){
currState = RefreshState.LOADING;
refreshHeaderView();
// 通知外部,现在正在刷新了
if (onRefreshListener != null){
onRefreshListener.onRefresh(this);
}
}
break;
}
// (五)必须依然将事件还给 ListView,处理默认的滚动
return super.onTouchEvent(ev);
}
(一)正在刷新时,不允许改变继续往下拉
当状态切换到正在刷新(LOADING),就不允许 headerView 再往下拉了,直接 break ,将 ACTION_MOVE 事件交给 ListView 的默认滚动,而不再 mHeaderView.setPadding();
(二)状态的切换
由于 ACTION_MOVE 是持续执行(移动过程中,dispatchTouchEvent 不断将事件包 event 丢给 onTouchEvent),为了保证只在状态改变的时候更新 UI,必须判断:当前的状态是不是等于即将改变的状态,如果不相等,才认为是改变了状态(说的够详细吧)。
(三)判断事件是否要交给 ListView 处理
当手指在 ListView 上不断往下拉,ACTION_MOVE 中的代码,headerView 的 paddingTop 值越来越大。这时,可以很明显的发现,ListView 滑动速度比一般的快了。因为 ACTION_MOVE 不仅被用来改变 headerView 的 paddingTop 值,而且还被 ListView 用来处理滑动,两者相加,速度明显快。所以,在改变 headerView 的 padingTop 时,必须拦截。我的判断条件是:当手指往下拉并且当前可见条目为 0 ,就拦截,这样能省很多不必要的判断。
(四)松开手指,再根据状态,修改 UI
当松开手指,如果 headerView 完全显示(松开刷新状态)就直接切换为正在刷新,并调接口显示数据;如果 headerView 未完全显示(下拉刷新状态)则直接隐藏 headerView,使其恢复初始状态。
(五)必须依然将事件还给 ListView,处理默认的滚动
这个必须注意,如果直接 return true、false,意味着事件到你这里结束了,不再交给 ListView,那么 ListView 将无法滚动。ListView 自带的滚动逻辑可全在 super.onTouchEvent(ev) 中。
/* 根据状态,更新HeaderView的UI */
private void refreshHeaderView() {
switch (currState) {
case PULL:
mIvArrow.startAnimation(downRotateAnimation);
mTvStatus.setText("下拉刷新");
break;
case RELEASE:
mIvArrow.startAnimation(upRotateAnimation);
mTvStatus.setText("松开刷新");
break;
case LOADING:
// 必须要清除动画,否则无法设置隐藏
mIvArrow.clearAnimation();
mHeaderView.setPadding(0, 0, 0, 0);
mIvArrow.setVisibility(View.INVISIBLE);
mPbRotate.setVisibility(View.VISIBLE);
mTvStatus.setText("正在刷新...");
break;
}
}
这里就是根据各种状态,操作元素。三种状态 PULL、RELEASE、LOADING 分别对应如下如:
footerView 在初始化的时候添加在 ListView 的尾部,但是默认隐藏(paddingTop 为 -footerViewHeight),那么在什么情况下显示呢? 本例中,当手指松开屏幕并且滑到最后一个时,才显示,并调接口加载数据到 ListView 尾部。
@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
// 当手指松开、并且最后一个可见 view 为 List 最后一条数据,才显示 footerView
if (scrollState == OnScrollListener.SCROLL_STATE_IDLE && getLastVisiblePosition() == getCount() - 1){
mFooterView.setPadding(0, 0, 0, 0);
setSelection(Integer.MAX_VALUE);
// 调接口,通知外部加载更多
if (onRefreshListener != null){
onRefreshListener.onLoad(this);
}
}
}
这里需要注意:
setSelection 使 ListView 滑到最底部,以显示 footerView
mFooterView.setPadding(0, 0, 0, 0);
虽然能让 footerView 显示出来,但如果不往下滑动 ListView,看不 setSelection(Integer.MAX_VALUE)
将 ListView 拉到最底部。
恢复 headerView 的状态
/* 完成下拉刷新 */
public void completeRefresh(){
mTvStatus.setText("下拉刷新");
mHeaderView.setPadding(0, -mHeaderHeight, 0, 0);
mPbRotate.setVisibility(View.INVISIBLE);
mIvArrow.setVisibility(View.VISIBLE);
// 注意修改状态
currState = RefreshState.PULL;
mTvTime.setText("最后刷新:"+getCurrTime());
}
恢复 footerView 的状态
/* 完成加载更多 */
public void completeLoadMore(){
// 隐藏 footerView
mFooterView.setPadding(0, -mFooterHeight, 0, 0);
setSelection(Integer.MAX_VALUE);
}
注意:completeRefresh() 和 completeLoadMore() 是提供给使用者调用的,RefreshListView 并不知道什么时候加载完成。由使用者,开启子线程执行异步任务,执行完成后在 UI 线程调用 mRefreshListView.completeRefresh()
或者 mRefreshListView.completeLoadMore()
恢复状态。
至此,完成了下拉刷新和上拉加载。
设置监听
mListView = new RefreshListView(this);
mListView.setOnRefreshListener(new RefreshListView.OnRefreshListener(){
@Override
public void onRefresh(RefreshListView listView) {
// 刷新 - 从服务器加载数据
sendRequest(true);
}
@Override
public void onLoad(RefreshListView listView) {
// 加载更多 - 从服务器加载数据
sendRequest(false);
}
});
请求数据
/* 从服务器加载数据 */
private void sendRequest(boolean isPullRefresh){
new Thread(new Runnable() {
@Override
public void run() {
SystemClock.sleep(2000);
mDatas.add(isPullRefresh ? 0 : mDatas.size(), "这是新加载的数据" + new Date());
Message msg = mHandler.obtainMessae();
msg.obj = isPullRefresh;
mHandler.sendMessage(msg);
}
}).start();
}
handler
Handler mHandler = new Handler(){
public void handleMessage(android.os.Message msg) {
myAdapter.notifyDataSetChanged();
boolean isRefresh = (Boolean) msg.obj;
if (isRefresh){
// 通知ListView应该完成刷新了
mListView.completeRefresh();
} else {
// 通知ListView应该完成加载更多了
mListView.completeLoadMore();
}
};
};
1 . ListView item 根布局的不管设置成什么都是默认的 MATCH_PARENT、WRAP_CONTENT,设置成其他的不生效
2 . 在 xml 中定义旋转动画,pivotX 属性只支持百分数,不支持小数
示例代码:http://git.oschina.net/Integer/RefreshListView