前面第一章我们讲了刷新控件的初步实现,基本上已经处理了它的手势,状态切换甚至还有UI交互效果(松手的时候自己滚动),那么我们还剩下什么呢?
- 现在我们的refreshView和loadView都还只是两个啥都没有的view,太丑了,我们其实是想要动画效果,所以我们需要加动画
- 现在刷新控件就只是能被拖动,实际上我们能在项目里面用吗?不能,因为我们没有挂监听,当前状态变成刷新或者加载了不会去通知我们的业务模块
接下来我们先把动画加上去
说到动画,我们可以直接用ANIMATION,也可以有诸如写成XML的方式等等。这里偷一下懒(先搞一个动画放上去再说),我们直接扒其他项目已经写好的动画MaterialDrawable
。
从表现上看,它就是安卓5.0风格的进度条,差不多是这个样子
从实现上来说,它其实是一个Drawable
,就是说其实是通过不停的draw
来达到动画的效果的,这一点跟view
的draw
是一个道理。正因为它依赖draw
,所以后面我们会看到,在手势拖动的过程中,为了实时的让mRefreshDrawable
和mLoadDrawable
发生视觉上的变化,我们不得不在处理拖动的时候调用invalidate
来刷新整个控件。然后这个Drawable
就是在每一帧的时候去计算颜色和绘制弧形
在使用上,这个Drawable
需要提供start
(开始转),stop
(停止转),setPercent
(现在应该转到哪里)。
而且我们有时候需要根据Drawable
是不是正在跑动画而做某些事情,就是说我们还需要一个isRunning
方法,MaterialDrawable
虽然有了,但它的逻辑可实现不了我们的诉求,所以要调整下
而且start
,stop
,isRunning
正好由安卓SDK的接口Animatable
提供了,所以只需要实现其即可(其实start
,stop
的逻辑MaterialDrawable
已经做好了,这里都不需要改,就isRunning
改改就好了)
boolean isRunning = false;
@Override
public void start() {
mAnimation.reset();
mRing.storeOriginals();
// Already showing some part of the ring
if (mRing.getEndTrim() != mRing.getStartTrim()) {
mParent.startAnimation(mFinishAnimation);
} else {
mRing.setColorIndex(0);
mRing.resetOriginals();
mParent.startAnimation(mAnimation);
}
isRunning = true;
}
@Override
public void stop() {
mParent.clearAnimation();
mFinishAnimation.cancel();
mAnimation.cancel();
mFinishAnimation.reset();
mAnimation.reset();
setRotation(0);
mRing.setShowArrow(false);
mRing.setColorIndex(0);
mRing.resetOriginals();
isRunning = false;
}
@Override
public boolean isRunning() {
return isRunning;
}
但是有个地方要注意
原作中的
MaterialDrawable
是根据内置的mTop
变量在draw
的时候移动自己的画布,为何要这样呢?因为它就是通过这种方式移动下拉refreshView
的,就是说其实refreshView
没动,上面的Drawable
的画布自己动,视觉上跟我们直接让refreshView
动是一样的(个人觉得,这种方式维护起来很容易乱,Drawable
只要负责好自己的动画就行啦)
另外我们还需要确定这个Drawable
的高度,这里设置为40dp,mDiameter = dp2px(40);
然后我们要把这个RingDrawable
(姑且就取这个名字)加到我们的刷新控件里面去,因为我们的refreshView
和loadView
就是ImageView
,所以直接setImageDrawable
就可以设置Drawable
了。而且这两个ImageView
的高度就是DRAW_VIEW_MAX_HEIGHT
(这里是64dp)
refreshView = new ImageView(getContext());
loadView = new ImageView(getContext());
refreshView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp2px(DRAW_VIEW_MAX_HEIGHT)));
loadView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp2px(DRAW_VIEW_MAX_HEIGHT)));
mRefreshDrawable = new RingDrawable(this);
mLoadDrawable = new RingDrawable(this);
refreshView.setImageDrawable(mRefreshDrawable);
loadView.setImageDrawable(mLoadDrawable);
但是如同我们上面所说,RingDrawable
的高度是40dp,ImageView
的高度是64dp,怎么让RingDrawable
居中显示在ImageView
上面呢?直接设置padding
就可以了
refreshView.setPadding(0, dp2px(DRAW_PADDING), 0, dp2px(DRAW_PADDING));
loadView.setPadding(0, dp2px(DRAW_PADDING), 0, dp2px(DRAW_PADDING));
这里的DRAW_PADDING
就是12dp,至于宽度,不需要管,原本的MaterialDrawable
里面计算的就是横向屏幕正中间
接下来就该具体使用RingDrawable
了。
- 一般刷新控件都有这么一个表现,手指拽动的时候会根据当前位置动态的设置动画,比如传统的pullToRefresh,拽到一定位置那个下拉箭头就变成上拉,并且提示你松手后开始刷新,我们这里也是如此,所以我们需要监听move事件,并且动态设置
RingDrawable
的进度 - 当我们调用
setLoading
和setRefreshing
之后,就需要根据具体情况来通知RingDrawable
开始/结束动画
动态设置进度
首先得确定进度该怎么计算,为了简单起见,我们就直接用当前位置contentTop
占整个最大可滑动区域的比重,来作为进度
public boolean onTouchEvent(MotionEvent event) {
// .....
switch (action) {
case MotionEvent.ACTION_MOVE:
if (mActivePointerId == -1) {
return true;
}
float originalDragPercent = (float) Math.abs(consignor.contentTop()) / (float)DRAG_MAX_RANGE + .4f;
mDragPercent = Math.min(1f, Math.abs(originalDragPercent));
consignor.setDrawPercent(mDragPercent);
consignor.dragHelper().processTouchEvent(event);
break;
// .....
}
// .....
}
为啥要加个0.4f?咳咳,这个是因为
MaterialDrawable
计算的需要,有时间可以看看它的算法
然后是setDrawPercent
的实现
mRefreshDrawable.setPercent(drawPercent);
mLoadDrawable.setPercent(drawPercent);
之前说了,RingDrawable
是通过draw
来绘制的,所以设置了进度之后不要忘记invalidate
mRefreshDrawable.invalidateSelf();
mLoadDrawable.invalidateSelf();
然后是把RingDrawable
与setLoading
和setRefreshing
联系起来
前面所讲,通过setLoading
来设置刷新状态或者取消刷新状态的时候,其实是用的scroller来让整个刷新控件滚动的,那么就是通过computeScroll
来计算滚完没有,同时会在移动之后调用VDH的onViewPositionChanged
(参见《用viewDragHelper来写刷新控件<一>》)
那么自然是要在滚完之后才开启/关闭动画播放
public void computeScroll() {
animContinue = dragHelper.continueSettling(true);
if (animContinue) {
ViewCompat.postInvalidateOnAnimation(this);
mRefreshDrawable.invalidateSelf();
mLoadDrawable.invalidateSelf();
} else {
if (ScrollStatus.isRefreshing(status)) {
mRefreshDrawable.start();
} else if (ScrollStatus.isLoading(status)) {
mLoadDrawable.start();
} else if (ScrollStatus.isIdle(status)) {
mRefreshDrawable.stop();
mLoadDrawable.stop();
}
}
}
这样写之后我们就能控制好开启/关闭动画播放了吗?
不
computeScroll
有个特点,就是每次view
在调用draw
的时候都会去调它,所以不能简单的判断animContinue
为false
,因为这样我们甚至还在拖拽,它的逻辑依然会进入下面那部分。于是我们需要一个标志位lastAnimState
来区别
public void setRefreshing(boolean refreshing) {
if (refreshing) {
lastAnimState = true;
if (dragHelper.smoothSlideViewTo(mTarget, 0, dp2px(DRAW_VIEW_MAX_HEIGHT))) {
ViewCompat.postInvalidateOnAnimation(this);
status = ScrollStatus.REFRESHING;
} else {
status = ScrollStatus.REFRESHING;
}
} else {
lastAnimState = true;
if (dragHelper.smoothSlideViewTo(mTarget, 0, 0)) {
ViewCompat.postInvalidateOnAnimation(this);
status = ScrollStatus.IDLE;
} else {
status = ScrollStatus.IDLE;
}
}
}
public void computeScroll() {
animContinue = dragHelper.continueSettling(true);
if (animContinue && lastAnimState == animContinue) {
ViewCompat.postInvalidateOnAnimation(this);
mRefreshDrawable.invalidateSelf();
mLoadDrawable.invalidateSelf();
} else if (!animContinue && lastAnimState != animContinue) {
if (ScrollStatus.isRefreshing(status)) {
mRefreshDrawable.start();
} else if (ScrollStatus.isLoading(status)) {
mLoadDrawable.start();
} else if (ScrollStatus.isIdle(status)) {
mRefreshDrawable.stop();
mLoadDrawable.stop();
}
lastAnimState = animContinue;
}
}
看上去好像没有问题了,但是请设想这么一种情况:
在本来就是刷新状态的前提下,我们向下拉动,松手的一瞬间再次去拉动,这时候它还没有滚到该到的位置,那么这时候status是什么状态呢?是
ScrollStatus.DRAGGING
因为不管我们刚刚放手的时候它是不是ScrollStatus.REFRESHING
,我们现在是在拖动它,所以它已经被设置为ScrollStatus.DRAGGING
了,可是这会造成什么问题?
因为我们刚刚是松手了,所以其实程序走向是进入到了setRefreshing
,这意味着lastAnimState
被置为了true
,然而我们又接着继续开始拖动,前面我们说到,在view
调用draw
的时候都会进入computeScroll
,就是说接下来我们又会进入computeScroll
!而且这时候animContinue
为false
,lastAnimState
为true
,然后我们就可能得面临着动画被错误开启的境况了mRefreshDrawable.start();
(如果这时候还没有被设置为ScrollStatus.DRAGGING
的话),用户于是会看到,我拖拽的时候那个动画还在跑,而且跑的那么怪异(setPercent
和start
同时起作用)。。。
所以我们得继续修正,这里用另外一个status来避免这种干扰
public void setRefreshing(boolean refreshing) {
if (refreshing) {
lastAnimState = true;
if (dragHelper.smoothSlideViewTo(mTarget, 0, dp2px(DRAW_VIEW_MAX_HEIGHT))) {
ViewCompat.postInvalidateOnAnimation(this);
scrollStatus = ScrollStatus.REFRESHING;
} else {
status = ScrollStatus.REFRESHING;
scrollStatus = status;
}
} else {
lastAnimState = true;
if (dragHelper.smoothSlideViewTo(mTarget, 0, 0)) {
ViewCompat.postInvalidateOnAnimation(this);
scrollStatus = ScrollStatus.IDLE;
} else {
status = ScrollStatus.IDLE;
scrollStatus = status;
}
}
}
public void computeScroll() {
animContinue = dragHelper.continueSettling(true);
if (animContinue && lastAnimState == animContinue) {
ViewCompat.postInvalidateOnAnimation(this);
mRefreshDrawable.invalidateSelf();
mLoadDrawable.invalidateSelf();
} else if (!animContinue && lastAnimState != animContinue) {
if (ScrollStatus.isRefreshing(scrollStatus)) {
mRefreshDrawable.start();
} else if (ScrollStatus.isLoading(scrollStatus)) {
mLoadDrawable.start();
} else if (ScrollStatus.isIdle(scrollStatus)) {
mRefreshDrawable.stop();
mLoadDrawable.stop();
}
status = scrollStatus;
lastAnimState = animContinue;
}
}
说到这里,就还有另外一种情况,我们当前是刷新状态,然后开始拖拽,那么我们就需要先把已经在运转的动画停住,所以我们的setDrawPercent
也要做些调整
public void setDrawPercent(float drawPercent) {
if (mRefreshDrawable.isRunning()) {
lastAnimState = false;
mRefreshDrawable.stop();
}
if (mLoadDrawable.isRunning()) {
lastAnimState = false;
mLoadDrawable.stop();
}
mRefreshDrawable.setPercent(drawPercent);
mLoadDrawable.setPercent(drawPercent);
mRefreshDrawable.invalidateSelf();
mLoadDrawable.invalidateSelf();
}
如此一来,动画已经添加完成,加载部分代码跟刷新是差不多的,就不赘述了
接下来,我们还剩下监听回调,来让刷新控件真正的可被使用到项目之中
监听回调
首先,我们得先确定由刷新控件暴露哪些回调接口
一般我们使用的刷新控件,往往有如下几个接口:
- 刷新回调
onRefresh
- 加载回调
onLoad
- 刷新取消
refreshCancel
- 加载取消
loadCancel
- 设置模式
setMode
设置模式分为允许刷新,允许加载,不允许刷新,不允许加载
以上几个接口是一个刷新控件最基本的接口,仔细看看,其实分为两种,一种是刷新的,一种是加载的
public interface DragLoadListener {
void onLoad();
void loadCancel();
}
public interface DragRefreshListener {
void onRefresh();
void refreshCancel();
}
至于setMode
这里简化一下,直接根据refreshListener
和loadListener
是否为null来进行判断
@Override
public boolean isRefreshAble() {
return refreshListener != null;
}
@Override
public boolean isLoadAble() {
return loadListener != null;
}
那么何时响应onRefresh
和refreshCancel
呢?必然是在computeScroll
和setRefreshing
之中做文章
onRefresh
一般我们在调用setRefreshing(true)
就会触发onRefresh
,然而一个更精准的触发时机应该是在整个computeScroll
滚动结束的时候
public void computeScroll() {
animContinue = dragHelper.continueSettling(true);
if (animContinue && lastAnimState == animContinue) {
ViewCompat.postInvalidateOnAnimation(this);
mRefreshDrawable.invalidateSelf();
mLoadDrawable.invalidateSelf();
} else if (!animContinue && lastAnimState != animContinue) {
if (ScrollStatus.isRefreshing(scrollStatus)) {
mRefreshDrawable.start();
if (isRefreshAble()) {
refreshListener.onRefresh();
}
} else if (ScrollStatus.isLoading(scrollStatus)) {
mLoadDrawable.start();
if (isLoadAble()) {
loadListener.onLoad();
}
} else if (ScrollStatus.isIdle(scrollStatus)) {
mRefreshDrawable.stop();
mLoadDrawable.stop();
// 取消刷新或者加载回调
}
status = scrollStatus;
lastAnimState = animContinue;
}
}
当然如果本身已经不能滚动,则直接触发
if (refreshing) {
lastAnimState = true;
if (dragHelper.smoothSlideViewTo(mTarget, 0, dp2px(DRAW_VIEW_MAX_HEIGHT))) {
ViewCompat.postInvalidateOnAnimation(this);
scrollStatus = ScrollStatus.REFRESHING;
} else {
status = ScrollStatus.REFRESHING;
scrollStatus = status;
// 不必滑动,直接触发
if (isRefreshAble()) {
refreshListener.onRefresh();
}
}
} else {
lastAnimState = true;
if (dragHelper.smoothSlideViewTo(mTarget, 0, 0)) {
ViewCompat.postInvalidateOnAnimation(this);
scrollStatus = ScrollStatus.IDLE;
} else {
status = ScrollStatus.IDLE;
scrollStatus = status;
}
}
onLoad
的部分跟这个差不多,就不说了
refreshCancel
同样的,refreshCancel
也是在computeScroll
滚动结束的时候触发
public void computeScroll() {
.....
mRefreshDrawable.stop();
mLoadDrawable.stop();
refreshListener.refreshCancel();
......
}
public void setRefreshing(boolean refreshing) {
if (refreshing) {
......
} else {
lastAnimState = true;
if (dragHelper.smoothSlideViewTo(mTarget, 0, 0)) {
ViewCompat.postInvalidateOnAnimation(this);
scrollStatus = ScrollStatus.IDLE;
} else {
if (ScrollStatus.isRefreshing(scrollStatus) && isRefreshAble()) {
refreshListener.refreshCancel();
}
status = ScrollStatus.IDLE;
scrollStatus = status;
}
}
}
可是问题来了,请注意一下setRefreshing(false)
和setLoading(false)
的逻辑部分
public void setLoading(boolean loading, boolean animation) {
if (loading) {
....
} else {
lastAnimState = true;
if (dragHelper.smoothSlideViewTo(mTarget, 0, 0)) {
ViewCompat.postInvalidateOnAnimation(this);
scrollStatus = ScrollStatus.IDLE;
} else {
if (ScrollStatus.isLoading(scrollStatus) && isLoadAble()) {
loadListener.loadCancel();
}
status = ScrollStatus.IDLE;
scrollStatus = status;
}
}
}
它们都是通过dragHelper.smoothSlideViewTo(mTarget, 0, 0)
的方式将刷新控件复位,而我们的computeScroll
都是通过ScrollStatus.isIdle(scrollStatus)
来停止动画并且调用cancel回调,因此我们必须要某种方式来区分到底是refreshCancel
还是loadCancel
。这里我们加一个方向变量,来标识是哪个方向的cancel,暂确定为UP
表示refresh
,DOWN
表示load
Direction smoothToDirection = Direction.STATIC;
public void setRefreshing(boolean refreshing) {
if (refreshing) {
......
} else {
lastAnimState = true;
if (dragHelper.smoothSlideViewTo(mTarget, 0, 0)) {
ViewCompat.postInvalidateOnAnimation(this);
scrollStatus = ScrollStatus.IDLE;
smoothToDirection = Direction.UP;
} else {
if (ScrollStatus.isRefreshing(scrollStatus) && isRefreshAble()) {
refreshListener.refreshCancel();
}
status = ScrollStatus.IDLE;
scrollStatus = status;
}
}
}
public void computeScroll() {
.....
mRefreshDrawable.stop();
mLoadDrawable.stop();
if (smoothToDirection == Direction.UP && isRefreshAble()) {
refreshListener.refreshCancel();
}
if (smoothToDirection == Direction.DOWN && isLoadAble()) {
loadListener.loadCancel();
}
smoothToDirection = Direction.STATIC;
......
}
public void setLoading(boolean loading, boolean animation) {
if (loading) {
....
} else {
lastAnimState = true;
if (dragHelper.smoothSlideViewTo(mTarget, 0, 0)) {
ViewCompat.postInvalidateOnAnimation(this);
scrollStatus = ScrollStatus.IDLE;
smoothToDirection = Direction.DOWN;
} else {
if (ScrollStatus.isLoading(scrollStatus) && isLoadAble()) {
loadListener.loadCancel();
}
status = ScrollStatus.IDLE;
scrollStatus = status;
}
}
}
现在可以正常的并且精准的响应cancel
了吗?还不够。
事实上,还有一种情况会调用
setRefreshing(false)
和setLoading(false)
,那就是在静止状态的时候,手机稍微拖拽一下刷新控件,控件是拖动中,但拖拽的距离并没有达到刷新/加载的反应阈值,这时候松手刷新控件只会滚回静止位置
public void onViewReleased(View releasedChild, float xvel, float yvel) {
super.onViewReleased(releasedChild, xvel, yvel);
if (contentTop > dp2px(DRAW_VIEW_MAX_HEIGHT)) {
setRefreshing(true);
} else if (contentTop < -dp2px(DRAW_VIEW_MAX_HEIGHT)) {
setLoading(true);
} else if (contentTop > 0) {
setRefreshing(false);
} else if (contentTop == 0) {
// 松手后通过setRefreshing(false)或者setLoading(false)滚回静止位置
if (!ScrollViewCompat.canSmoothDown(mTarget)) {
setRefreshing(false);
} else if (!ScrollViewCompat.canSmoothUp(mTarget)) {
setLoading(false);
}
} else {
setLoading(false);
}
}
但在我们代码里面还是会触发cancel
调用,显然这是不合理的。因此我们必须要在开始拖拽的时刻就判断出当前是不是刷新中或者加载中的状态,这里我们需要在ACTION_DOWN事件的时候就判断,回到DragDelegate
来:
public boolean onInterceptTouchEvent(MotionEvent event) {
final int action = MotionEventCompat.getActionMasked(event);
switch (action) {
case MotionEvent.ACTION_DOWN:
mActivePointerId = MotionEventCompat.getPointerId(event, 0);
initY = (int) MotionEventUtil.getMotionEventY(event, mActivePointerId);
consignor.dragHelper().shouldInterceptTouchEvent(event);
mDragPercent = 0;
// 通知刷新控件去判断
consignor.beforeMove();
break;
.......
}
然后我们刷新控件实现beforeMove
方法:
boolean shouldCancel = false;
public void beforeMove() {
shouldCancel = ScrollStatus.isRefreshing(status) || ScrollStatus.isLoading(status);
}
computeScroll
方法调整:
public void computeScroll() {
.....
mRefreshDrawable.stop();
mLoadDrawable.stop();
if (smoothToDirection == Direction.UP && isRefreshAble() && shouldCancel) {
refreshListener.refreshCancel();
}
if (smoothToDirection == Direction.DOWN && isLoadAble() && shouldCancel) {
loadListener.loadCancel();
}
smoothToDirection = Direction.STATIC;
shouldCancel = false;
......
}
终于,刷新控件基本完成,已基本实现了下拉刷新,上拉加载功能,效果图如下:
到了这里其实该控件可以结束了,最后一章将讨论一下工具类的实现,以及增强功能emptyView
的支持