Android实现支持所有View的通用的下拉刷新控件
下拉刷新对于一个app来说是必不可少的一个功能,在早期大多数使用的是chrisbanes的PullToRefresh,或是修改自该框架的其他库。而到现在已经有了更多的选择,github上还是有很多体验不错的下拉刷新。
而下拉刷新主要有两种实现方式:
1. 在ListView中添加header和footer,监听ListView的滑动事件,动态设置header/footer的高度,但是这种方式只适用于ListView,RecyclerView。
2. 第二种方式则是继承ViewGroup或其子类,监听事件,通过scroll或Layout的方式移动child。如图(又分两种情况)
Layout时将header放到屏幕外面,target则填充满屏幕。这个也是SwipeRefreshLayout的实现原理(第二种,只下拉header)
这两种(指的是继承ListView或继承ViewGroup)下拉刷新的实现方式主要有以下区别
而今天,我打算先讲第二种方式实现方式,继承ViewGroup,代码可以直接参考SwipeRefreshLayout,或者pullToRefresh,或者ultra-pull-to-refresh
一、思考和需求
下拉刷新需要几个状态:Reset?C> Pull ?C > Refreshing ?C >Completed ?C>Reset
为了应对各式各样的下拉刷新设计,我们应该提供设置自定义的Header,开发者可以通过实现接口从而自定义自己的header。
而且header可以有两种显示方式,一种是只下拉header,另外一种则是header和target一起下拉。
二、着手实现代码
2.1 定义Header的接口,创建自定义Layout
/** * Created by AItsuki on 2016/6/13. * */ public enum State { RESET, PULL, LOADING, COMPLETE }
/** * Created by AItsuki on 2016/6/13. * */ public interface RefreshHeader { /** * 松手,头部隐藏后会回调这个方法 */ void reset(); /** * 下拉出头部的一瞬间调用 */ void pull(); /** * 正在刷新的时候调用 */ void refreshing(); /** * 头部滚动的时候持续调用 * @param currentPos target当前偏移高度 * @param lastPos target上一次的偏移高度 * @param refreshPos 可以松手刷新的高度 * @param isTouch 手指是否按下状态(通过scroll自动滚动时需要判断) * @param state 当前状态 */ void onPositionChange(float currentPos, float lastPos, float refreshPos, boolean isTouch, State state); /** * 刷新成功的时候调用 */ void complete(); }
package com.aitsuki.custompulltorefresh; import android.content.Context; import android.graphics.Color; import android.util.AttributeSet; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.widget.ImageView; /** * Created by AItsuki on 2016/6/13. * - */ public class RefreshLayout extends ViewGroup { private View refreshHeader; private View target; private int currentTargetOffsetTop; // target偏移距离 private boolean hasMeasureHeader; // 是否已经计算头部高度 private int touchSlop; private int headerHeight; // header高度 private int totalDragDistance; // 需要下拉这个距离才进入松手刷新状态,默认和header高度一致 public RefreshLayout(Context context) { this(context, null); } public RefreshLayout(Context context, AttributeSet attrs) { super(context, attrs); touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); // 添加默认的头部,先简单的用一个ImageView代替头部 ImageView imageView = new ImageView(context); imageView.setImageResource(R.drawable.one_piece); imageView.setBackgroundColor(Color.BLACK); setRefreshHeader(imageView); } /** * 设置自定义header */ public void setRefreshHeader(View view) { if (view != null && view != refreshHeader) { removeView(refreshHeader); // 为header添加默认的layoutParams ViewGroup.LayoutParams layoutParams = view.getLayoutParams(); if (layoutParams == null) { layoutParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); view.setLayoutParams(layoutParams); } refreshHeader = view; addView(refreshHeader); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); if (target == null) { ensureTarget(); } if (target == null) { return; } // ----- measure target ----- // target占满整屏 target.measure(MeasureSpec.makeMeasureSpec( getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec( getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)); // ----- measure refreshView----- measureChild(refreshHeader, widthMeasureSpec, heightMeasureSpec); if (!hasMeasureHeader) { // 防止header重复测量 hasMeasureHeader = true; headerHeight = refreshHeader.getMeasuredHeight(); // header高度 totalDragDistance = headerHeight; // 需要pull这个距离才进入松手刷新状态 } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { final int width = getMeasuredWidth(); final int height = getMeasuredHeight(); if (getChildCount() == 0) { return; } if (target == null) { ensureTarget(); } if (target == null) { return; } // onLayout执行的时候,要让target和header加上偏移距离(初始0),因为有可能在滚动它们的时候,child请求重新布局,从而导致target和header瞬间回到原位。 // target铺满屏幕 final View child = target; final int childLeft = getPaddingLeft(); final int childTop = getPaddingTop() + currentTargetOffsetTop; final int childWidth = width - getPaddingLeft() - getPaddingRight(); final int childHeight = height - getPaddingTop() - getPaddingBottom(); child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); // header放到target的上方,水平居中 int refreshViewWidth = refreshHeader.getMeasuredWidth(); refreshHeader.layout((width / 2 - refreshViewWidth / 2), -headerHeight + currentTargetOffsetTop, (width / 2 + refreshViewWidth / 2), currentTargetOffsetTop); } /** * 将第一个Child作为target */ private void ensureTarget() { // Don't bother getting the parent height if the parent hasn't been laid // out yet. if (target == null) { for (int i = 0; i < getChildCount(); i++) { View child = getChildAt(i); if (!child.equals(refreshHeader)) { target = child; break; } } } } }
MainActivity中的布局如下,先用一个TextView作为Target
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context="com.aitsuki.custompulltorefresh.MainActivity"> <com.aitsuki.custompulltorefresh.RefreshLayout android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:layout_width="match_parent" android:layout_height="match_parent" android:text="Target" android:textSize="30sp" android:gravity="center" android:background="#FFDAB9" /> </com.aitsuki.custompulltorefresh.RefreshLayout> </FrameLayout>
运行后结果如图如下,但是我们还没有监听事件,所以此时还无法滑动。
2.2 处理事件分发
控件已经测量布局好了,现在就开始处理事件分发,对于事件分发还不了解的应该先去复习下……
对于多点触控的处理:
记录活动手指的id(activePointerId),通过此ID获取move事件的坐标。
1.在手指按下的时候,记录下activePointerId
2.第二根手指按下的时候,更新activePointerId。(我们让第二根手指作为活动手指,忽略第一个手指的move)
3.当其中一根手指抬起时,如果是第一根手指,那么不做处理,如果是第二根手指抬起,也就是活动手指抬起的话,将活动手指改回第一根。
对于事件分发一般有两种处理方式
1. 在onIntercept + onTouchEvnet中处理
2. 在dispatchTouchEvent中处理
在这里我选择了第二种方式
首先了解DispatchTouchEvent返回值的含义
重写dispatchTouchEvent的时候,无论你是return true,亦或是return false都会导致child接受不到事件。
return true : 告诉parent,这个事件我消费了。如果这个是down事件,那么我就会作为一个target或者说handle(事件持有者),后续的move事件或者up事件等,都会直接分发到我这里,不继续往下分发。
return false:告诉parent,这个事件我不需要,那么会交回给parent的onTouchEvnet处理
只有return super.dispatchTouchEvent的时候才会将事件继续往下传递。
上面只说了最简单的一点,如果对事件分发不了解的话需要看看,真的很重要。
分析
在dispatch中,即使child响应了事件,我们也能拿到所有事件。
这样我们就可以很简单的控制头部是否能下拉,那么如何拦截child的事件呢?
可以在合适的时候分发一个cancel事件给child,那么就相当于拦截了!
虽然我们一直都响应着事件,但肯定是不能所有事件都接收的,以下情况是需要我们处理的
1.如果是下拉,并且child不能往上滚动
2.如果上划,并且target不在顶部的时候
3.如果是这些时候,我们拦截child的事件(派发cancel事件)
代码如下
@Override public boolean dispatchTouchEvent(MotionEvent ev) { if (!isEnabled() || target == null) { return super.dispatchTouchEvent(ev); } final int actionMasked = ev.getActionMasked(); // support Multi-touch switch (actionMasked) { case MotionEvent.ACTION_DOWN: Log.e(TAG, "ACTION_DOWN"); activePointerId = ev.getPointerId(0); isTouch = true; // 手指是否按下 hasSendCancelEvent = false; mIsBeginDragged = false; // 是否开始下拉 lastTargetOffsetTop = currentTargetOffsetTop; // 上一次target的偏移高度 currentTargetOffsetTop = target.getTop(); // 当前target偏移高度 initDownX = lastMotionX = ev.getX(0); // 手指按下时的坐标 initDownY = lastMotionY = ev.getY(0); super.dispatchTouchEvent(ev); return true; // return true,否则可能接收不到move和up事件 case MotionEvent.ACTION_MOVE: if (activePointerId == INVALID_POINTER) { Log.e(TAG, "Got ACTION_MOVE event but don't have an active pointer id."); return super.dispatchTouchEvent(ev); } lastEvent = ev; // 最后一次move事件 float x = ev.getX(MotionEventCompat.findPointerIndex(ev,activePointerId)); float y = ev.getY(MotionEventCompat.findPointerIndex(ev,activePointerId)); float xDiff = x - lastMotionX; float yDiff = y - lastMotionY; float offsetY = yDiff * DRAG_RATE; lastMotionX = x; lastMotionY = y; if(!mIsBeginDragged && Math.abs(y - initDownY) > touchSlop) { mIsBeginDragged = true; } if (mIsBeginDragged) { boolean moveDown = offsetY > 0; // ↓ boolean canMoveDown = canChildScrollUp(); boolean moveUp = !moveDown; // ↑ boolean canMoveUp = currentTargetOffsetTop > START_POSITION; // 判断是否拦截事件 if ((moveDown && !canMoveDown) || (moveUp && canMoveUp)) { moveSpinner(offsetY); return true; } } break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: isTouch = false; activePointerId = INVALID_POINTER; break; case MotionEvent.ACTION_POINTER_DOWN: int pointerIndex = MotionEventCompat.getActionIndex(ev); if (pointerIndex < 0) { Log.e(TAG, "Got ACTION_POINTER_DOWN event but have an invalid action index."); return super.dispatchTouchEvent(ev); } lastMotionX = ev.getX(pointerIndex); lastMotionY = ev.getY(pointerIndex); activePointerId = MotionEventCompat.getPointerId(ev, pointerIndex); break; case MotionEvent.ACTION_POINTER_UP: onSecondaryPointerUp(ev); lastMotionY = ev.getY(ev.findPointerIndex(activePointerId)); lastMotionX = ev.getX(ev.findPointerIndex(activePointerId)); break; } return super.dispatchTouchEvent(ev); }
private void onSecondaryPointerUp(MotionEvent ev) { final int pointerIndex = MotionEventCompat.getActionIndex(ev); final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex); if (pointerId == activePointerId) { // This was our active pointer going up. Choose a new // active pointer and adjust accordingly. final int newPointerIndex = pointerIndex == 0 ? 1 : 0; lastMotionY = ev.getY(newPointerIndex); lastMotionX = ev.getX(newPointerIndex); activePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex); } }
public boolean canChildScrollUp() { if (android.os.Build.VERSION.SDK_INT < 14) { if (target instanceof AbsListView) { final AbsListView absListView = (AbsListView) target; return absListView.getChildCount() > 0 && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0) .getTop() < absListView.getPaddingTop()); } else { return ViewCompat.canScrollVertically(target, -1) || target.getScrollY() > 0; } } else { return ViewCompat.canScrollVertically(target, -1); } }
以上就是事件的处理,我们还需要在header下拉之前发送cancel事件给child
private void moveSpinner(float diff) { int offset = Math.round(diff); if (offset == 0) { return; } // 发送cancel事件给child if (!hasSendCancelEvent && isTouch && currentTargetOffsetTop > START_POSITION) { sendCancelEvent(); hasSendCancelEvent = true; } int targetY = Math.max(0, currentTargetOffsetTop + offset); // target不能移动到小于0的位置…… offset = targetY - currentTargetOffsetTop; setTargetOffsetTopAndBottom(offset); }
private void setTargetOffsetTopAndBottom(int offset) { if (offset == 0) { return; } target.offsetTopAndBottom(offset); refreshHeader.offsetTopAndBottom(offset); lastTargetOffsetTop = currentTargetOffsetTop; currentTargetOffsetTop = target.getTop(); invalidate(); }
private void sendCancelEvent() { if (lastEvent == null) { return; } MotionEvent ev = MotionEvent.obtain(lastEvent); ev.setAction(MotionEvent.ACTION_CANCEL); super.dispatchTouchEvent(ev); }
代码有点多,不过没关系,其实很多都是从SwipeRefreshLayout中复制过来的。
我们来看看代码运行后的效果,很不错,就是模拟器录屏有点卡=。=
换成ListView试试, 也没有问题。
多点触控也是可以的,但是模拟器我没法演示了。
2.3 添加自动滚动
头虽然可以下拉了, 但是拉下来后就不会回去了啊,我们需要在手指松开让头部自动回到原位。
可以使用动画,可以使用ValueAnimator计算距离移动,也可以使用Scroller计算距离移动。
但是选择第三种是比较好的,为什么呢。
首先如果使用动画,在回去的过程中我们无法下拉,我们想做的是一个可以在任何时候都能上下拉的,就像ListView添加头的哪种效果。
valueAnimator也是,不好停止。
但是scroller却可以使用forceFinish强行停止计算。
松开手指时,我们通过scroller计算每次移动的offset,然后调用moveSpinner即可。
在手指按下的时候,需要停止scroller。
我们先写一个内部类,封装一下滚动功能
private class AutoScroll implements Runnable { private Scroller scroller; private int lastY; public AutoScroll() { scroller = new Scroller(getContext()); } @Override public void run() { boolean finished = !scroller.computeScrollOffset() || scroller.isFinished(); if (!finished) { int currY = scroller.getCurrY(); int offset = currY - lastY; lastY = currY; moveSpinner(offset); // 调用此方法移动header和target post(this); onScrollFinish(false); } else { stop(); onScrollFinish(true); } } public void scrollTo(int to, int duration) { int from = currentTargetOffsetTop; int distance = to - from; stop(); if (distance == 0) { return; } scroller.startScroll(0, 0, 0, distance, duration); post(this); } private void stop() { removeCallbacks(this); if (!scroller.isFinished()) { scroller.forceFinished(true); } lastY = 0; } }
然后这个是回调,暂时用户不上,但还是先写好吧。
/** * 在scroll结束的时候会回调这个方法 * @param isForceFinish 是否是强制结束的 */ private void onScrollFinish(boolean isForceFinish) { }
我们在构造中初始化AutoScroll,然后分别在ActionDown和ActionUp中分别调用stop和scrollto即可,如下
case MotionEvent.ACTION_DOWN: //... autoScroll.stop(); //... break
case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: //... if(currentTargetOffsetTop > START_POSITION) { autoScroll.scrollTo(START_POSITION, SCROLL_TO_TOP_DURATION); } //...
运行效果如下图
2.4 添加刷新状态
最开始的时候我们也新建了一个枚举,设置了几种状态,分别是 RESET, PULL, LOADING, COMPLETE
而我们的初始状态应该为RESET
private State state = State.RESET;
再分析一下,这几种状态什么时候互相切换:
1. 在RESET状态时,第一次下拉出现header的时候,设置状态变成PULL
2. 在PULL或者COMPLETE状态时,header回到顶部的时候,状态变回RESET
3. 如果是从底部回到顶部的过程(往上滚动),并且手指是松开状态, 并且当前是PULL状态,状态变成LOADING,这时候我们需要强制停止autoScroll。并且正在刷新中的侦听器也在这里调用(onRefresh())
4. 在LOADING状态中,想变成其他状态,需要提供公共方法给外部调用
首先,我们先写一个改变状态的方法,在状态改变的同时要回调给header。
private void changeState(State state) { this.state = state; RefreshHeader refreshHeader = this.refreshHeader instanceof RefreshHeader ? ((RefreshHeader) this.refreshHeader) : null; if (refreshHeader != null) { switch (state) { case RESET: refreshHeader.reset(); break; case PULL: refreshHeader.pull(); break; case LOADING: refreshHeader.refreshing(); break; case COMPLETE: refreshHeader.complete(); break; } } }
还有,提供外部设置刷新成功的方法。
因为刷新成功后需要将header滚动回原位,所以需要做以下判断
1. 如果已经在原位,那么直接将状态改成Reset
2. 如果不在原位,延时500毫秒后自动滚动回原位。这里延时500毫秒是为了展示刷新成功的提示,否则在网速很快的情况下,刷新成功后header立即回到原位体验性不好,感觉就像是下拉后立即就自动回去了。
3. 在自动回滚时还需要判断当前手指是否在触摸状态,如果正在触摸,代表用户可能并不想header回去,所以这时候我们不能让头部滚动。
4. 再者就是,如果在延时的500内,用户按下了手指,我们需要将这个runnable取消,在ActionDown中RemoveCallBack即可。总的来说一句话就是,用户必须持有header的绝对控制权,在手指按下时,header不应该出现自动滚动的情况。
public void refreshComplete() { changeState(State.COMPLETE); // if refresh completed and the target at top, change state to reset. if (currentTargetOffsetTop == START_POSITION) { changeState(State.RESET); } else { // waiting for a time to show refreshView completed state. // at next touch event, remove this runnable if (!isTouch) { postDelayed(delayToScrollTopRunnable, SHOW_COMPLETED_TIME); } } } // 刷新成功,显示500ms成功状态再滚动回顶部,这个runnalbe需要在ActionDown事件中Remove private Runnable delayToScrollTopRunnable = new Runnable() { @Override public void run() { autoScroll.scrollTo(START_POSITION, SCROLL_TO_TOP_DURATION); } };
提供设置正在刷新回调的方法
当用户松开手指,进入刷新状态时我们需要回调这个方法。
// 定义一个侦听器 public interface OnRefreshListener { void onRefresh(); } // 提供外部设置方法 public void setRefreshListener(OnRefreshListener refreshListener) { this.refreshListener = refreshListener; }
做完以上几部,我们算是完成了LOADING到COMPLETE的状态切换,余下的几个状态我们则需要在movespinner这个方法中控制,上面也已经分析过了逻辑,那么可以直接看代码了。
private void moveSpinner(float diff) { int offset = Math.round(diff); if (offset == 0) { return; } // 发送cancel事件给child if (!hasSendCancelEvent && isTouch && currentTargetOffsetTop > START_POSITION) { sendCancelEvent(); hasSendCancelEvent = true; } int targetY = Math.max(0, currentTargetOffsetTop + offset); // target不能移动到小于0的位置…… offset = targetY - currentTargetOffsetTop; // 1. 在RESET状态时,第一次下拉出现header的时候,设置状态变成PULL if (state == State.RESET && currentTargetOffsetTop == START_POSITION && targetY > 0) { changeState(State.PULL); } // 2. 在PULL或者COMPLETE状态时,header回到顶部的时候,状态变回RESET if (currentTargetOffsetTop > START_POSITION && targetY <= START_POSITION) { if (state == State.PULL || state == State.COMPLETE) { changeState(State.RESET); } } // 3. 如果是从底部回到顶部的过程(往上滚动),并且手指是松开状态, 并且当前是PULL状态,状态变成LOADING,这时候我们需要强制停止autoScroll if (state == State.PULL && !isTouch && currentTargetOffsetTop > totalDragDistance && targetY <= totalDragDistance) { autoScroll.stop(); changeState(State.LOADING); if (refreshListener != null) { refreshListener.onRefresh(); } // 因为判断条件targetY <= totalDragDistance,会导致不能回到正确的刷新高度(有那么一丁点偏差),调整change int adjustOffset = totalDragDistance - targetY; offset += adjustOffset; } setTargetOffsetTopAndBottom(offset); // 别忘了回调header的位置改变方法。 if(refreshHeader instanceof RefreshHeader) { ((RefreshHeader) refreshHeader) .onPositionChange(currentTargetOffsetTop, lastTargetOffsetTop, totalDragDistance, isTouch,state); } }
而ActionUp的时候也不能单纯的让header回到顶部了,而是需要通过判断状态,回到刷新高度亦或是回到顶部。
1. 刷新状态,回到刷新高度
2. 否则,回到顶部
我们将原本在ActionUp中的autoScroll.scrollto(…)抽取成一个方法再调用,如下
private void finishSpinner() { if (state == State.LOADING) { if (currentTargetOffsetTop > totalDragDistance) { autoScroll.scrollTo(totalDragDistance, SCROLL_TO_REFRESH_DURATION); } } else { autoScroll.scrollTo(START_POSITION, SCROLL_TO_TOP_DURATION); } }
好了,大功告成!在changeState方法中添加Toast打印一下状态,来运行下!
Toast.makeText(getContext(), state.toString(), Toast.LENGTH_SHORT).show();
别忘记在Activity中调用refreshComplete方法,我们延时三秒后设置刷新成功!
以下是Activity中的调用:
final RefreshLayout refreshLayout = (RefreshLayout) findViewById(R.id.refreshLayout); if (refreshLayout != null) { // 刷新状态的回调 refreshLayout.setRefreshListener(new RefreshLayout.OnRefreshListener() { @Override public void onRefresh() { // 延迟3秒后刷新成功 refreshLayout.postDelayed(new Runnable() { @Override public void run() { refreshLayout.refreshComplete(); } }, 3000); } }); }
运行结果:我们演示几种情况
下拉 ?C >回到顶部 (pull ?C> reset)
下拉 ?C>刷新 ?C> 刷新成功 ?C> 回到顶部(pull?C>loading?C>complete?C>reset)
下拉 ?C>刷新 ?C> 刷新成功 ?C> 回到顶部(手指按下,不让header回到顶部)
完全没有问题,体验还是可以的!这样我们就完成了一个下拉刷新控件了!
三、自定义默认的Header
下拉刷新是弄好了,但是我们的header也太寒碜太敷衍了吧!
现在我们就来自定义一个header,包含一个旋转的箭头,还有文字提示!但是我不准备提供时间提示了~普通点,和QQ一样的
首先我们需要一些图片资源,从QQ的apk解压获取到
先来定义几个旋转动画
rotate_down.xml
<rotate xmlns:android="http://schemas.android.com/apk/res/android" android:duration="150" android:fillAfter="true" android:fromDegrees="-180" android:pivotX="50%" android:pivotY="50%" android:repeatCount="0" android:toDegrees="0" />
rotate_up.xml
<rotate xmlns:android="http://schemas.android.com/apk/res/android" android:duration="150" android:fillAfter="true" android:fromDegrees="0" android:pivotX="50%" android:pivotY="50%" android:toDegrees="180" />
rotate_infinite.xml
<rotate xmlns:android="http://schemas.android.com/apk/res/android" android:duration="150" android:fillAfter="true" android:fromDegrees="180" android:interpolator="@android:anim/linear_interpolator" android:pivotX="50%" android:pivotY="50%" android:repeatCount="0" android:toDegrees="0" />
header代码如下
import android.content.Context; import android.util.AttributeSet; import android.view.View; import android.view.animation.Animation; import android.view.animation.AnimationUtils; import android.widget.FrameLayout; import android.widget.TextView; /** * Created by AItsuki on 2016/6/15. * */ public class QQRefreshHeader extends FrameLayout implements RefreshHeader { private Animation rotate_up; private Animation rotate_down; private Animation rotate_infinite; private TextView textView; private View arrowIcon; private View successIcon; private View loadingIcon; public QQRefreshHeader(Context context) { this(context, null); } public QQRefreshHeader(Context context, AttributeSet attrs) { super(context, attrs); // 初始化动画 rotate_up = AnimationUtils.loadAnimation(context , R.anim.rotate_up); rotate_down = AnimationUtils.loadAnimation(context , R.anim.rotate_down); rotate_infinite = AnimationUtils.loadAnimation(context , R.anim.rotate_infinite); inflate(context, R.layout.header_qq, this); textView = (TextView) findViewById(R.id.text); arrowIcon = findViewById(R.id.arrowIcon); successIcon = findViewById(R.id.successIcon); loadingIcon = findViewById(R.id.loadingIcon); } @Override public void reset() { textView.setText(getResources().getText(R.string.qq_header_reset)); successIcon.setVisibility(INVISIBLE); arrowIcon.setVisibility(VISIBLE); arrowIcon.clearAnimation(); loadingIcon.setVisibility(INVISIBLE); loadingIcon.clearAnimation(); } @Override public void pull() { } @Override public void refreshing() { arrowIcon.setVisibility(INVISIBLE); loadingIcon.setVisibility(VISIBLE); textView.setText(getResources().getText(R.string.qq_header_refreshing)); arrowIcon.clearAnimation(); loadingIcon.startAnimation(rotate_infinite); } @Override public void onPositionChange(float currentPos, float lastPos, float refreshPos, boolean isTouch, State state) { // 往上拉 if (currentPos < refreshPos && lastPos >= refreshPos) { if (isTouch && state == State.PULL) { textView.setText(getResources().getText(R.string.qq_header_pull)); arrowIcon.clearAnimation(); arrowIcon.startAnimation(rotate_down); } // 往下拉 } else if (currentPos > refreshPos && lastPos <= refreshPos) { if (isTouch && state == State.PULL) { textView.setText(getResources().getText(R.string.qq_header_pull_over)); arrowIcon.clearAnimation(); arrowIcon.startAnimation(rotate_up); } } } @Override public void complete() { loadingIcon.setVisibility(INVISIBLE); loadingIcon.clearAnimation(); successIcon.setVisibility(VISIBLE); textView.setText(getResources().getText(R.string.qq_header_completed)); } }
我们来看看运行结果,完美~
四、自动下拉刷新
是不是觉得还少了点什么?没错,就是自动刷新了!
很多时候,我们进入某个页面,初始化是需要自动刷新数据,这时候就需要用到自动刷新了,不需要用户手动。
分析:
1. 刷新状态都是在moveSpinner中变更的,而autoScroll正好是调用moveSpinner实现滚动
2. 我们可以调用autoScroll方法,让它滚动到刷新高度,然后再调用finishSpinner方法,让控件进入Loading状态
3. 自动刷新一般是在Activity的onCreate的这个生命周期执行,此时界面可能还没有绘制完毕,可以通过postDelay方法延迟个几百毫秒,保证界面显示正常。
4. 而如果在postDelay的延迟时间中,用户如果点击了界面,我们应该将自动刷新功能移除。
首先我们定义公共方法:
public void autoRefresh() { autoRefresh(500); } /** * 在onCreate中调用autoRefresh,此时View可能还没有初始化好,需要延长一段时间执行。 * * @param duration 延时执行的毫秒值 */ public void autoRefresh(long duration) { if (state != State.RESET) { return; } postDelayed(autoRefreshRunnable, duration); }
runnable
// 自动刷新,需要等View初始化完毕才调用,否则头部不会滚动出现 private Runnable autoRefreshRunnable = new Runnable() { @Override public void run() { // 标记当前是自动刷新状态,finishScroll调用时需要判断 // 在actionDown事件中重新标记为false isAutoRefresh = true; changeState(State.PULL); autoScroll.scrollTo(totalDragDistance, SCROLL_TO_REFRESH_DURATION); } };
当autoScroll滚动结束的时候,会回调这个方法,判断如果是自动刷新,将状态设置为Loading,并且调用finishSpinner方法。
/** * 滚动结束回调 * * @param isForceFinish 是否强制停止 */ private void onScrollFinish(boolean isForceFinish) { if (isAutoRefresh && !isForceFinish) { isAutoRefresh = false; changeState(State.LOADING); if (refreshListener != null) { refreshListener.onRefresh(); } finishSpinner(); } }
搞定,在Activity中调用
refreshLayout.autoRefresh();
五、添加滑动阻力
目前还有个问题,控件可以无限下拉(多点触控),我们应该让阻力随着滑动距离的增大而逐渐增加,直到划不动为止。
我们可以用到这个方程
y是阻力,控制在0~1。
x是target偏移量超出刷新高度的百分比,控制在0~2。
代码如下,写在moveSpinnner中。
// y = x - (x/2)^2 float extraOS = targetY - totalDragDistance; float slingshotDist = totalDragDistance; float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2) / slingshotDist); float tensionPercent = (float) (tensionSlingshotPercent - Math.pow(tensionSlingshotPercent / 2, 2)); if(offset > 0) { // 下拉的时候才添加阻力 offset = (int) (offset * (1f - tensionPercent)); targetY = Math.max(0, currentTargetOffsetTop + offset); }
那么,一个体验还算不错的下拉刷新控件就这么完成了
部分代码参考自SwipeRefreshLayout和UltraPullToRefresh
这是Demo下载地址:
https://github.com/AItsuki/CustomPullToRefresh
上一篇:Android编程之Button控件配合Toast控件用法分析
栏 目:Android代码
下一篇:Android自定义ImageView实现点击两张图片切换效果
本文标题:Android实现支持所有View的通用的下拉刷新控件
本文地址:http://www.codeinn.net/misctech/57200.html