自定义控件Android控件高级UI

Android - ValueAnimator+PathMeas

2019-06-13  本文已影响21人  东方未曦

一、效果展示

动画分为三种状态:Loading、Success、Fail,可以点击按钮切换状态。
加载后成功的效果如下所示。

Loading->Success.gif

加载后失败的效果如下所示。

Loading->Fail.gif

二、前置知识

1. ValueAnimator

ValueAnimator是属性动画的一种,它不直接改变View的属性,而是不断生成一个代表动画进度的值,用户通过该值改变View的某些属性达到动画的效果。ValueAnimator基本的用法如下。

ValueAnimator anim = ValueAnimator.ofFloat(0, 1); // 动画的进度为[0, 1]中某个值
anim.setDuration(1000); // 动画的时长为 1s
anim.setRepeatMode(ValueAnimator.RESTART); // 动画重复时重新开始
anim.setRepeatCount(ValueAnimator.INFINITE); // 动画重复次数为无限次
anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        progress = (float) animation.getAnimatedValue(); // 获取到动画的当前进度
        invalidate(); // 立即重绘当前 View
    }
});
anim.start();

程序通过为ValueAnimator设置监听来获取当前的进度progress,这里的进度为[0, 1]中的某个float值。如果有一个这样的需求,要求某个View从透明慢慢变为显示,就可以通过view.setAlpha(progress * 255)来实现这种淡出的效果。
除了AnimatorUpdateListener,ValueAnimator还有个监听器AnimatorListener,主要用于对动画的状态进行监听,4个方法如下。

public void start() { } // 动画开始时调用
public void end() { } // 动画结束时调用
public void cancel() { } // 动画取消时调用
public void repeat() { } // 动画重复时调用

2. PathMeasure

PathMeasure用于实现路径动画,它能够截取Path中的一段内容进行显示。构造方法如下。
参数中的path就是PathMeasure 之后截取的对象,forceClosed只对测量PathMeasure长度的结果有影响,一般设置为false。

PathMeasure pm = new PathMeasure(Path path, boolean forceClosed);

PathMeasure的常用函数如下。

float getLength(); // 获取当前段的长度(注意是当前)
boolean nextContour(); // 跳转到Path的下一条曲线,成功返回true
/**
 * 通过startD和stopD来截取Path中的某个片段
 * 结果保存至dst
 * startWithMoveTo为true时,截取的path保存原样
 */
boolean getSegment(float startD, float stopD, Path dst, boolean startWithMoveTo);

如果你还不理解也不要紧,下面让我们在实战中去理解ValueAnimator和PathMeasure。

三、Loading动画

首先来完成Loading动画,该动画的主体是一个圆,在动画的过程中不断对圆切割,展示其中的一部分。实现加载框的有种做法是在View每次调用onDraw(Canvas)时调整圆弧的起始角度(startAngle)和扫过的角度(sweepAngle),再根据角度绘制Arc圆弧,但是这种做法有3个问题。第一是不方便控制动画的时间,也就是duration; 第二是不方便设置插值器,导致动画一直为匀速;第三是无法对动画的各种状态(开始、结束等)进行监听。
而ValueAnimator正好能解决上述问题,我们通过ValueAnimator计算动画的进度,再通过PathMeasure切割圆弧。我们一步一步来,先尝试把简单的圆画出来。
自定义一个PayTestView ,在其中新建一个ValueAnimator,该动画的进度从0到1,并设置为无限循环,在监听器的onAnimationUpdate()方法中将动画的进度赋值给mProgress。随后新建一个PathMeasure,因为所要截取的动画为一个圆,所以新建PathMeasure时传入一个圆形Path。

public class PayTestView extends View {

    private float mProgress; // 代表动画当前进度

    private Paint mBluePaint; // 蓝色画笔

    private ValueAnimator mLoadingAnimator;
    private PathMeasure mLoadingPathMeasure;
    private Path mDstPath; // 保存PathMeasure切割后的内容

