ListView增加下拉刷新,上拉加载更多

项目简介

为ListView增加header和footer来实现下拉刷新和上拉加载更多,已经是Android开发老生常谈的问题,网络上有数不清的demo来方便你的项目开发,但是网络上这么多的资源导致你在选择第三方ListView时往往会陷入选择困难,与其每次都要纠结哪一家的第三方ListView比较完美,倒不如参照这些第三方ListView打造出自己想要的ListView。

项目结构

项目结构比较清晰,主要分为三部分:header,footer,以及自定义的ListView,在这个项目中,笔者将其定义为:RefreshHeader,RefreshFooter以及RefreshListView

RefreshHeader实现

首先来看RefreshHeader的截图:

0.png
1.png
2.png

从图中我们可以清晰的看出图中有三种状态:

  • 下拉刷新
  • 松开刷新
  • 正在加载

创建RefreshHeader.java文件让其extendsLinearLayout,可以为其增加三种状态并初始化状态:

public static final int PULL_TO_REFRESH = 0;    //下拉刷新
public static final int RELEASE_TO_REFRESH = 1; //松开刷新
public static final int REFRESHING = 2;         //正在刷新
int mState = PULL_TO_REFRESH;

编写RefreshHeader我们可以分为四步来执行:

  1. 编写布局文件
  2. 根据状态改变RefreshHeader界面显示
  3. 根据手指拖动位置来改变RefreshHeader高度和状态
  4. 根据手指松开位置再次改变状态,滚动至相应位置

首先编写布局文件:

refresh_header.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="wrap_content"
    android:gravity="bottom"
    android:orientation="vertical">

    <RelativeLayout
        android:id="@+id/header_content"
        android:layout_width="match_parent"
        android:layout_height="@dimen/refresh_header_height">


        <LinearLayout
            android:id="@+id/ll_pull_state"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true"
            android:gravity="center"
            android:orientation="vertical">

            <TextView
                android:id="@+id/tv_pull_state"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="下拉刷新" />
        </LinearLayout>

        <ProgressBar
            android:id="@+id/progressBar"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:layout_gravity="center"
            android:layout_marginRight="10dp"
            android:layout_toLeftOf="@id/ll_pull_state"
            android:visibility="invisible" />

        <ImageView
            android:id="@+id/iv_arrow"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:layout_marginRight="15dp"
            android:layout_toLeftOf="@id/ll_pull_state"
            android:src="@drawable/arrow_down" />
    </RelativeLayout>

</LinearLayout>

布局文件比较简单,就是一些简单的LinearLayout于RelativeLayout的嵌套,这里不再细说,主要来讲解下RefreshHeader.java文件:

private void init(Context context) {
    LayoutParams lp = new LayoutParams(LayoutParams.MATCH_PARENT, 0);
    mContainer = LayoutInflater.from(context).inflate(R.layout.refresh_header, null);
    addView(mContainer, lp);

    rl_my_content = (RelativeLayout) findViewById(R.id.header_content);
    tv_pull_state = (TextView) findViewById(R.id.tv_pull_state);
    iv_arrow = (ImageView) findViewById(R.id.iv_arrow);
    progressBar = (ProgressBar) findViewById(R.id.progressBar);

    rotateUpAnimation = new RotateAnimation(0, -180, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
    rotateUpAnimation.setDuration(180);
    rotateUpAnimation.setFillAfter(true);

    rotateDownAnimation = new RotateAnimation(-180, 0, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f);
    rotateDownAnimation.setDuration(180);
    rotateDownAnimation.setFillAfter(true);
}
/**
 * 获取头部的高度
 * @return
 */
public int getHeaderHeight() {
    return mContainer.getHeight();
}

/**
 * 修改header的高度
 * @param height
 */
public void setHeaderHeight(int height) {
    if (height < 0) height = 0;
    LayoutParams layoutParams = (LayoutParams) mContainer.getLayoutParams();
    layoutParams.height = height;
    mContainer.setLayoutParams(layoutParams);
}

由代码可以看出,在我们根据布局文件创建mContainer 时候,由于xml文件中android:layout_widthandroid:layout_height失效,所以我们需要为其指定了一个LayoutParams,一是在为之后ListView的addHeader()方法调用之后能隐藏RefreshHeader,二是宽度让其匹配父窗体(如果不这么做,控件不居中)。由于执行过程中涉及到ImageView的旋转动画效果,我们为其指定两个旋转动画,最后再增加获取和修改RefreshHeader的高度的方法,为后续操作作准备。

根据状态改变RefreshHeader界面显示

初始化操作之后,我们需要根据状态为其设置相应的布局:

/**
 * 根据状态设置相应的布局
 * @param state
 */
public void setHeaderState(int state) {
    if (state == mState && !isFirst) {
        return;
    }
    isFirst = false;
    if (state == REFRESHING) {
        iv_arrow.clearAnimation();
        progressBar.setVisibility(View.VISIBLE);
        iv_arrow.setVisibility(View.INVISIBLE);
        tv_pull_state.setText("正在刷新");
    } else {
        progressBar.setVisibility(View.INVISIBLE);
        iv_arrow.setVisibility(View.VISIBLE);
    }

    switch (state) {
        case PULL_TO_REFRESH:
            if (mState == RELEASE_TO_REFRESH) {
                iv_arrow.clearAnimation();
                iv_arrow.startAnimation(rotateDownAnimation);
            }
            tv_pull_state.setText("下拉刷新");
            break;
        case RELEASE_TO_REFRESH:
            if (mState == PULL_TO_REFRESH) {
                iv_arrow.clearAnimation();
                iv_arrow.startAnimation(rotateUpAnimation);
            }
            tv_pull_state.setText("松开刷新");
            break;

    }
    mState = state;
}

方法逻辑比较简单,根据传递进来的state不同来改变不同的布局UI,这里需要注意的是,因为布局有用到动画来控制iv_arrow这个控件,所以每次改变状态时需要执行clearAnimation()方法,来消除之前动画效果的影响。

根据手指拖动位置来改变RefreshHeader高度和状态

首先我们来看一下项目操作:

执行下拉刷新
不执行下拉刷新

1.当拖动位置大于指定高度()时,状态改变执行setHeaderState()并传入RELEASE_TO_REFRESH参数。松开手指后,状态改变,执行setHeaderState()并传入REFRESHING参数,位置自动滚动到相应位置。

2.当拖动位置小于指定高度()时,状态改变执行setHeaderState()并传入PULL_TO_REFRESH参数。松开手指后,状态不改变,位置自动滚动到隐藏位置。

  • 获取指定高度

初始化高度在RefreshListView初始化方法init()中调用:

private void init(Context context) {
    mContext = context;

    //初始化mScroller,设置插值器为减速插值器,隐藏header或者footer时,数值变化先快后慢
    mScroller = new Scroller(mContext, new DecelerateInterpolator());
    setOnScrollListener(this);

    //初始化header
    mHeader = new RefreshHeader(mContext);
    mHeaderContent = (RelativeLayout) mHeader.findViewById(R.id.header_content);
    this.addHeaderView(mHeader);

    //初始化footer
    mFooter = new RefreshFooter(mContext);
    this.addFooterView(mFooter);

    // 获取header头部的固定高度
    ViewTreeObserver observer = mHeader.getViewTreeObserver();
    if (null != observer) {
        observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @SuppressWarnings("deprecation")
            @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
            @Override
            public void onGlobalLayout() {
                mHeaderHeight = mHeaderContent.getHeight();

                ViewTreeObserver observer = getViewTreeObserver();
                if (null != observer) {
                    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
                        observer.removeGlobalOnLayoutListener(this);
                    } else {
                        observer.removeOnGlobalLayoutListener(this);
                    }
                }
            }
        });
    }
}
  • 改变RefreshHeader高度和RefreshHeader状态

我们知道,RefreshHeader高度是随着手指的变化而变化,所以重写RefreshListView的onTouchEvent()方法:

@Override
public boolean onTouchEvent(MotionEvent ev) {
    if (mLastY == -1) {
        mLastY = ev.getRawY();
    }

    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mLastY = ev.getRawY();
            break;
        case MotionEvent.ACTION_MOVE:
            float dy = ev.getRawY() - mLastY;
            mLastY = ev.getRawY();
            if (getFirstVisiblePosition() == 0 && (mHeader.getHeaderHeight() > 0 ||
                    dy > 0)) {
                //OFFSET_RADIO的设置,让下拉header时感觉到阻力,增强体验
                updateHeaderHeight(dy / OFFSET_RADIO);
            }

            if (getLastVisiblePosition() == mTotalItemCount - 1 &&
                    (mFooter.getFooterMargin() > 0 || dy < 0)) {
                //原理同上
                updateFooterHeight(-dy / OFFSET_RADIO);
            }

            break;
        case MotionEvent.ACTION_UP:
            mLastY = ev.getRawY();
            if (getFirstVisiblePosition() == 0) {
                if (!mPullRefreshing && mEnablePullRefresh && (mHeader.getHeaderHeight() > mHeaderHeight)) {
                    refresh();
                }
                resetHeaderHeight();
            }

            if (getLastVisiblePosition() == mTotalItemCount - 1) {
                if (!mPullLoading && mEnableLoadMore && (mFooter.getFooterMargin() > PULL_LOAD_MORE_DELTA)) {
                    loadMore();
                }
                resetFooterHeight();
            }

            break;

    }
    return super.onTouchEvent(ev);
}

注意看:

 if (getFirstVisiblePosition() == 0 && (mHeader.getHeaderHeight() > 0 ||dy > 0)) {
      //OFFSET_RADIO的设置,让下拉header时感觉到阻力,增强体验
      updateHeaderHeight(dy / OFFSET_RADIO);
 }

OFFSET_RADIO是一个大于1.0的数,通常设置在1.5到2.0之间,这里取1.8,目的是让用户下拉时RefreshHeader高度的变化比手指拖动距离小,给用户一种下拉阻力的感觉。接下来我们看updateHeaderHeight(dy / OFFSET_RADIO);方法:

private void updateHeaderHeight(float delta) {    
    //改变RefreshHeader高度
    mHeader.setHeaderHeight((int) delta + mHeader.getHeaderHeight());
    //改变RefreshHeader状态
    if (mEnablePullRefresh && !mPullRefreshing) {
        if (mHeader.getHeaderHeight() > mHeaderHeight) {
            mHeader.setHeaderState(RefreshHeader.RELEASE_TO_REFRESH);
        } else {
            mHeader.setHeaderState(RefreshHeader.PULL_TO_REFRESH);
        }
    }
    //保证改变RefreshListView高度的同时,不滑动RefreshListView
    setSelection(0);
}

代码根据传进来的delta值来改变RefreshHeader的界面高度,并将与之前获取到的mHeaderHeight高度进行对比,来改变RefreshHeader的状态,进而改变RefreshHeader的界面内容。需要注意的是这里需要加上setSelection(0);这个方法。我们在父类ListView中找到该方法:

@Override
public void setSelection(int position) {
    setSelectionFromTop(position, 0);
}

跟进到setSelectionFromTop(int position, int y)方法中,我们可以在注释中找到:

Sets the selected item and positions the selection y pixels from the top edgeof the ListView. (If in touch mode, the item will not be selected but it willstill be positioned appropriately.)

该方法的作用就是让该position上的Item顶到距离ListView上方边缘y像素的地方,说白了这个方法的使用避免了你在改变RefreshHeader高度的同时又滑动RefreshListView,避免造成你手指下拉1个单位同时,RefreshHeader的高度改变1+1/OFFSET_RADIO(非精确)的高度(由于设置RefreshHeader的高度时会把delta强制转换int类型,实际也不会这么精确)。setSelection(0);的设置确保你的RefreshHeader的改变高度是你所期望的1/OFFSET_RADIO手指下拉高度。

根据手指松开位置再次改变状态,滚动至相应位置

看到这里,我们再把之前写的两句话复制过来:

