项目已经上传Github,点击这里查看,里面有整个项目的效果。
先说说我为什么要做这个。github上有一个比较火的开源项目,PullToRefreshView,估计不少人都看过,这个项目实现的动画很漂亮,但是它有一个我无法忍受的缺点,就是当列表可以向下滚动的时候,你点击屏幕,然后手指向下移动,列表跟着向下滑,当列表滑动到顶部的时候,手指继续向下移动的时候,刷新的动画效果并不会出现,只有你手指抬起再点击向下滑动,刷新的动画才会出现。这个效果的出现是因为作者是自定义了一个VIewGroup,而非继承ListView或者RecyclerView,原作者把刷新对应的滑动手势放在ViewGroup处理,而列表向下滑动时对应的手势是在ListView或者RecyclerView中处理的,而当列表拿到action_down事件并处理,那么后续的move,up之类的事件都会由它处理,这就会出现上述所说的问题。
从项目介绍里大家可以看到,有两种实现方式,这两种效果都有很广泛的应用.
第一种是前景实现,通过RecyclerView的内部类ItemDecoration实现,关于这个类网上资料很多,我就不展开讲了,它主要起到一个装饰的作用,既可以给每一个item装饰,也可以装饰整个RecyclerView,它内部的onDrawOver方法实现给整个RecyclerView添加装饰。而前景动画的旋转效果正好可以看成是View的装饰。
先说一个概念,滑动效果有一个最大距离,而手指移动的距离乘以一个系数得到的距离是图案在屏幕上移动的距离,这个距离和最大距离的比值是计算图案展示效果的关键系数,当这个值大于0.8的时候,我们就认为可以刷新,如果小于这个值就认为还不能下拉刷新。
前景的实现逻辑,说说下拉刷新,加载更多原理类似。
手势监听
|
| 如果如果手势向下,view不能向下继续滑动
|
计算滑动的距离,当滑动距离没有到阀值的时候,根据滑动距离和
最大滑动距离的百分比,计算绘制的图案位置,圆弧的角度等等
| |
| 当滑动时未超过阀值松开 | 超过阀值时松开
| |
这个时候让图案回弹就Ok 图案先回弹到阀值对应的位置,然后开始旋转,
当刷新完成的时候,回弹
整个逻辑就是这样。
下面结合代码来说说。翠花,上代码。
public interface ItemDecorAnimatable extends Animatable{
/**
* coder maybe should call this method before call {@link #interruptAnimation()}
*
* @return true if can interrupt animation ,otherwise return false
*/
boolean canInterrupt();
/**
* ItemDecor's animation maybe be complicated
* when new TouchEvent received but ItemDecor still animating,if ItemDecor support interrupt
*u should override this method
*/
void interruptAnimation();
}
首先从架构的角度考虑下,一个前景效果需要定义哪些方法。它需要实现动画效果,那么它要实现Animatable接口中的方法吧。另外是不是这个接口就满足使用了?
我们再仔细想想这样一个需求,当我把手指向下移动到没有达到阀值的时候抬起手指的时候,图案应该是回弹的,如果说回弹需要0.5秒,但是我抬起0.3秒后就立刻按下去,那么此时图案的回弹动画还没有结束的,那么这个后续的action_down怎么处理?偷懒的话直接无视,等到回弹动画结束再响应新的接口。这么处理可以,但是我想做的更用户友好呢?那么我应该把动画打断,并且使图案停留在打断时对应的位置上。再想想,如果是正在刷新时,手指点击,是不应该打断刷新过程的,这个时候不应该打断动画,所以我新定义了ItemDecorAnimatable接口,它继承了Animatable同时定义了两个方法:
boolean canInterrupt();
判断是否可以中断动画
void interruptAnimation();
中断动画
这两个动画是配合使用的,你在调用interruptAnimation前应该先调用canInterrupt判断是否应该调用。
public abstract class CustomItemDecor extends RecyclerView.ItemDecoration implements ItemDecorAnimatable {
protected float refreshPercent = 0.0f;
protected float loadPercent = 0.0f;
private View mParent;
protected RefreshableAndLoadable mDataSource;
public CustomItemDecor(View parent){
mParent = parent;
}
public void setRefreshPercent(float percent) {
if (percent == refreshPercent)return;
refreshPercent = percent;
mParent.invalidate();
}
public void setLoadPercent(float percent) {
if (percent == loadPercent)return;
loadPercent = percent;
mParent.invalidate();
}
public float getRefreshPercent() {
return refreshPercent;
}
public float getLoadPercent() {
return loadPercent;
}
public void setRefreshableAndLoadable(RefreshableAndLoadable dataSource){
mDataSource = dataSource;
}
}
这是继承了ItemDecoration类和ItemDecorAnimatable接口的抽象类,目的是给自实现的ItemDecoration定义几个必须的元素。
refreshPercent是刷新动画百分比,loadPercent是加载更多百分比。mParent是ItemDecoration所在的RecyclerView。
public class AdvancedRecyclerView extends RecyclerView
AdvancedRecyclerView是自定义RecyclerView
private boolean canRefresh = true;
private boolean canLoad = false;
private static final int DRAG_MAX_DISTANCE_V = 300;
private static final float DRAG_RATE = 0.48f;
public static final long MAX_OFFSET_ANIMATION_DURATION = 500;
private float INITIAL_Y = -1;
private Interpolator mInterpolator = new LinearInterpolator();
private static final String TAG = "AdvancedRecyclerView";
private boolean showRefreshFlag = false;
private boolean showLoadFlag = false;
private CustomItemDecor mItemDecor;
这是AdvancedRecyclerView中的一些属性。都是字面意思。
我们看看此类中onTouchEvent方法的实现
@Override
public boolean onTouchEvent(@NonNull MotionEvent ev) {
if ((!canRefresh && !canLoad) || !mItemDecor.canInterrupt())return super.onTouchEvent(ev);
final int action = MotionEventCompat.getActionMasked(ev);
switch (action){
case MotionEvent.ACTION_DOWN:
if (!mItemDecor.isRunning()){
INITIAL_Y = MotionEventCompat.getY(ev,0);
}else {// animating
if(!mItemDecor.canInterrupt())return super.onTouchEvent(ev);
mItemDecor.interruptAnimation();
// 如果取消正在运行的动画,需要动用calculateInitY方法把对应的INITIAL_Y计算出来
// 由于RecyclerView记录的action down的位置和我们逻辑上的action down位置不一致
// 所以要手动生成一个MotionEvent对象作为参数调用super.onTouchEvent()来修正
calculateInitY(MotionEventCompat.getY(ev,0),DRAG_MAX_DISTANCE_V,DRAG_RATE,
showRefreshFlag ? mItemDecor.getRefreshPercent() : -mItemDecor.getLoadPercent());
// correct action-down position
final MotionEvent simulateEvent = MotionEvent.obtain(ev);
simulateEvent.offsetLocation(0, INITIAL_Y - MotionEventCompat.getY(ev,0));
return super.onTouchEvent(simulateEvent);
}
break;
case MotionEvent.ACTION_MOVE:
final float agentY = MotionEventCompat.getY(ev,0);
if (agentY > INITIAL_Y){
// towards bottom
if (showLoadFlag)showLoadFlag = false;// 手指上下摆动导致状态切换
if (canChildScrollUp()){
if(showRefreshFlag){
showRefreshFlag = false;
mItemDecor.setRefreshPercent(0);
}else {
return super.onTouchEvent(ev);
}
break;
}else {// 不能向下滚动
if (!canRefresh)return super.onTouchEvent(ev);
if (!showRefreshFlag){// 从能滚动切换为不能滚动
showRefreshFlag = true;
INITIAL_Y = agentY;
}
mItemDecor.setRefreshPercent(fixPercent(calculatePercent(INITIAL_Y,agentY,DRAG_MAX_DISTANCE_V,DRAG_RATE)));
}
}else if(agentY < INITIAL_Y) {
// towards top
if (showRefreshFlag)showRefreshFlag = false;// 手指上下摆动导致状态切换
if(canChildScrollBottom()){
if(showLoadFlag){
showLoadFlag = false;
mItemDecor.setLoadPercent(0);
}else {
return super.onTouchEvent(ev);
}
break;
}else {
if (!canLoad)return super.onTouchEvent(ev);
if(!showLoadFlag){// 从能滚动切换为不能滚动
showLoadFlag = true;
INITIAL_Y = agentY;
}
mItemDecor.setLoadPercent(fixPercent(Math.abs(calculatePercent(INITIAL_Y,agentY,DRAG_MAX_DISTANCE_V,DRAG_RATE))));
}
}else {
clearState();
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
actionUpOrCancel();
return super.onTouchEvent(ev);
case MotionEventCompat.ACTION_POINTER_UP:
break;
}
return true;
}
来按手势流程一点点剖析这个方法。
首先接收到action_down的时候,如果当前没有动画的时候,直接记录下手指在屏幕Y轴的位置就Ok。
手指在屏幕上随意滑动,agentY是手指move时Y轴的位置,如果agentY如果大于INIT_Y,那么手指就是向下滑动,canChildScrollUp判断能否顶部继续滑动(列表向下),如果不能,就设置下拉刷新的状态,把下拉刷新的标识showRefreshFlag设为true。这时设置INIT_Y为agentY,这时的INIT_Y是对应真正开始显示动画的值。接下来就是计算动画的百分比了,这个百分比用来控制整个动画。
mItemDecor.setRefreshPercent(fixPercent(calculatePercent(INITIAL_Y,agentY,DRAG_MAX_DISTANCE_V,DRAG_RATE)));
回到action_down的时候,如果当前已经有正在的运行的动画但是当前可以中断动画时应该怎么处理?代码里的中文注释已经详细解释了为什么要重新计算INIT_Y值。已经运行的动画使用的是上一次手势过程中计算出来的INIT_Y,而再次收到action_down时,如果想让当前动画停留在当前位置,那么就必须把action_down手势的Y轴的值当作一个普通move动作时的值计算出逻辑上对应的INIT_Y值,否则整个动画会混乱掉。
计算的逻辑
/**
* 计算中断动画时,percent对应的InitY
* calculate the InitY corresponding to current(animation interrupted) percent
*
* @param agentY
* @param maxDragDistance
* @param rate
* @param percent
*/
private void calculateInitY(float agentY,int maxDragDistance,float rate,float percent){
INITIAL_Y = agentY - percent * (float) maxDragDistance / rate;
}
/**
* 计算百分比 coder可以调节rate值来改变手指移动的距离改变percent的速度
* @param initialPos
* @param currentPos
* @param maxDragDistance
* @param rate
* @return
*/
private float calculatePercent(float initialPos,float currentPos,int maxDragDistance,float rate){
return (currentPos - initialPos) * rate / ((float) maxDragDistance);
}
/**
* 当percent大于1时,大幅减少percent增长的幅度
* @param initPercent
* @return
*/
private float fixPercent(float initPercent){
if (initPercent <= 1){
return initPercent;
}else {
return 1f + (initPercent - 1f) * 0.6f;
}
}
计算百分比时把移动的差值乘以一个系数,是让动画的流程更自然点,coder可以修改这个系数控制手指滑动时更改动画的速度。
计算百分比的函数和计算percent对应的INIT_Y的函数可以看成互为逆函数。
计算出来的百分比会设置给ItemDecoration。
看看ItemDecoration的实现。
public class ItemDecor extends CustomItemDecor {
Paint mPaint;
Paint ovalPaint;
final float backgroundRadius = 60;
final float ovalRadius = 41;
RectF oval;
final float START_ANGLE_MAX_OFFSET = 90;
final float SWEEP_ANGLE_MAX_VALUE = 300;
final float ovalWidth = (float) (ovalRadius / 3.8);
private ValueAnimator valueAnimator;
private ValueAnimator animator;
private float offsetAngle = 0;
private boolean canStopAnimation = true;
private static final float CRITICAL_PERCENT = 0.8f;// 可以refresh或load的临界百分比
public ItemDecor(View view) {
super(view);
init();
}
private void init(){
mPaint = new Paint();
mPaint.setColor(getResources().getColor(R.color.colorWhite));
ovalPaint = new Paint();
ovalPaint.setColor(getResources().getColor(R.color.colorLightBlue));
ovalPaint.setStyle(Paint.Style.STROKE);
ovalPaint.setStrokeWidth(ovalWidth);
oval = new RectF();
}
@Override
public void onDrawOver(Canvas c, RecyclerView parent, State state) {
if (showRefreshFlag && refreshPercent > 0){// refresh logo visible
// draw background circle
c.drawCircle(getMeasuredWidth() / 2, -backgroundRadius + refreshPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius),
backgroundRadius, mPaint);
if (getSweepAngle() >= SWEEP_ANGLE_MAX_VALUE){// if need, draw circle point
drawCirclePoint(true,c);
}
calculateOvalAngle();
c.drawArc(oval,getStartAngle(),getSweepAngle(),false,ovalPaint);// draw arc
}else if (showLoadFlag && loadPercent > 0){// load logo visible
// draw background circle
c.drawCircle(getMeasuredWidth() / 2, getMeasuredHeight() + backgroundRadius -
loadPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius), backgroundRadius, mPaint);
if (getSweepAngle() >= SWEEP_ANGLE_MAX_VALUE){// if need, draw circle point
drawCirclePoint(false,c);
}
calculateOvalAngle();
c.drawArc(oval,getStartAngle(),getSweepAngle(),false,ovalPaint);// draw arc
}
}
/**
* draw circle point
*
* @param refresh if true draw refresh logo point, otherwise draw load logo point
* @param c canvas
*/
void drawCirclePoint(boolean refresh,Canvas c){
ovalPaint.setStyle(Paint.Style.FILL);
// calculate zhe angle of the point relative to logo central point
final double circleAngle = (360 - SWEEP_ANGLE_MAX_VALUE) / 2 - getStartAngle();
// calculate X coordinate for point center
final float circleX = getMeasuredWidth() / 2 + (float) (Math.cos(circleAngle * Math.PI / 180) * ovalRadius);
// calculate Y coordinate for point center
float circleY;
if (refresh){
circleY = -backgroundRadius + refreshPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius) -
(float) (Math.sin(circleAngle * Math.PI / 180) * ovalRadius);
}else {
circleY = getMeasuredHeight() + backgroundRadius -
loadPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius) -
(float) (Math.sin(circleAngle * Math.PI / 180) * ovalRadius);
}
c.drawCircle(circleX,circleY, ovalWidth / 2 + 2,ovalPaint);
ovalPaint.setStyle(Paint.Style.STROKE);
}
/**
* calculate arc circumcircle's position
*/
private void calculateOvalAngle(){
if (showRefreshFlag){
oval.set(getMeasuredWidth() / 2 - ovalRadius,-backgroundRadius + refreshPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius) - ovalRadius,
getMeasuredWidth() / 2 + ovalRadius,-backgroundRadius + refreshPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius) + ovalRadius);
}else {
oval.set(getMeasuredWidth() / 2 - ovalRadius,getMeasuredHeight() + backgroundRadius - loadPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius) - ovalRadius,
getMeasuredWidth() / 2 + ovalRadius,getMeasuredHeight() + backgroundRadius - loadPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius) + ovalRadius);
}
}
/**
* calculate start angle if percent larger than CRITICAL_PERCENT start angle should offset a little relative to 0
*
* @return start angle
*/
private float getStartAngle(){
final float percent = showRefreshFlag ? refreshPercent : loadPercent;
if (percent <= CRITICAL_PERCENT){
return 0 + offsetAngle;
}
return START_ANGLE_MAX_OFFSET * (percent - CRITICAL_PERCENT) + offsetAngle;
}
/**
* calculate oval sweep angle
*
* @return sweep angle
*/
private float getSweepAngle(){
final float percent = showRefreshFlag ? refreshPercent : loadPercent;
if (percent > 0 && percent <= CRITICAL_PERCENT){
return percent / CRITICAL_PERCENT * SWEEP_ANGLE_MAX_VALUE;
}
return SWEEP_ANGLE_MAX_VALUE;
}
@Override
public void start() {
if (showLoadFlag && showRefreshFlag){
throw new IllegalStateException("load state and refresh state should be mutual exclusion!");
}
if (showRefreshFlag){
if (refreshPercent >= CRITICAL_PERCENT){
toCriticalPositionAnimation(refreshPercent);
initRotateAnimation();
}else {
translationAnimation(refreshPercent,0);
}
}else {
if (loadPercent >= CRITICAL_PERCENT){
toCriticalPositionAnimation(loadPercent);
initRotateAnimation();
}else {
translationAnimation(loadPercent,0);
}
}
}
@Override
public boolean isRunning() {
if (animator != null){
if(animator.isRunning() || animator.isStarted())return true;
}
if (valueAnimator != null){
if(valueAnimator.isRunning() || valueAnimator.isStarted())return true;
}
return false;
}
/**
* 让标识平移到临界位置
* @param start
*/
private void toCriticalPositionAnimation(final float start){
animator = ValueAnimator.ofFloat(start,CRITICAL_PERCENT);
animator.setInterpolator(mInterpolator);
animator.setDuration((long) (MAX_OFFSET_ANIMATION_DURATION * (start - CRITICAL_PERCENT)));
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (float) animation.getAnimatedValue();
if (showRefreshFlag){
setRefreshPercent(value);
}else {
setLoadPercent(value);
}
if (value == CRITICAL_PERCENT){
startRotateAnimation();
if (showRefreshFlag){
if (mDataSource != null){
mDataSource.onRefreshing();
}
}else {
if (mDataSource != null){
mDataSource.onLoading();
}
}
}
}
});
animator.start();
}
/**
* 让标识平移到起始位置
* @param start
* @param end
*/
private void translationAnimation(final float start,final float end){
animator = ValueAnimator.ofFloat(start,end);
animator.setInterpolator(mInterpolator);
animator.setDuration((long) (MAX_OFFSET_ANIMATION_DURATION * Math.min(start,1)));
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (float) animation.getAnimatedValue();
if (showRefreshFlag){
setRefreshPercent(value);
}else {
setLoadPercent(value);
}
if (value == end){
showLoadFlag = showRefreshFlag = false;
}
}
});
animator.start();
}
/**
* 开始旋转动画
*/
void initRotateAnimation(){
valueAnimator = ValueAnimator.ofFloat(1,360);
valueAnimator.setInterpolator(new LinearInterpolator());
valueAnimator.setDuration(1100);
valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
offsetAngle = (float) animation.getAnimatedValue();
invalidate();
}
});
valueAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
canStopAnimation = false;
}
@Override
public void onAnimationEnd(Animator animation) {
}
@Override
public void onAnimationCancel(Animator animation) {
offsetAngle = 0;
canStopAnimation = true;
if (showRefreshFlag){
translationAnimation(refreshPercent,0);
}else if(showLoadFlag) {
translationAnimation(loadPercent,0);
}
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
}
private void startRotateAnimation(){
valueAnimator.start();
}
@Override
public void stop(){
if (valueAnimator != null && (valueAnimator.isStarted() || valueAnimator.isRunning())){
valueAnimator.cancel();
}
}
@Override
public boolean canInterrupt(){
return canStopAnimation;
}
@Override
public void interruptAnimation(){
if (!canStopAnimation)return;
if (animator != null && (animator.isStarted() || animator.isRunning())){
animator.cancel();
}
}
}
先解释一下属性
backgroundRadius是加载动画的白色背景圆的半径
ovalRadius是蓝色圆弧的半径
START_ANGLE_MAX_OFFSET是圆弧开始绘制时的起始角度(这个角度会发生偏移)
SWEEP_ANGLE_MAX_VALUE圆弧的最大角度
ovalWidth圆弧宽度
offsetAngle这个值是为了旋转动画而存在的,它和START_ANGLE_MAX_OFFSET一起用于计算起始角度,这个值不断改变,使起始角度不断改变,从而实现动画效果
CRITICAL_PERCENT是判断是否应该刷新(加载)的临界百分比值
ItemDecoration中的onDrawOver是实现绘制的方法
@Override public void onDrawOver(Canvas c, RecyclerView parent, State state) { if (showRefreshFlag && refreshPercent > 0){// refresh logo visible // draw background circle c.drawCircle(getMeasuredWidth() / 2, -backgroundRadius + refreshPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius), backgroundRadius, mPaint); if (getSweepAngle() >= SWEEP_ANGLE_MAX_VALUE){// if need, draw circle point drawCirclePoint(true,c); } calculateOvalAngle(); c.drawArc(oval,getStartAngle(),getSweepAngle(),false,ovalPaint);// draw arc }else if (showLoadFlag && loadPercent > 0){// load logo visible // draw background circle c.drawCircle(getMeasuredWidth() / 2, getMeasuredHeight() + backgroundRadius - loadPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius), backgroundRadius, mPaint); if (getSweepAngle() >= SWEEP_ANGLE_MAX_VALUE){// if need, draw circle point drawCirclePoint(false,c); } calculateOvalAngle(); c.drawArc(oval,getStartAngle(),getSweepAngle(),false,ovalPaint);// draw arc } }
整个绘制逻辑比较简单
绘制背景圆 ----- 绘制圆弧 ----- 如果有需要,绘制圆点(如果达到临界值,就绘制,所以圆点也可以用
来判断是否已经可以刷新或加载)以下的绘制过程我都是以刷新动画为例,加载更多的动画类似。绘制背景圆,可以看出起始Y值是-backgroudRadius,也是说圆开始是真好隐藏在屏幕正上方的,然后向下运动。c.drawCircle(getMeasuredWidth() / 2, -backgroundRadius + refreshPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius), backgroundRadius, mPaint);
/** * draw circle point * * @param refresh if true draw refresh logo point, otherwise draw load logo point * @param c canvas */ void drawCirclePoint(boolean refresh,Canvas c){ ovalPaint.setStyle(Paint.Style.FILL); // calculate zhe angle of the point relative to logo central point final double circleAngle = (360 - SWEEP_ANGLE_MAX_VALUE) / 2 - getStartAngle(); // calculate X coordinate for point center final float circleX = getMeasuredWidth() / 2 + (float) (Math.cos(circleAngle * Math.PI / 180) * ovalRadius); // calculate Y coordinate for point center float circleY; if (refresh){ circleY = -backgroundRadius + refreshPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius) - (float) (Math.sin(circleAngle * Math.PI / 180) * ovalRadius); }else { circleY = getMeasuredHeight() + backgroundRadius - loadPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius) - (float) (Math.sin(circleAngle * Math.PI / 180) * ovalRadius); } c.drawCircle(circleX,circleY, ovalWidth / 2 + 2,ovalPaint); ovalPaint.setStyle(Paint.Style.STROKE); }
如果有需要,绘制小圆点。计算圆点的圆心坐标,需要计算circleAngle这个角度,你可以想像,在背景圆圆心和我们要绘制的小圆点的圆心之间连一条线,而背景圆圆心也是一个坐标系的圆点,这个坐标系X轴平行于android的X轴,这个角度就是连线和这个坐标系X轴之间的夹角。至于这个角度为什么这么算,这不画图不好说清啊,建议读者拿一只笔和一张纸自己画画看,帮助理解。然后是绘制圆弧的逻辑calculateOvalAngle(); c.drawArc(oval,getStartAngle(),getSweepAngle(),false,ovalPaint);// draw arc
绘制圆弧分为两步,计算相关参数和绘制android中,绘制圆弧是根据这个圆的外切矩形来决定位置的(绘制圆弧外切矩形其实就是正方形,如果不是正方形,那么绘制的就是椭圆弧)/** * calculate arc circumcircle's position */ private void calculateOvalAngle(){ if (showRefreshFlag){ oval.set(getMeasuredWidth() / 2 - ovalRadius,-backgroundRadius + refreshPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius) - ovalRadius, getMeasuredWidth() / 2 + ovalRadius,-backgroundRadius + refreshPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius) + ovalRadius); }else { oval.set(getMeasuredWidth() / 2 - ovalRadius,getMeasuredHeight() + backgroundRadius - loadPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius) - ovalRadius, getMeasuredWidth() / 2 + ovalRadius,getMeasuredHeight() + backgroundRadius - loadPercent * (DRAG_MAX_DISTANCE_V + backgroundRadius) + ovalRadius); } }
这是计算外切圆的坐标的,不难理解。整个绘制逻辑到此讲完了。说下动画逻辑动画分为两种,一个是平移的动画,和旋转的动画,我说的平移和旋转非android中的平移和旋转,而是都是通过属性动画实现的。/** * 让标识平移到临界位置 * @param start */ private void toCriticalPositionAnimation(final float start){ animator = ValueAnimator.ofFloat(start,CRITICAL_PERCENT); animator.setInterpolator(mInterpolator); animator.setDuration((long) (MAX_OFFSET_ANIMATION_DURATION * (start - CRITICAL_PERCENT))); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float value = (float) animation.getAnimatedValue(); if (showRefreshFlag){ setRefreshPercent(value); }else { setLoadPercent(value); } if (value == CRITICAL_PERCENT){ startRotateAnimation(); if (showRefreshFlag){ if (mDataSource != null){ mDataSource.onRefreshing(); } }else { if (mDataSource != null){ mDataSource.onLoading(); } } } } }); animator.start(); }
这是从超过临界的位置平移到临界位置的动画,平移完成后调用startRotateAnimation开始旋转。为什么startRotateAnimation在onAnimationUpdate中调用而不是给动画添加一个AnimatorLstener,
然后在监听的onAnimationEnd中调用呢,这是因为这个动画是可能被我们cancel掉的,只有真正开始旋转才会掉用
刷新的方法。而调用cancel也会让onAnimationEnd方法执行。这个在android源码中可以看到,我上一些源码,算是展开讲下。@Override public void cancel() { // Only cancel if the animation is actually running or has been started and is about // to run AnimationHandler handler = getOrCreateAnimationHandler(); if (mPlayingState != STOPPED || handler.mPendingAnimations.contains(this) || handler.mDelayedAnims.contains(this)) { // Only notify listeners if the animator has actually started if ((mStarted || mRunning) && mListeners != null) { if (!mRunning) { // If it's not yet running, then start listeners weren't called. Call them now. notifyStartListeners(); } ArrayList<AnimatorListener> tmpListeners = (ArrayList<AnimatorListener>) mListeners.clone(); for (AnimatorListener listener : tmpListeners) { listener.onAnimationCancel(this); } } endAnimation(handler); } }
这是ValueAnimator中的cancel,最后调用了endAnimation,protected void endAnimation(AnimationHandler handler) { handler.mAnimations.remove(this); handler.mPendingAnimations.remove(this); handler.mDelayedAnims.remove(this); mPlayingState = STOPPED; mPaused = false; if ((mStarted || mRunning) && mListeners != null) { if (!mRunning) { // If it's not yet running, then start listeners weren't called. Call them now. notifyStartListeners(); } ArrayList<AnimatorListener> tmpListeners = (ArrayList<AnimatorListener>) mListeners.clone(); int numListeners = tmpListeners.size(); for (int i = 0; i < numListeners; ++i) { tmpListeners.get(i).onAnimationEnd(this); } } mRunning = false; mStarted = false; mStartListenersCalled = false; mPlayingBackwards = false; mReversing = false; mCurrentIteration = 0; if (Trace.isTagEnabled(Trace.TRACE_TAG_VIEW)) { Trace.asyncTraceEnd(Trace.TRACE_TAG_VIEW, getNameForTrace(), System.identityHashCode(this)); } }
看到没,把所有的监听的onAnimationEnd方法执行了一遍。回到我们项目的代码/** * 让标识平移到起始位置 * @param start * @param end */ private void translationAnimation(final float start,final float end){ animator = ValueAnimator.ofFloat(start,end); animator.setInterpolator(mInterpolator); animator.setDuration((long) (MAX_OFFSET_ANIMATION_DURATION * Math.min(start,1))); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float value = (float) animation.getAnimatedValue(); if (showRefreshFlag){ setRefreshPercent(value); }else { setLoadPercent(value); } if (value == end){ showLoadFlag = showRefreshFlag = false; } } }); animator.start(); }
这是从某个位置平移到初始位置的动画/** * 开始旋转动画 */ void initRotateAnimation(){ valueAnimator = ValueAnimator.ofFloat(1,360); valueAnimator.setInterpolator(new LinearInterpolator()); valueAnimator.setDuration(1100); valueAnimator.setRepeatCount(ValueAnimator.INFINITE); valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { offsetAngle = (float) animation.getAnimatedValue(); invalidate(); } }); valueAnimator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animation) { canStopAnimation = false; } @Override public void onAnimationEnd(Animator animation) { } @Override public void onAnimationCancel(Animator animation) { offsetAngle = 0; canStopAnimation = true; if (showRefreshFlag){ translationAnimation(refreshPercent,0); }else if(showLoadFlag) { translationAnimation(loadPercent,0); } } @Override public void onAnimationRepeat(Animator animation) { } }); }
private void startRotateAnimation(){ valueAnimator.start(); }
这是旋转动画相关的两个方法,逻辑很简单。好了,整个项目的主要结构说的差不多了,补充一些细节。项目中的自定义ItemDecoration是放在RecylerView的内部的,这是因为ItemDecoration需要和RecyclerView
配合使用,而一些动画需要的值放在RecyclerView中更合适,而ItemDecoration又需要用到这些值,如果把ItemDecoration定义在外面,那就有需要定义新的接口传递这些值,这显的很多余,代码也不更容易理解,所以就没
这么做。
如果coder想实现自己的前景动画,只需要在RecyclerView中添加一个内部类,这个类继承CustomItemDecor,再实现相关方法即可。
特别感谢github上的pullToRefresh的原作者。动画的背景实现我会写一篇新的文章进行解析,后景是通过添加headerView和footerView配合Drawable实现。最后,欢迎大家和我一起交流,如果有意见,可以直接留言讨论。欢迎加入github优秀项目分享群:589284497,不管你是项目作者或者爱好者,请来和我们一起交流吧。