    public PayTestView(Context context) {
        super(context);
        init();
    }

    public PayTestView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public PayTestView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        setLayerType(LAYER_TYPE_SOFTWARE, null); // 取消硬件加速
        // 画笔设置
        mBluePaint = new Paint(Paint.ANTI_ALIAS_FLAG); // 画笔抗锯齿
        mBluePaint.setColor(Color.BLUE);
        mBluePaint.setStyle(Paint.Style.STROKE);
        mBluePaint.setStrokeWidth(10);
        mBluePaint.setStrokeCap(Paint.Cap.ROUND);
        // 新建 PathMeasure
        Path loadingPath = new Path();
        loadingPath.addCircle(100, 100, 60, Path.Direction.CW); // CW代表顺时针
        mLoadingPathMeasure = new PathMeasure(loadingPath, false);
        mDstPath = new Path();
        // 动画
        mLoadingAnimator = ValueAnimator.ofFloat(0, 1);
        mLoadingAnimator.setDuration(1500);
        mLoadingAnimator.setRepeatMode(ValueAnimator.RESTART);
        mLoadingAnimator.setRepeatCount(ValueAnimator.INFINITE);
        mLoadingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mProgress = (float) animation.getAnimatedValue();
                invalidate();
            }
        });
        mLoadingAnimator.start();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mDstPath.reset();
        float stop = mLoadingPathMeasure.getLength() * mProgress;
        mLoadingPathMeasure.getSegment(0, stop, mDstPath, true);
        canvas.drawPath(mDstPath, mBluePaint);
    }
}

可以发现在onDraw()中使用getSegment(start, stop, ...)切割圆弧时,起点start永远是0,而终点是整个圆弧的长度乘当前进度。每次调用onDraw()时就将从0到当前进度的圆弧切割,因此得到的是圆弧从0度增长到到360度的动画,并且循环播放。效果如下。

Loading1.gif

当前动画中圆弧的起点一直是0,最后的效果比较僵硬,我们尝试修改动画的起点。这里使用启舰《Anroid自定义控件开发入门与实战》中的方法,在mProgress <= 0.5时,start为0,在mProgress > 0.5时,start为mProgress * 2 - 1。修改onDraw()中的代码如下:

mDstPath.reset();
float length = mPathMeasure.getLength();
float stop = mProgress * length;
float start = (float) (stop - (0.5 - Math.abs(mProgress - 0.5)) * length);
mPathMeasure.getSegment(start, stop, mDstPath, true);

这里将start的计算放在了一句代码中,当然用if-else的方法来做可读性会更高。最终的效果如下。

Loading2.gif

这个效果离之前的展示的Loading动画已经比较接近了,仔细观察可以发现,最终的Loading动画只是在当前的动画上加了一个整体旋转的效果。我们可以通过旋转View的画布(Canvas)来实现,要注意的是,旋转时必须按照Loading动画的圆心进行旋转。
不过对画布的旋转也会影响到之后成功/失败状态下的动画,因此在旋转之前需要将当前的画布保存,然后在Loading动画结束之后恢复画布。
首先定义一个变量标记画布是否被保存了,因为画布只需要保存一次;随后在进入onDraw()时判断画布是否已经被保存,如果未保存,则保存当前画布,否则跳过。

public class PayTestView extends View {

    // ......

    private boolean hasCanvasSaved = false; // 画布是否已被保存
    private int mCurRotate = 0; // 当前画布旋转的角度

    // ......

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 在 Loading 状态下 Canvas 会被旋转, 需要在第一次进入时保存
        if (!hasCanvasSaved) {
            canvas.save();
            hasCanvasSaved = true;
        }
        // Loading 动画
        // ......
        mCurRotate = (mCurRotate + 2) % 360;
        canvas.rotate(mCurRotate, 100, 100);
        canvas.drawPath(mDstPath, mBluePaint);
    }
}