1.当拖动位置大于指定高度()时,状态改变执行setHeaderState()并传入RELEASE_TO_REFRESH参数。松开手指后,状态改变,执行setHeaderState()并传入REFRESHING参数,位置自动滚动到相应位置。

2.当拖动位置小于指定高度()时,状态改变执行setHeaderState()并传入PULL_TO_REFRESH参数。松开手指后,状态不改变,位置自动滚动到隐藏位置。

case MotionEvent.ACTION_UP:
    mLastY = ev.getRawY();
    if (getFirstVisiblePosition() == 0) {
        if (!mPullRefreshing && mEnablePullRefresh && (mHeader.getHeaderHeight() > mHeaderHeight)) {
            refresh();
        }
        //RefreshHeader位置自动滚动到相应位置
        resetHeaderHeight();
    }
private void refresh() {
    mPullRefreshing = true;
    if (mEnablePullRefresh && refreshListViewListener != null) {
        mHeader.setHeaderState(RefreshHeader.REFRESHING);
        refreshListViewListener.onRefresh();
    }
}

当拖动位置大于指定高度()时,松开手指,执行refresh()方法,方法中干了两件事:

  • 改变RefreshHeader的状态
  • 将正在刷新状态传递到外部,让其去处理

不论最后拖动位置如何,最后都要调用resetHeaderHeight()方法:

private void resetHeaderHeight() {
    int height = mHeader.getHeaderHeight();
    if (height == 0)
        return;

    if (mPullRefreshing && height < mHeaderHeight) {  //正在刷新并且将header向上拖动隐藏时
        return;
    }

    int finalHeight = 0;

    if (mPullRefreshing && mHeader.getHeaderHeight() >= mHeaderHeight) {
        finalHeight = mHeaderHeight;
    }
    mScrollBack = SCROLL_BACK_HEADER;
    mScroller.startScroll(0, height, 0, finalHeight - height, SCROLL_DURATION);

    //激活computeScroll方法
    invalidate();
}

在这个方法中,我们最终会调用mScroller的startScroll方法,通过激活computeScroll()的方法来让我们的RefreshHeader自动滚动到我们想要的位置:

@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        if (mScrollBack == SCROLL_BACK_HEADER) {
            mHeader.setHeaderHeight(mScroller.getCurrY());
        } else {
            mFooter.setFooterMargin(mScroller.getCurrY());
        }
    }
    super.computeScroll();
}

自此,ListView下拉刷新部分就告一段落。

RefreshFooter实现

同样地,编写RefreshFooter我们可以分为四步来执行:

  1. 编写布局文件
  2. 根据状态改变RefreshFooter界面显示
  3. 根据手指拖动位置来改变RefreshFooter高度和状态
  4. 根据手指松开位置再次改变状态,滚动至相应位置

由于RefreshFooter代码逻辑与RefreshHeader相似,这里需要注意的是RefreshFooter的初始高度默认不是0,目的是使用户松开手指惯性滑动到最底部时,显示的最后一个Item时我们设置的RefreshFooter:

mContainer.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));

最后

整体来说,RefreshListView的逻辑整体看起来并不复杂,只要将其逻辑按照前文说的4个步骤来实现,化繁为简,就不会觉得无从下手。
RefreshListView是我在学习android阶段一直想写的一个项目,借由国庆期间的几天时间,研读了Github上的XListView以及PullToRefreshListView,趁着最近刚刚学习,将思路贴上来与大家分享,也算是做一份总结。如写得有错误,欢迎指正~

项目代码:

https://github.com/MakeDeath/RefreshListView

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,263评论 25 707
  • IntelliJ IDEA 15 开发之前配置jdk ,tomcat8 一:IDEA配置JDK 下载jdk htt...
    Tsnow308阅读 373评论 0 0
  • 对不起 我不知道 该如何想你
    远去的星辰呀阅读 269评论 1 4
  • 很早很早以前就看过小王子,那时候穷,20多块一本薄薄的书都没法支付,拿着老旧的手机看着文字版的小王子,那个时候的感...
    蓦然回首不悔当初阅读 211评论 0 0