1. 简介
好长时间没有写博客了,一来是工作忙,抽不出空,二来是迷上了王者荣耀。现在正好赶上项目空闲期,写一篇关于下拉刷新的文章,个人觉得上来加载更多功能使用场景非常少,而且没有必要做的那么麻烦,文章最后会提一下加载更多的实现。
最近项目中遇见了下拉刷新的需求,正好研究了一下,分享一下自己的心得。
主要参考文章或工程:
郭霖大神—Android下拉刷新完全解析,教你如何一分钟实现下拉刷新功能
这三篇文章各自提供了实现下拉刷新的思路,文章会分别介绍这三种实现方式的优劣。文章中会涉及到点击事件分发知识,大家可以查看这篇文章Android事件分发机制详解。自己写对三种实现做了部分优化,写了demo,地址链接
2. 分析
下拉刷新主要分为两部分,一部分是刷新头部Header,一部分是内容展示区域,一般是列表。通过某些方法,来控制刷新头部Header的展示范围,达到下拉刷新的效果,如下图。
图-1 下拉刷新原理图
做下拉刷新之前,分析一下下拉刷新场景以及达到的效果,常见的下拉刷新最少有四种状态
- 正常状态,下拉刷新头部不展示,用户可以正常操作列表
- 下拉状态,用户下拉列表,但是没有到达刷新时机,松开手后,刷新头部会自动隐藏
- 松开刷新状态,到达这个状态时候,刷新头部是完全展示的,用户松开手,即可刷新,如果下拉距离过大,列表会自动上移,完整的露出刷新头部,头部显示刷新中文案。
- 刷新中状态,请求数据的刷新态,在这种状态下,根据交互需求有不同的实现。
- 刷新中状态,用户不能操作列表
- 刷新中状态,可以滑动和操作列表,但刷新头部一直置顶
- 刷新中状态,可以滑动和操作列表,刷新头部会随着列表的滑动而一起滑动,参考欣慰微博下拉刷新。(大部分下拉刷新的交互效果)
前三种状态会根据用户手势的移动相互切换,大部分下拉刷新中状态交互是第三种,以新浪微博为参考蓝本,本文最终实现的效果也是以这个效果为目标。
3. 第一个例子链接
郭霖大神文章篇幅写的比较多,很多可以不用关心,关于下拉刷新的核心代码在ListView的OnTouchListener中,是通过修改Header的MarginTop值控制Header显示可见范围,到达下拉刷新的效果。缺点就是,每次更改Header的MarginTop值时候,会触发父布局重新onMeasure()/onLayout()方法,如果ListView中Item内容比较复杂,有卡顿现象,同时没有处理刷新中状态点击事件,如果要处理,需要额外添加复杂的逻辑。
3.1 第一个例子实现过程
3.1.1 初始化Header
父布局中包含下拉刷新的Header和ListView,在父布局的构造方法中实例化Header,并放入父布中。
public PtrFirstRefreshableView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
/**
* 实例化刷新头部并将刷新头部添加的父布局
*/
private void init() {
mHeader = new PtrFirstRefreshHeader(getContext());
touchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
addView(mHeader, 0);
}
3.1.2 隐藏Header
在父布局中onLayout()
方法中,设置Header的topMarigin,隐藏Header,设置ListView的点击监听器,记录一个标签isLayouted
保证只设置一次。
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
// 如果是第一次Layout, 做一些设置
if (changed && !isLayouted) {
isLayouted = true;
// 设置刷新头部MarginTop, 隐藏刷新头部
mHeaderHeight = mHeader.getHeight();
mHeaderLayoutParams = (LayoutParams) mHeader.getLayoutParams();
mHeader.setTopMargin(-mHeaderHeight);
// 设置ListView的事件监听
mListView = (ListView) getChildAt(1);
mListView.setOnTouchListener(this);
}
}
3.1.3 处理点击事件
这个地方逻辑复杂一些,获取用户点击事件后,调用checkTopShow()
方法检查当前是否需要处理点击事件,如果ListView的第一个Item展示,且顶部距离父布局为0,则可以下拉刷新。
在DOWN
事件中记录用户起始位置,注意一定要通过getRawY()
获取手指相对屏幕的位置,而不是通过getY()
获取手指相对ListView的位置,因为ListView会随着手指滑动而滑动,如果用getY()
获取位置会有偏差。
在MOVE
事件中,如果用户手指向上滑动,且刷新头部是完全隐藏的,不做处理;如果当时非刷新中状态,根据头部MarginTop的值更改当前刷新状态,同时更改刷新头部MarginTop。
在UP
事件中,用户松开手,如果当前状态是下拉状态,则隐藏刷新头部;如果当前状态是松开刷新状态,则更改状态为刷新中状态,同是隐藏多余margin,仅显示完整的刷新头部,同时调用回调监听(在RefreshingTask类中)。
在整个过程中,如果当前状态处于下拉状态或者松开刷新状态,设置ListView属性,让ListView失去焦点,否则那点击Item会一直处于点击状态。
public boolean onTouch(View v, MotionEvent event) {
checkTopShow(event);
if (ableToPull) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mDownY = event.getRawY();
break;
case MotionEvent.ACTION_MOVE:
float yMove = event.getRawY();
int distance = (int) (yMove - mDownY);
// 如果手指向上滑动,并且下拉头是完全隐藏的,不处理
if (distance <= 0 && mHeader.getTopMargin() <= -mHeaderHeight) {
return false;
}
if (distance < touchSlop) {
return false;
}
if (mStatus != STATUS_REFRESHING) {
if (mHeader.getTopMargin() > 0) {
mStatus = STATUS_RELEASE_TO_REFRESH;
} else {
mStatus = STATUS_PULL_TO_REFRESH;
}
// 通过偏移下拉头的topMargin值,来实现下拉效果
int topMargin = (distance / 2) - mHeaderHeight;
mHeader.setTopMargin(topMargin);
// 更新刷新头部圆环动画
mHeader.updateCircle(Math.abs(distance * 1f / 2f / mHeaderHeight));
}
break;
case MotionEvent.ACTION_UP:
default:
if (mStatus == STATUS_RELEASE_TO_REFRESH) {
// 松手时如果是释放立即刷新状态,就去调用正在刷新的任务
mStatus = STATUS_REFRESHING;
updateHeaderView();
new RefreshingTask().execute();
mHeader.startLoading();
} else if (mStatus == STATUS_PULL_TO_REFRESH) {
// 松手时如果是下拉状态,就去调用隐藏下拉头的任务
mStatus = STATUS_NORMAL;
updateHeaderView();
new HideHeaderTask().execute();
}
break;
}
if (mStatus == STATUS_PULL_TO_REFRESH ||
mStatus == STATUS_RELEASE_TO_REFRESH) {
mListView.setPressed(false);
mListView.setFocusable(false);
mListView.setFocusableInTouchMode(false);
updateHeaderView();
return true;
}
}
return false;
}
private void checkTopShow(MotionEvent event) {
View firstChild = mListView.getChildAt(0);
if (firstChild != null) {
// 如果列表第一个item可见且距离ListView顶部为0,则说明ListView已经到最顶部,此时可以下拉刷新
int firstVisiblePos = mListView.getFirstVisiblePosition();
if (firstVisiblePos == 0 && firstChild.getTop() == 0) {
ableToPull = true;
} else {
if (mHeader.getTopMargin() != -mHeaderHeight) {
mHeader.setTopMargin(-mHeaderHeight);
}
ableToPull = false;
}
} else {
ableToPull = true;
}
}
3.1.4 隐藏头部
在用户手指离开屏幕时候,会根据当前状态选择是隐藏头部还是仅展示头部,仅以隐藏头部为例,代码如下。关于AsyncTask的使用可以查看AsyncTask 第一篇使用篇
class HideHeaderTask extends AsyncTask<Void, Integer, Integer> {
@Override
protected Integer doInBackground(Void... params) {
int topMargin = mHeaderLayoutParams.topMargin;
while (true) {
topMargin = topMargin + SCROLL_SPEED;
if (topMargin <= -mHeaderHeight) {
topMargin = -mHeaderHeight;
break;
}
publishProgress(topMargin);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return topMargin;
}
@Override
protected void onProgressUpdate(Integer... topMargin) {
mHeader.setTopMargin(topMargin[0]);
}
@Override
protected void onPostExecute(Integer topMargin) {
mHeader.setTopMargin(topMargin);
}
}
3.1.5 请求完毕恢复原状态
网络请求完成后,要隐藏刷新头部,同时恢复原状态。
public void finishRefreshing() {
mStatus = STATUS_NORMAL;
new HideHeaderTask().execute();
mHeader.stopLoading();
}
3.2 第一个例子实现效果
最终的实现效果如下:
图-2 第一个例子实现效果图
3.3 第一个例子总结
该方案思路清晰,不需要对ListView进行拓展。缺点也比较明显,如果ListView中Item过于复杂,会有卡顿现象,而且代码中并没有对刷新中状态
的点击事件进行处理,如果在刷新中状态
中,滑动布局,会将刷新头部隐藏,在完成请求之前,无法将头部下拉展出,要对此进行修复,需要添加额外的逻辑。不推荐。
4. 第二个例子链接
原文章是使用scrollTo()/scrollBy()
方法实现下拉刷新,默认控件向上位移一段距离,正好将刷新头部隐藏。然后根据用户的手势通过scrollBy()
方法将刷新头部逐渐展示出来。因为使用scrollTo()/scrollBy()
来移动控件,是移动父布局中所有的子控件,如果逻辑处理不当会出现子控件部分移出父布局的情况,子控件显示出现问题。原文章实现很简单,下面实例的代码是做过优化后的代码实现。
4.1 第二个例子实现过程
4.1.1 初始化Header
实例化Header,并将其添加至父布局。
public PtrSecondRefreshableView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
mHeader = new PtrSecondRefreshHeader(context);
addView(mHeader, 0);
mScroller = new Scroller(getContext());
}
4.1.2 隐藏Header
在父布局的onMeasure()
方法中测量子View的大小,在onLayout()
方法中将刷新头部向上偏移,达到隐藏Header效果。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 测量子View大小
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
measureChild(child, widthMeasureSpec, heightMeasureSpec);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
mLayoutContentHeight = 0;
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (child == mHeader) { // 如果是刷新头部,向上偏移
child.layout(0, 0 - child.getMeasuredHeight(), child.getMeasuredWidth(), 0);
} else {
child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
mLayoutContentHeight += child.getMeasuredHeight();
}
}
}
4.1.3 拦截事件
在父布局的onTouchEvent
中设置Header的可见范围,所以用户手势在操作屏幕时候,在某些情况下父布局需要拦截点击事件。
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercept = false;
if(mStatus == STATUS_REFRESHING) {
return false;
}
// 记录此次触摸事件的y坐标
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
intercept = false;
break;
}
case MotionEvent.ACTION_MOVE: {
if (y > mLastMoveY) { // 下滑操作
View child = getChildAt(1);
if (child instanceof AdapterView) {
AdapterView adapterChild = (AdapterView) child;
// 判断AbsListView是否已经到达内容最顶部(如果已经到达最顶部,就拦截事件,自己处理滑动)
if (adapterChild.getFirstVisiblePosition() == 0
|| adapterChild.getChildAt(0).getTop() == 0) {
intercept = true;
}
}
}
break;
}
case MotionEvent.ACTION_UP: {
intercept = false;
break;
}
}
mLastMoveY = y;
return intercept;
}
4.1.4 处理点击事件
重写父布局的onTouchEvent()
,根据用户手势做出相应展示效果。
public boolean onTouchEvent(MotionEvent event) {
float nowY = event.getY();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
mLastMoveY = nowY;
break;
case MotionEvent.ACTION_MOVE:
float distance = mLastMoveY - nowY;
if(distance < 0) { // 如果是向下滑动,移动子View
// 头部没有完全展示
if(Math.abs(getScrollY()) <= mHeader.getMeasuredHeight()) {
mStatus = STATUS_PULL_TO_REFRESH;
scrollBy(0, (int) (distance * DRAG_COEFFICIENT_NORMAL));
} else { // 头部已经完全展示
scrollBy(0, (int) (distance * DRAG_COEFFICIENT_LIMIT));
mHeader.updateText(R.string.ptr_refresh_release);
mStatus = STATUS_RELEASE_TO_REFRESH;
}
} else { // 如果是向上滑动,移动子View
if(getScrollY() < 0) {
scrollBy(0, (int) distance);
}
if(Math.abs(getScrollY()) <= mHeader.getMeasuredHeight()) {
mStatus = STATUS_PULL_TO_REFRESH;
mHeader.updateText(R.string.ptr_refresh_normal);
}
}
// 更新刷新头部动画
mHeader.updateCircle(Math.abs(getScrollY() * 1f/ mHeader.getMeasuredHeight()));
break;
case MotionEvent.ACTION_UP:
default:
if(mStatus == STATUS_RELEASE_TO_REFRESH) { // 用户松开手后,如果是松开刷新状态,则回弹显示完整Header,并刷新数据
mHeader.updateText(R.string.ptr_refresh_refreshing);
mHeader.startLoading();
mStatus = STATUS_REFRESHING;
// 刷新状态回调
if(mListener != null) {
mListener.onRefresh();
}
mScroller.startScroll(0, getScrollY(), 0, -(getScrollY() + mHeader.getMeasuredHeight()), 200);
invalidate();
} else if(mStatus == STATUS_PULL_TO_REFRESH){ // 用户松开手后,如果是下拉刷新状态,则隐藏Header
mScroller.startScroll(0, getScrollY(), 0, -getScrollY(), 200);
invalidate();
mHeader.updateText(R.string.ptr_refresh_normal);
mStatus = STATUS_NORMAL;
}
break;
}
mLastMoveY = nowY;
return true;
}
4.1.5 处理刷新中状态
在onInterceptTouchEvent()
方法中,如果是刷新中状态,拦截事件,会导致用户无法操作ListView;如果不拦截事件,则事件会传递到ListView,这样当用户滚动列表ListView时候,刷新头部会一直悬浮在顶部。所以需要在dispatchTouchEvent()
方法中处理刷新中状态。
public boolean dispatchTouchEvent(MotionEvent event) {
int nowY = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastDownY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
if(mStatus == STATUS_REFRESHING) {
float distance = mLastDownY - nowY;
// 如果手势向下滑动且列表中第一个Item可见,向下移动全部子View
if(distance < 0
&& mListView.getFirstVisiblePosition() == 0
&& mListView.getChildAt(0).getTop()==0) {
scrollBy(0, (int) (distance * DRAG_COEFFICIENT_LIMIT));
isListViewMove = true;
mLastDownY = nowY;
return true;
} else { // 如果手势向上滑动
if(getScrollY() < 0) { // 当Header没有完全隐藏,移动全部子View;当Header完全隐藏,将事件传递给ListView
if(getScrollY() + distance > 0) {
scrollBy(0, 0);
} else {
scrollBy(0, (int) distance);
}
mLastDownY = nowY;
isListViewMove = true;
return true;
}
}
}
break;
case MotionEvent.ACTION_UP:
default:
// 用户抬起手,如果子View通过scrollBy移动过
if(isListViewMove) {
isListViewMove = false;
// 如果子View向下移动,向下移动距离大于Header高度,则自动回弹,显示完整Header
if(getScrollY() < 0 && Math.abs(getScrollY()) > mHeader.getMeasuredHeight()) {
mScroller.startScroll(0, getScrollY(), 0, -(getScrollY() + mHeader.getMeasuredHeight()), 200);
invalidate();
}
return true;
}
isListViewMove = false;
break;
}
return super.dispatchTouchEvent(event);
}
4.1.6 请求完毕恢复原状态
请求完成后,隐藏Header,恢复原状态。
public void finishRefresh() {
if(!mScroller.isFinished()) {
mScroller.abortAnimation();
}
scrollTo(0, 0);
mHeader.updateText(R.string.ptr_refresh_normal);
mHeader.stopLoading();
mStatus = STATUS_NORMAL;
}
4.2 第二个例子最终实现效果
最终效果如下图:
图-3 第二个例子效果图]
4.3 第二个例子总结
涉及点击事件的三个方法dispatchTouchEvent()
、onInterceptTouchEvent()
、onTouchEvent()
都有对点击事件不同的处理逻辑。虽然能勉强到达文章开头提到的效果,但是在零界点,特别是刷新头部Header刚好隐藏的零界点,会有卡顿现象,加上处理逻辑比较复杂。不推荐。
5. 第三个例子链接
第三个例子是很久的开源项目,不同于前两种实现方式,前面两种都是自定义一个父布局,然后将刷新头部和列表放入其中,第三个例子是直接将刷新头部放在列表ListView的头部,然后动态的设置刷新头部的高度,达到下拉刷新的效果。
5.1 实现过程
5.1.1 初始化Header
在ListView的构造函数中,初始化Heade作为ListView的HeaderView,这里有两点要注意,一是Header的布局文件,因为要动态设置Header的高度,所以布局文件需要嵌套一层,外面一层动态设置高度,里面一层包容所有的Header布局,高度不变;二是因为初始Header是不显示的,想要获取Header的真正高度,要在所有的View初始化以后才能获取。
public PtrThirdRefreshableView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
private void init(Context context) {
mScroller = new Scroller(context, new DecelerateInterpolator());
// 初始化Header,在初始化时候设置高度为0
mHeader = new PtrThirdRefreshHeader(context);
mHeaderContainer = mHeader.findViewById(R.id.dgp_header_container);
addHeaderView(mHeader);
mHeader.getViewTreeObserver().addOnGlobalLayoutListener(
new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
// 获取Header的完全展示时候的高度
getViewTreeObserver().removeGlobalOnLayoutListener(this);
mHeaderViewHeight = mHeaderContainer.getHeight();
mHeader.setContentHeight(mHeaderViewHeight);
}
});
}
5.1.2 处理点击事件
在MOVE
事件中,根据当前状态,动态更新刷新Header的高度;在UP
事件中根据当前Header展示高度,来做相应处理。
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:
final float deltaY = ev.getRawY() - mLastY;
mLastY = ev.getRawY();
// 如果列表中第一个Item是可见的, 且Header的部分可见或者向下滑动,则动态设置Header高度
if (getFirstVisiblePosition() == 0
&& (mHeader.getShowHeight() > 0 || deltaY > 0)) {
updateHeaderHeight(deltaY * OFFSET_RADIO);
return true;
}
break;
default:
mLastY = -1;
// 用户松开手时候,如果列表第一个Item可以见
if (getFirstVisiblePosition() == 0) {
// 如果Header展示的高度大于Header的真正高度,则可刷新
if (mHeader.getShowHeight() > mHeaderViewHeight) {
mPullRefreshing = true;
mHeader.updateText(R.string.ptr_refresh_refreshing);
mHeader.startLoading();
if (mRefreshListener != null) {
mRefreshListener.onRefresh();
}
}
// 根据当前情况重置Header高度
resetHeaderHeight();
return true;
}
break;
}
return super.onTouchEvent(ev);
}
/**
* 动态更新Header高度
*
* @param delta
*/
private void updateHeaderHeight(float delta) {
mHeader.setShowHeight((int) (delta + mHeader.getShowHeight()));
if (!mPullRefreshing) {
if (mHeader.getShowHeight() > mHeaderViewHeight) {
mHeader.updateText(R.string.ptr_refresh_release);
} else {
mHeader.updateText(R.string.ptr_refresh_normal);
}
}
setSelection(0);
}
5.1.3 请求完毕恢复原状态
请求完毕后,恢复原状态,这里没有使用设置Header的高度来隐藏Header,为了移动平滑通过Scroller将Header移动到屏幕外,不显示在屏幕中,达到隐藏的目的。使用Scroller需要复写computeScroll()
方法,才能移动。
public void finishRefresh() {
if (mPullRefreshing == true) {
mPullRefreshing = false;
resetHeaderHeight();
mHeader.stopLoading();
}
}
private void resetHeaderHeight() {
int height = mHeader.getShowHeight();
if (height == 0)
return;
if (mPullRefreshing && height <= mHeaderViewHeight) {
return;
}
int finalHeight = 0;
// 如果当前是刷新中状态,且Header的展示高度要大于Header的真实高度,则滑动列表,完整展示Header,否则隐藏Header
if (mPullRefreshing && height > mHeaderViewHeight) {
finalHeight = mHeaderViewHeight;
}
mScrollBack = SCROLL_BACK_HEADER;
mScroller.startScroll(0, height, 0, finalHeight - height,
SCROLL_DURATION);
invalidate();
}
/**
* 使用了Scroller, 需要复写该方法
*/
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
if (mScrollBack == SCROLL_BACK_HEADER) {
mHeader.setShowHeight(mScroller.getCurrY());
}
postInvalidate();
}
super.computeScroll();
}
5.2 第三个例子最终实现效果
最终效果如下图:
图-4 第三个例子效果图]
5.3 第三个例子总结
相比前两种实现方法,第三种是最简单最方便的实现方式,而且完全不用考虑刷新中状态的点击事件处理,唯一的缺点可能要对某些手机做一些适配,个人比较推荐。
6. 上拉加载
这三个例子中,后面两个例子都实现了上拉加载更多,而在市面上大部分应用没有上拉加载,看得出在实际场景中,上拉加载更多的使用频率不高。以大量使用列表的应用新浪微博为例子,滑动到列表最下方,继续向上拉时候不会像下拉刷新一样,有一定拉伸的弹簧效果,而是直接在加载了。我猜测这种实现是在Adapter中,当滑动到最后一个Item时候,直接返回一个加载中的View,同时请求数据,当用户看见这个View时候,其实请求已经发出去了(部分应用是设置一个按钮,然用户手动点击请求数据)。
7. 总结
下拉刷新开源库很多,上面列举出的几种实现可能不是最优的,个人认为最好的下拉刷新库是这个下拉刷新库,它基本支持所有的布局。但是在选择使用哪一个开源库的时候,并不是实现的最全最好的那个,而是最贴合实际业务的库。
在做下拉刷新时候,因为没有去对这个功能做出具体分析,走了很多弯路,浪费很多时间,以此为戒。
最后附上三个例子工程地址:https://github.com/Kyogirante/PtrDemo