效果如下所示。

Loading3.gif

此时的Loading动画已经完成,只不过圆的坐标(100, 100)和半径(60)是固定的。其实我们可以在onSizeChanged()中获取当前View的高宽,再去设置圆的坐标和半径。这里不细说,后面会在整体代码中贴出。

三、状态切换

整个支付的动画包含3种状态:加载、成功、失败。那么绘制时怎么识别当前的状态?他们之间的状态又是怎么切换的呢?
对于第一个问题,我们可以在onDraw(Canvas)中根据动画当前的状态来绘制不同的图形。但是要注意在绘制Loading动画之前保存画布;在绘制成功或失败动画之前将原始的画布恢复。onDraw()中的逻辑如下所示。

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    // 在 Loading 状态下 Canvas 会被旋转, 需要在第一次进入时保存
    if (!hasCanvasSaved) {
        canvas.save();
        hasCanvasSaved = true;
    }
    // 判断当前动画的状态并绘制相应动画
    if (curStatus == STATUS_LOADING) {
        // 绘制 Loading 动画
    } else if (curStatus == STATUS_SUCCESS) {
        // 如果画布还未恢复则将其恢复
        if (!hasCanvasRestored) {
            canvas.restore();
            hasCanvasRestored = true;
        }
        // 绘制 success 动画
    } else if (curStatus == STATUS_FAIL) {
        if (!hasCanvasRestored) {
            canvas.restore();
            hasCanvasRestored = true;
        }
        // 绘制 fail 动画
    }
}

对于第二个问题,状态之间的切换需要外部调用,且只能由loading向success或fail切换。因此View中需要一个供外部修改状态的方法,同时修改状态后需要停止Loading动画。

// 将动画的状态从 Loading 变为 success 或 fail
public void setStatus(int status) {
    if (curStatus == STATUS_LOADING && status != STATUS_LOADING) {
        curStatus = status;
        mLoadingAnimator.end();
    }
}

Loading动画结束之后需要开始success动画或fail动画,你可以在上述的setStatus()方法中开始success/fail动画,也可以为Loading动画设置监听,监听到其结束时开启新动画。这里使用监听的方式,如下所示。

mLoadingAnimator.addListener(new Animator.AnimatorListener() {
    @Override
    public void onAnimationStart(Animator animation) { }

    @Override
    public void onAnimationEnd(Animator animation) {
        if (curStatus == STATUS_SUCCESS) {
            mSuccessAnimator.start();
        } else if (curStatus == STATUS_FAIL) {
            mFailAnimator.start();
        }
    }

    @Override
    public void onAnimationCancel(Animator animation) { }

    @Override
    public void onAnimationRepeat(Animator animation) { }
});

了解了3种动画的切换逻辑之后,再来看看成功/失败动画的实现。

四、成功动画

之前的Loading动画只有一段路径,就是一个圆。而成功的动画包含两段:外部的圆和内部的勾。之前介绍过PathMeasure是可以通过nextContour()方法从Path中的一段路径切换到下一段的,因此我们可以构造一个由两段路径构成的PathMeasure。

Path successPath = new Path();
successPath.addCircle(100, 100, 60, Path.Direction.CW);
successPath.moveTo(100- 60 * 0.5f, 100- 60 * 0.2f);
successPath.lineTo(100 - 60 * 0.1f, 100 + 60 * 0.4f);
successPath.lineTo(100 + 60 * 0.6f, 100 - 60 * 0.5f);
mSuccessPathMeasure = new PathMeasure(successPath, false);

代码首先在successPath中添加了外圈的圆,随后moveTo到勾的起点,lineTo到勾的下方,最后lineTo到勾的终点。很显然moveTo之前的是第一段路径,moveTo之后的是第二段路径。
为了在ValueAnimator的进度中将两段路径分开,新建时进度的范围设置为[0, 2]。

mSuccessAnimator = ValueAnimator.ofFloat(0, 2);
// ......

在绘制时,mProgress∈[0, 1]代表外部的圆,mProgress∈[1, 2]代表内部的勾,我们通过一个变量来表示当前在绘制第几段,初始化时为1。

private int mSuccessIndex = 1;

绘制代码如下,当mProgress < 1时绘制外部的圆,mProgress >= 1时切换到下一条路径。代码在切换路径之前通过mSuccessPathMeasure.getSegment(0, mSuccessPathMeasure.getLength(), mSuccessDstPath, true)将第一段路径完整地绘制了一下。如果不使用这句代码,第一段的圆绘制出来不是完整的。

if (mProgress < 1) {
    float stop = mSuccessPathMeasure.getLength() * mProgress;
    mSuccessPathMeasure.getSegment(0, stop, mSuccessDstPath, true);
} else {
    if (mSuccessIndex == 1) {
        mSuccessIndex = 2;
        mSuccessPathMeasure.getSegment(0, mSuccessPathMeasure.getLength(), mSuccessDstPath, true);
        mSuccessPathMeasure.nextContour();
    }
    float stop = mSuccessPathMeasure.getLength() * (mProgress - 1);
    mSuccessPathMeasure.getSegment(0, stop, mSuccessDstPath, true);
}
canvas.drawPath(mSuccessDstPath, mBluePaint);

PS:千万不要在切换路径时使用if (mProgress == 1)这种写法,首先动画的进度值是float类型的,要判断float值是否“相等”只能用if (Math.abs(mProgress - 1) < 0.01)这种方式;其次如果把动画执行中的所有进度值打印出来,会是这个样子的:

2019-06-13 00:00:24.842 2224-2224/com.lister.myviews E/TAG: progress = 0.0
2019-06-13 00:00:24.853 2224-2224/com.lister.myviews E/TAG: progress = 5.57065E-4
2019-06-13 00:00:24.869 2224-2224/com.lister.myviews E/TAG: progress = 0.0022275448
......
2019-06-13 00:00:25.604 2224-2224/com.lister.myviews E/TAG: progress = 0.9411293
2019-06-13 00:00:25.621 2224-2224/com.lister.myviews E/TAG: progress = 0.9725146
2019-06-13 00:00:25.637 2224-2224/com.lister.myviews E/TAG: progress = 1.0058903
2019-06-13 00:00:25.656 2224-2224/com.lister.myviews E/TAG: progress = 1.0392599
2019-06-13 00:00:25.675 2224-2224/com.lister.myviews E/TAG: progress = 1.0706271
2019-06-13 00:00:25.693 2224-2224/com.lister.myviews E/TAG: progress = 1.1038773
......
2019-06-13 00:00:26.407 2224-2224/com.lister.myviews E/TAG: progress = 1.9984891
2019-06-13 00:00:26.423 2224-2224/com.lister.myviews E/TAG: progress = 1.9997668
2019-06-13 00:00:26.440 2224-2224/com.lister.myviews E/TAG: progress = 2.0

进度值progress会在多大的范围内逼近1.0是无法确定的,因此直接判断进度值是不是小于1比较妥当。

五、完整代码

fail状态下的动画比较简单,是一个三段的路径,不再赘述。这里贴出完整代码,注释也比较齐全,如果有不完善的地方还望批评指正。

public class PayAnimatorView extends View {

    /**
     * 动画状态:加载中、成功、失败
     */
    public static final int STATUS_LOADING = 1;
    public static final int STATUS_SUCCESS = 2;
    public static final int STATUS_FAIL = 3;

    /**
     * 当前动画的状态
     */
    private int curStatus;

    /**
     * loading 动画变量
     */
    private PathMeasure mPathMeasure;
    private Path mDstPath;
    private int mCurRotate = 0;
    private float mProgress;
    private boolean hasCanvasSaved = false;
    private boolean hasCanvasRestored = false;

