您的位置:首页 > 其它

自定义控件:含下拉刷新和上拉加载 ListView 的原理

2016-03-28 13:07 567 查看

前言

下拉刷新的 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

/* 根据状态,更新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

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 属性只支持百分数,不支持小数

3 . api17 及以下版本,addHeaderView 必须在 setAdapter 之前调用。(《Cannot add header view to list – setAdapter has already been called.》)

附录

示例代码:http://git.oschina.net/Integer/RefreshListView
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: