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

前言

下拉刷新的 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 属性只支持百分数,不支持小数

附录

示例代码:http://git.oschina.net/Integer/RefreshListView

你可能感兴趣的:(框架,android,ListView,下拉刷新,上拉加载)