    /**
     * success / Fail 动画变量
     */
    private PathMeasure mSuccessPathMeasure;
    private Path mSuccessDstPath;
    private PathMeasure mFailPathMeasure;
    private Path mFailDstPath;

    /**
     * 动画
     */
    private ValueAnimator mLoadingAnimator;
    private ValueAnimator mSuccessAnimator;
    private ValueAnimator mFailAnimator;

    private Paint mBluePaint;
    private Paint mRedPaint;

    private int mCenterX, mCenterY;

    public PayAnimatorView(Context context) {
        super(context);
        init();
    }

    public PayAnimatorView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public PayAnimatorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mCenterX = w / 2;
        mCenterY = h / 2;
        int radius = (int) (Math.min(w, h) * 0.3);
        // 在获取宽高之后设置加载框的位置和大小
        Path circlePath = new Path();
        circlePath.addCircle(mCenterX, mCenterY, radius, Path.Direction.CW);
        mPathMeasure = new PathMeasure(circlePath, true);
        mDstPath = new Path();
        // 设置 success 动画的 path
        Path successPath = new Path();
        successPath.addCircle(mCenterX, mCenterY, radius, Path.Direction.CW);
        successPath.moveTo(mCenterX - radius * 0.5f, mCenterY - radius * 0.2f);
        successPath.lineTo(mCenterX - radius * 0.1f, mCenterY + radius * 0.4f);
        successPath.lineTo(mCenterX + radius * 0.6f, mCenterY - radius * 0.5f);
        mSuccessPathMeasure = new PathMeasure(successPath, false);
        mSuccessDstPath = new Path();
        // 设置 fail 动画的 path
        Path failPath = new Path();
        failPath.addCircle(mCenterX, mCenterY, radius, Path.Direction.CW);
        failPath.moveTo(mCenterX - radius / 3, mCenterY - radius / 3);
        failPath.lineTo(mCenterX + radius / 3, mCenterY + radius / 3);
        failPath.moveTo(mCenterX + radius / 3, mCenterY - radius / 3);
        failPath.lineTo(mCenterX - radius / 3, mCenterY + radius / 3);
        mFailPathMeasure = new PathMeasure(failPath, false);
        mFailDstPath = new Path();
    }

    private void init() {
        // 取消硬件加速
        setLayerType(LAYER_TYPE_SOFTWARE, null);

        // 初始化画笔
        mBluePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mBluePaint.setColor(Color.BLUE);
        mBluePaint.setStyle(Paint.Style.STROKE);
        mBluePaint.setStrokeCap(Paint.Cap.ROUND);
        mBluePaint.setStrokeWidth(10);

        mRedPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mRedPaint.setColor(Color.RED);
        mRedPaint.setStyle(Paint.Style.STROKE);
        mRedPaint.setStrokeCap(Paint.Cap.ROUND);
        mRedPaint.setStrokeWidth(10);

        // 初始化时, 动画为加载状态
        curStatus = STATUS_LOADING;

        // 新建 Loading 动画并 start
        mLoadingAnimator = ValueAnimator.ofFloat(0, 1);
        mLoadingAnimator.setDuration(2000);
        mLoadingAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mProgress = (float) animation.getAnimatedValue();
                invalidate();
            }
        });
        mLoadingAnimator.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) { }

            @Override
            public void onAnimationEnd(Animator animation) {
                if (curStatus == STATUS_SUCCESS) {
                    mSuccessAnimator.start();
                } else if (curStatus == STATUS_FAIL) {
                    mFailAnimator.start();
                }
            }

            @Override
            public void onAnimationCancel(Animator animation) { }

            @Override
            public void onAnimationRepeat(Animator animation) { }
        });
        mLoadingAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
        mLoadingAnimator.setRepeatCount(ValueAnimator.INFINITE);
        mLoadingAnimator.setRepeatMode(ValueAnimator.RESTART);
        mLoadingAnimator.start();
        // 新建 success 动画
        mSuccessAnimator = ValueAnimator.ofFloat(0, 2);
        mSuccessAnimator.setDuration(1600);
        mSuccessAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mProgress = (float) animation.getAnimatedValue();
                invalidate();
            }
        });
        // 新建 fail 动画
        mFailAnimator = ValueAnimator.ofFloat(0, 3);
        mFailAnimator.setDuration(2100);
        mFailAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mProgress = (float) animation.getAnimatedValue();
                invalidate();
            }
        });
    }

    /**
     * 将动画的状态从 Loading 变为 success 或 fail
     */
    public void setStatus(int status) {
        if (curStatus == STATUS_LOADING && status != STATUS_LOADING) {
            curStatus = status;
            mLoadingAnimator.end();
        }
    }

    private int mSuccessIndex = 1;
    private int mFailIndex = 1;

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 在 Loading 状态下 Canvas 会被旋转, 需要在第一次进入时保存
        if (!hasCanvasSaved) {
            canvas.save();
            hasCanvasSaved = true;
        }
        // 判断当前动画的状态并绘制相应动画
        if (curStatus == STATUS_LOADING) {
            mDstPath.reset();
            float length = mPathMeasure.getLength();
            float stop = mProgress * length;
            float start = (float) (stop - (0.5 - Math.abs(mProgress - 0.5)) * length);
            mPathMeasure.getSegment(start, stop, mDstPath, true);
            // 旋转画布
            mCurRotate = (mCurRotate + 2) % 360;
            canvas.rotate(mCurRotate, mCenterX, mCenterY);
            canvas.drawPath(mDstPath, mBluePaint);
        } else if (curStatus == STATUS_SUCCESS) {
            if (!hasCanvasRestored) {
                canvas.restore();
                hasCanvasRestored = true;
            }
            if (mProgress < 1) {
                float stop = mSuccessPathMeasure.getLength() * mProgress;
                mSuccessPathMeasure.getSegment(0, stop, mSuccessDstPath, true);
            } else {
                if (mSuccessIndex == 1) {
                    mSuccessIndex = 2;
                    mSuccessPathMeasure.getSegment(0, mSuccessPathMeasure.getLength(),
                            mSuccessDstPath, true);
                    mSuccessPathMeasure.nextContour();
                }
                float stop = mSuccessPathMeasure.getLength() * (mProgress - 1);
                mSuccessPathMeasure.getSegment(0, stop, mSuccessDstPath, true);
            }
            canvas.drawPath(mSuccessDstPath, mBluePaint);
        } else if (curStatus == STATUS_FAIL) {
            if (!hasCanvasRestored) {
                canvas.restore();
                hasCanvasRestored = true;
            }
            if (mProgress < 1) {
                float stop = mFailPathMeasure.getLength() * mProgress;
                mFailPathMeasure.getSegment(0, stop, mFailDstPath, true);
            } else if (mProgress < 2) {
                if (mFailIndex == 1) {
                    mFailIndex = 2;
                    mFailPathMeasure.getSegment(0, mFailPathMeasure.getLength(),
                            mFailDstPath, true);
                    mFailPathMeasure.nextContour();
                }
                float stop = mFailPathMeasure.getLength() * (mProgress - 1);
                mFailPathMeasure.getSegment(0, stop, mFailDstPath, true);
            } else {
                if (mFailIndex == 2) {
                    mFailIndex = 3;
                    mFailPathMeasure.getSegment(0, mFailPathMeasure.getLength(),
                            mFailDstPath, true);
                    mFailPathMeasure.nextContour();
                }
                float stop = mFailPathMeasure.getLength() * (mProgress - 2);
                mFailPathMeasure.getSegment(0, stop, mFailDstPath, true);
            }
            canvas.drawPath(mFailDstPath, mRedPaint);
        }
    }
}
上一篇下一篇

猜你喜欢

热点阅读