用viewDragHelper来写刷新控件<二>

前面第一章我们讲了刷新控件的初步实现,基本上已经处理了它的手势,状态切换甚至还有UI交互效果(松手的时候自己滚动),那么我们还剩下什么呢?

  • 现在我们的refreshView和loadView都还只是两个啥都没有的view,太丑了,我们其实是想要动画效果,所以我们需要加动画
  • 现在刷新控件就只是能被拖动,实际上我们能在项目里面用吗?不能,因为我们没有挂监听,当前状态变成刷新或者加载了不会去通知我们的业务模块

接下来我们先把动画加上去

说到动画,我们可以直接用ANIMATION,也可以有诸如写成XML的方式等等。这里偷一下懒(先搞一个动画放上去再说),我们直接扒其他项目已经写好的动画MaterialDrawable

从表现上看,它就是安卓5.0风格的进度条,差不多是这个样子

用viewDragHelper来写刷新控件<二>_第1张图片
material progressbar

从实现上来说,它其实是一个Drawable,就是说其实是通过不停的draw来达到动画的效果的,这一点跟viewdraw是一个道理。正因为它依赖draw,所以后面我们会看到,在手势拖动的过程中,为了实时的让mRefreshDrawablemLoadDrawable发生视觉上的变化,我们不得不在处理拖动的时候调用invalidate来刷新整个控件。然后这个Drawable就是在每一帧的时候去计算颜色和绘制弧形

在使用上,这个Drawable需要提供start(开始转),stop(停止转),setPercent(现在应该转到哪里)。
而且我们有时候需要根据Drawable是不是正在跑动画而做某些事情,就是说我们还需要一个isRunning方法,MaterialDrawable虽然有了,但它的逻辑可实现不了我们的诉求,所以要调整下

而且startstopisRunning正好由安卓SDK的接口Animatable提供了,所以只需要实现其即可(其实startstop的逻辑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(姑且就取这个名字)加到我们的刷新控件里面去,因为我们的refreshViewloadView就是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的进度
  • 当我们调用setLoadingsetRefreshing之后,就需要根据具体情况来通知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();
然后是把RingDrawablesetLoadingsetRefreshing联系起来

前面所讲,通过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的时候都会去调它,所以不能简单的判断animContinuefalse,因为这样我们甚至还在拖拽,它的逻辑依然会进入下面那部分。于是我们需要一个标志位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!而且这时候animContinuefalselastAnimStatetrue,然后我们就可能得面临着动画被错误开启的境况了mRefreshDrawable.start();如果这时候还没有被设置为ScrollStatus.DRAGGING的话),用户于是会看到,我拖拽的时候那个动画还在跑,而且跑的那么怪异(setPercentstart同时起作用)。。。

所以我们得继续修正,这里用另外一个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这里简化一下,直接根据refreshListenerloadListener是否为null来进行判断

@Override
public boolean isRefreshAble() {
    return refreshListener != null;
}

@Override
public boolean isLoadAble() {
    return loadListener != null;
}

那么何时响应onRefreshrefreshCancel呢?必然是在computeScrollsetRefreshing之中做文章

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表示refreshDOWN表示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的支持

你可能感兴趣的:(用viewDragHelper来写刷新控件<二>)