android 有阻尼下拉刷新列表的实现方法
本文将会介绍有阻尼下拉刷新列表的实现,先来看看效果预览:
这是下拉状态:
这是下拉松开手指后listView回滚到刷新状态时的样子:
1. 如何调用
虽然效果图看起来样子不太好看,主要是因为那个蓝色的背景对不对,没关系,这只是一个背景而已,在了解了我们这个下拉刷新列表的实现之后,你就可以很轻松地修改这个背景,从而实现你想要的UI效果!话不多说,下面我们先来讲讲这个下拉刷新列表是如何使用的,这也是我们编写代码所要实现的目标。
final PullToRefreshListView eListView = (PullToRefreshListView) rootView.findViewById(R.id.profile_listView); eListView.setOnLoadCallBack(new PullToRefreshListView.OnLoadCallBack() { @Override public int whereToLoad() { return PullToRefreshListView.DEFAULT_WHERE_TO_LOAD; } @Override public void onLoad() { eListView.postDelayed(new Runnable() { @Override public void run() { eListView.setLoadingFinish(); } }, 5000); } @Override public void cancelLoad() { } @Override public Drawable refreshDrawable() { return new ColorDrawable(Color.CYAN); } }); eListView.setAdapter(new BaseAdapter() { @Override public int getCount() { return 30; } @Override public Object getItem(int position) { return null; } @Override public long getItemId(int position) { return 0; } @Override public View getView(int position, View convertView, ViewGroup parent) { TextView tv; if (convertView == null) { tv = new TextView(getActivity()); tv.setGravity(Gravity.CENTER_VERTICAL); tv.setHeight(200); tv.setBackgroundColor(Color.WHITE); } else { tv = (TextView) convertView; } tv.setText(position+""); return tv; } });
在上述代码中,我们可以看到PullToRefreshListView的使用在adapter上跟ListView是一样的,这个当然,因为我们实现下拉刷新功能并不需要修改数据适配器。我们也看到,PullToRefreshListView的实例需要设置一个OnLoadCallBack回调,该回调需要实现4个方法,包括:
/** * 下拉刷新的回调 */ public interface OnLoadCallBack { /** * 下拉结束后将listView定位到哪个位置等待刷新完成 * @return listView的定位y坐标值,in dp */ int whereToLoad(); /** * 下拉结束后进行刷新的回调 */ void onLoad(); /** * 取消刷新 */ void cancelLoad(); /** * 下拉刷新的背景 * @return 背景drawable */ Drawable refreshDrawable(); }
whereToLoad方法告知PullToRefreshListView对象下拉刷新时停留在哪个位置,具体点说,也就是上述第二章效果图中蓝色背景的高度。onLoad方法是下拉刷新的回调,调用者可以在这里实现刷新动作。cancelLoad方法是取消刷新动作的回调,调用者需要在这里将刷新动作取消。
根据上述方法,我们可以猜测,在onLoad方法中执行的应该是一个线程或者AsyncTask,而在cancelLoad方法中要做的就是将这个线程或者AsyncTask取消掉。最后还有一个refreshDrawable方法,这个方法是为修改listView的背景而提供给调用者的,调用者可以返回任意一个喜欢的背景Drawable。
知道如何调用以后,我们就要一步一步地实现这个PullToRefreshListView了。
2. 在dispatchDraw中重画子View实现下拉视觉
PullToRefreshListView实现的关键在于重画该listVIew的子View。重画ViewGroup的子View一般是在dispatchDraw方法中实现的。因此,我们的PullToRefreshListView继承自ListView类,重载其dispatchDraw方法。
@Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); if (distanceY > 0) { if (refreshDrawable == null) { refreshDrawable = onLoadCallBack.refreshDrawable(); } if (refreshDrawable == null) { canvas.drawColor(Color.GRAY); } else { int left = getPaddingLeft(); int top = getPaddingTop(); refreshDrawable.setBounds(left, top, getWidth()+left, getHeight()+top); refreshDrawable.draw(canvas); } canvas.save(); canvas.translate(getPaddingLeft(), getPaddingTop() + distanceY); for (int i=0;i<getChildCount();i++) { View child = getChildAt(i); drawChild(canvas, child, getDrawingTime()); } canvas.restore(); } }
重画子View的关键在于这一句代码:
canvas.translate(getPaddingLeft(), getPaddingTop() + distanceY);
在重画子View之前,我们需要先将canvas向上移动distanceY距离。这是为什么呢?我们先来看看在canvas画子View的方法
drawChild方法的文档是怎么说的。
protected boolean drawChild (Canvas canvas, View child, long drawingTime)
Added in API level 1 Draw one child of this View Group. This method is responsible for getting the canvas in the right state. This includes clipping, translating so that the child's scrolled origin is at 0, 0, and applying any animation transformations.
Parameters canvas The canvas on which to draw the child child Who to draw drawingTime The time at which draw is occurring Returns True if an invalidate() was issued
我来翻译一下,drawChild方法可以画出这个View Group的一个子View。该方法需要使canvas处于一个正确的状态,该状态就
是通过对canvas进行clip裁剪,translate评议操作等以使得该子View位于canvas的(0,0)位置。
什么意思呢?简单来说就是,drawChild方法会将child view画在canvas的(0,0)位置,因此为了使得该child view位于
canvas的正确位置,我们需要在重画之前对canvas进行裁剪平移等操作。举个例子,有一个canvas和一个child view,本来
child view要画在(0,0)位置上,于是呈现在我们眼前的child view就是位于canvas的顶部,但是如果在画之前我们将
canvas向上移动100个像素单位,然后再将child view画在(0,0)位置上,那么呈现在我们眼前的child view的位置将会是
位于canvas的(0,100)位置上。
根据以上分析,我们可以知道,重画子View的原理就是:
当PullToRefreshListView已经滚动到顶部的时候,通过监控滑动手势来计算distanceY,从而确定要将canvas向上移动多少再重画子View,就可以实现PullToRefreshListView跟随滑动手势进行下拉的功能了。
3. 计算下拉距离
实现了重画以后,我们需要做的就是如何计算distanceY。我们的初步想法是,根据滑动的距离来计算,考虑到我们要实现阻尼效果,即随着滑动距离的变长,PullToRefreshListView的下拉距离会越来越短。在PullToRefreshListView实现中,我使用指数函数来实现这一阻尼效果,具体计算如下:
distanceY = ev.getY() - pullStartY; distanceY = (float) (Math.exp(-ev.getY() / pullStartY / 40) * distanceY);
我们知道负指数是加速度随距离变小的单调递增函数,我使用手指滑动距离计算负指数作为PullToRefreshListView的滑动距离的参考标准,便可以实现有阻尼下拉效果。
4. 监控手势判断ListView是否进入下拉状态并更新distanceY
更进一步,我们要实现的就是对手势的监控,在PullToRefreshListView中,我们在onTouchEvent方法中进行处理。
@Override public boolean onTouchEvent(MotionEvent ev) { if (lastAction == -1 && ev.getActionMasked() == MotionEvent.ACTION_DOWN) { // 按下的时候 lastAction = MotionEvent.ACTION_DOWN; cancelAnimating(); L.d(TAG, "touch down"); } else if (lastAction == MotionEvent.ACTION_MOVE && ev.getActionMasked() == MotionEvent.ACTION_UP) { // 放开手指,开始回滚 isPulling = false; lastAction = -1; startAnimating(); L.d(TAG, "touch up"); } else if (lastAction == MotionEvent.ACTION_DOWN) { if (ev.getActionMasked() == MotionEvent.ACTION_MOVE) { // 在按下手指的基础上,开始滑动 if (isTop && !isPulling) { // listView在顶部而且不处于下拉刷新状态,开始下拉 pullStartY = ev.getY(); lastAction = MotionEvent.ACTION_MOVE; isPulling = true; } } } else if (lastAction == MotionEvent.ACTION_MOVE) { if (isTop) { // 下拉 distanceY = ev.getY() - pullStartY; L.d(TAG, distanceY + ""); if (distanceY > 0) { distanceY = (float) (Math.exp(-ev.getY() / pullStartY / 40) * distanceY); // 在下拉状态时取消系统对move动作的响应,完全由本类响应 ev.setAction(MotionEvent.ACTION_DOWN); } else { distanceY = 0; // 在下拉过程中往上拉动该listView使得其回到顶部位置,则将该move动作交由系统进行响应 ev.setAction(MotionEvent.ACTION_MOVE); } } else { // 在下拉过程中往上拉动listView使listView往下滚动到其没有滚动到顶部,则取消其下拉状态,回到手指按下的初始状态 lastAction = MotionEvent.ACTION_DOWN; isPulling = false; distanceY = 0; } } return super.onTouchEvent(ev); }
这一段代码相对有一点复杂,我们慢慢解析。首先,我们有一个lastAction变量来记录上一个手势是什么,有一个isPulling变量来记录当前PullToRefreshListView是否处于下拉状态,有一个isTop变量记录当前PullToRefreshListView是否已经滚动到顶部。
在onTouchEvent方法的重载实现中,一开始PullToRefreshListView没有接受任何手势,然后当用户按下手指出发ACTION_DOWN事件时,我记录下这个动作,然后当用户进行滑动时,如果此时PullToRefreshListView没有“滚动到顶部”,则不做任何处理,反之则将lastAction更新为ACTION_MOVE状态,更新isPulling变量,记录当前手指的位置作为计算下拉距离的起始位置,开始下拉刷新,然后在下拉的过程中计算PullToRefreshListView下拉的距离以重画子View。
在这个手势处理的实现中,当用户在下拉过程中突然将PullToRefreshListView往上拉,如果将PullToRefreshListView 拉到不处于“滚动到顶部的状态”时,则重置下拉状态,使得:
lastAction = MotionEvent.ACTION_DOWN;
于是PullToRefreshListView接下来的下滑手势响应权被交还给系统,知道用户又将PullToRefreshListView下拉到“滚动到顶部”状态,则又重新执行上述操作,使PullToRefreshListView进入下拉状态。
5. 如何判断ListView是否已经滚动到顶部
下一步,我们如何判断ListView是否处于“滚动到顶部”状态呢?这一问题我PullToRefreshListView的onScroll中解决。
setOnScrollListener(new OnScrollListener() { @Override public void onScrollStateChanged(AbsListView view, int scrollState) { } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { // 没有子view的时候(没有数据,或者被拉到看不到子view),意味着该listView滚动到顶部 if (getChildCount() == 0) { isTop = true; return; } if (firstVisibleItem == 0) { View firstView = getChildAt(0); if (firstView.getTop() + distanceY >= 0) { // 第一个view可见且其相对parent(该listView)的顶部距离大于等于0,意味着该listView也是滚动到顶部 isTop = true; return; } } isTop = false; } });
为PullToRefreshListView设置一个OnScrollListener回调,并在其onScroll方法中监控其滚动位置,具体看注释也已经一目了然,我就不多解释了。
6. 下拉后的回滚动画
最后,当下拉结束松开手指时,我们需要为PullToRefreshListView执行一个回滚的动画,我们在onTouchEvent方法中看到:
// ...... else if (lastAction == MotionEvent.ACTION_MOVE && ev.getActionMasked() == MotionEvent.ACTION_UP) { // 放开手指,开始回滚 isPulling = false; lastAction = -1; startAnimating(); L.d(TAG, "touch up"); } // ......
startAnimating方法的实现如下:
/** * 下拉结束时进行回滚动画并执行刷新动作 */ private void startAnimating() { int whereToLoad = dp2px(onLoadCallBack.whereToLoad()); final boolean toLoad; if (distanceY <= whereToLoad) { pullCancelAnimator = ValueAnimator.ofFloat(distanceY, 0); toLoad = false; } else { pullCancelAnimator = ValueAnimator.ofFloat(distanceY, whereToLoad); toLoad = true; } pullCancelAnimator.setDuration((long) (DEFAULT_BASE_ANIMATING_TIME_PER_100DP*px2dp(distanceY)/100)); pullCancelAnimator.setInterpolator(new AccelerateDecelerateInterpolator()); pullCancelAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { distanceY = (float) animation.getAnimatedValue(); ViewCompat.postInvalidateOnAnimation(PullToRefreshListView.this); } }); pullCancelAnimator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { } @Override public void onAnimationEnd(Animator animation) { post(new Runnable() { @Override public void run() { pullCancelAnimator = null; if (toLoad) { onLoadCallBack.onLoad(); } } }); } @Override public void onAnimationCancel(Animator animation) { post(new Runnable() { @Override public void run() { pullCancelAnimator = null; if (toLoad) { onLoadCallBack.cancelLoad(); } } }); } @Override public void onAnimationRepeat(Animator animation) { } }); pullCancelAnimator.start(); }
我使用ValueAnimator来实现这一回滚动画,其中为ValueAnimator设置的回调中,在动画更新和动画结束以及动画取消中分别调用了OnLoadCallBack的3歌回调方法,从而实现PullToRefreshListView的下拉刷新动作。我们可以看到,onLoad方法是在UI线程执行的,因此如果在onLoad方法中执行耗时操作的话,需要在后台线程中操作,这与我们前面的解析是对应的。
7. 改进和问题
(1) 我们可以将onLoad回调修改成一个返回一个异步任务对象的方法,然后PullToRefreshListView在下拉结束后执行这个异步任务,因此我们就可以不需要cancelLoading回调了,直接就可以在PullToRefreshListView内部进行取消操作,这样做可以增强封装性,但相对目前的做法自由度就没有那么高了。
(2) 回滚动画应该也可以进行优化,具体怎么优化我也不清楚。。。各位朋友有好的想法可以在评论区提议一下,谢谢~
(3) 下拉的时候对多点触碰的响应并不完美,虽然也可以接受,但是做不到像qq客户端的聊天列表那样。
8. 源码
至此,我已经解析了如何实现一个下拉刷新列表,PullToRefreshListView的源码如下。
import android.animation.Animator; import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.drawable.Drawable; import android.support.v4.view.ViewCompat; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.view.animation.AccelerateDecelerateInterpolator; import android.widget.AbsListView; import android.widget.ListView; import com.ivan.healthcare.healthcare_android.log.L; /** * 支持下拉刷新的的listView * Created by Ivan on 16/2/14. */ public class PullToRefreshListView extends ListView { private final String TAG = "PullToRefreshListView"; private final int DEFAULT_BASE_ANIMATING_TIME_PER_100DP = 150; public static final int DEFAULT_WHERE_TO_LOAD = 80; private int lastAction = -1; private float pullStartY = -1; private boolean isTop = true; private float distanceY = 0; private boolean isPulling = false; private ValueAnimator pullCancelAnimator; private Context context; private Drawable refreshDrawable; private OnLoadCallBack onLoadCallBack = new OnLoadCallBack() { @Override public int whereToLoad() { return DEFAULT_WHERE_TO_LOAD; } @Override public void onLoad() { } @Override public void cancelLoad() { } @Override public Drawable refreshDrawable() { return null; } }; public PullToRefreshListView(Context context) { super(context); initView(context); } public PullToRefreshListView(Context context, AttributeSet attrs) { super(context, attrs); initView(context); } private void initView(Context context) { this.context = context; setOnScrollListener(new OnScrollListener() { @Override public void onScrollStateChanged(AbsListView view, int scrollState) { } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { // 没有子view的时候(没有数据,或者被拉到看不到子view),意味着该listView滚动到顶部 if (getChildCount() == 0) { isTop = true; return; } if (firstVisibleItem == 0) { View firstView = getChildAt(0); if (firstView.getTop() + distanceY >= 0) { // 第一个view可见且其相对parent(该listView)的顶部距离大于等于0,意味着该listView也是滚动到顶部 isTop = true; return; } } isTop = false; } }); } @Override public boolean onTouchEvent(MotionEvent ev) { if (lastAction == -1 && ev.getActionMasked() == MotionEvent.ACTION_DOWN) { // 按下的时候 lastAction = MotionEvent.ACTION_DOWN; cancelAnimating(); L.d(TAG, "touch down"); } else if (lastAction == MotionEvent.ACTION_MOVE && ev.getActionMasked() == MotionEvent.ACTION_UP) { // 放开手指,开始回滚 isPulling = false; lastAction = -1; startAnimating(); L.d(TAG, "touch up"); } else if (lastAction == MotionEvent.ACTION_DOWN) { if (ev.getActionMasked() == MotionEvent.ACTION_MOVE) { // 在按下手指的基础上,开始滑动 if (isTop && !isPulling) { // listView在顶部而且不处于下拉刷新状态,开始下拉 pullStartY = ev.getY(); lastAction = MotionEvent.ACTION_MOVE; isPulling = true; } } } else if (lastAction == MotionEvent.ACTION_MOVE) { if (isTop) { // 下拉 distanceY = ev.getY() - pullStartY; L.d(TAG, distanceY + ""); if (distanceY > 0) { distanceY = (float) (Math.exp(-ev.getY() / pullStartY / 40) * distanceY); // 在下拉状态时取消系统对move动作的响应,完全由本类响应 ev.setAction(MotionEvent.ACTION_DOWN); } else { distanceY = 0; // 在下拉过程中往上拉动该listView使得其回到顶部位置,则将该move动作交由系统进行响应 ev.setAction(MotionEvent.ACTION_MOVE); } } else { // 在下拉过程中往上拉动listView使listView往下滚动到其没有滚动到顶部,则取消其下拉状态,回到手指按下的初始状态 lastAction = MotionEvent.ACTION_DOWN; isPulling = false; distanceY = 0; } } return super.onTouchEvent(ev); } @Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); if (distanceY > 0) { if (refreshDrawable == null) { refreshDrawable = onLoadCallBack.refreshDrawable(); } if (refreshDrawable == null) { canvas.drawColor(Color.GRAY); } else { int left = getPaddingLeft(); int top = getPaddingTop(); refreshDrawable.setBounds(left, top, getWidth()+left, getHeight()+top); refreshDrawable.draw(canvas); } canvas.save(); canvas.translate(getPaddingLeft(), getPaddingTop() + distanceY); for (int i=0;i<getChildCount();i++) { View child = getChildAt(i); drawChild(canvas, child, getDrawingTime()); } canvas.restore(); } } /** * 下拉结束时进行回滚动画并执行刷新动作 */ private void startAnimating() { int whereToLoad = dp2px(onLoadCallBack.whereToLoad()); final boolean toLoad; if (distanceY <= whereToLoad) { pullCancelAnimator = ValueAnimator.ofFloat(distanceY, 0); toLoad = false; } else { pullCancelAnimator = ValueAnimator.ofFloat(distanceY, whereToLoad); toLoad = true; } pullCancelAnimator.setDuration((long) (DEFAULT_BASE_ANIMATING_TIME_PER_100DP*px2dp(distanceY)/100)); pullCancelAnimator.setInterpolator(new AccelerateDecelerateInterpolator()); pullCancelAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { distanceY = (float) animation.getAnimatedValue(); ViewCompat.postInvalidateOnAnimation(PullToRefreshListView.this); } }); pullCancelAnimator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { } @Override public void onAnimationEnd(Animator animation) { post(new Runnable() { @Override public void run() { pullCancelAnimator = null; if (toLoad) { onLoadCallBack.onLoad(); } } }); } @Override public void onAnimationCancel(Animator animation) { post(new Runnable() { @Override public void run() { pullCancelAnimator = null; if (toLoad) { onLoadCallBack.cancelLoad(); } } }); } @Override public void onAnimationRepeat(Animator animation) { } }); pullCancelAnimator.start(); } private void cancelAnimating() { if (pullCancelAnimator != null) { pullCancelAnimator.cancel(); } } private float px2dp(float pxvalue) { return (pxvalue - 0.5f) /context.getResources().getDisplayMetrics().density; } private int dp2px(float dpvalue) { return (int) (dpvalue * context.getResources().getDisplayMetrics().density + 0.5f); } /** * 下拉刷新的回调 */ public interface OnLoadCallBack { /** * 下拉结束后将listView定位到哪个位置等待刷新完成 * @return listView的定位y坐标值,in dp */ int whereToLoad(); /** * 下拉结束后进行刷新的回调 */ void onLoad(); /** * 取消刷新 */ void cancelLoad(); /** * 下拉刷新的背景 * @return 背景drawable */ Drawable refreshDrawable(); } /** * 设置下拉刷新回调 * @param cb 回调 */ public void setOnLoadCallBack(OnLoadCallBack cb) { this.onLoadCallBack = cb; } /** * 刷新动作结束后调用该方法结束刷新,使得listView回滚到顶部 */ public void setLoadingFinish() { startAnimating(); } }
上一篇:Android项目实现视频播放器
栏 目:Android代码
下一篇:Android视图控件架构分析之View、ViewGroup
本文地址:http://www.codeinn.net/misctech/227788.html