[Android]朝花夕拾之仿MIUI时钟效果绘制

858 阅读6分钟

本文为原创文章,转载请注明出处,原创不易,且转且珍惜

1. 前言

几年前做过一个类似MIUI时钟的效果,逻辑比较简单,虽然MIUI经过几年的系统迭代,时钟早已不是这个效果,但当时做需求时涉及到一些canvas绘制的技巧,想来还是有些意思,并且这些思路如果最近把代码翻了出来,回顾一下当时的想法和策略。

2.效果

俗话说的好,没有图你说个XX,先把效果图发出来看一下: miui[00_00_03--00_00_23].gif

#3. 用到的知识点

3.1. Canvas#saveLayer和Canvas#restore

Canvas 在一般的情况下可以看作是一张画布,所有的绘图操作如drawBitmap, drawCircle都发生在这张画布上,这张画板还定义了一些属性比如Matrix,颜色等等。但是如果需要实现一些相对复杂的绘图操作,比如多层动画,地图(地图可以有多个地图层叠加而成,比如:政区层,道路层,兴趣点层)。Canvas提供了图层(Layer)支持,缺省情况可以看作是只有一个图层Layer。如果需要按层次来绘图,Android的Canvas可以使用SaveLayerXXX, Restore 来创建一些中间层,对于这些Layer是按照“栈结构“来管理的:  

1355906035_7646.png

创建一个新的Layer到“栈”中,可以使用saveLayer, savaLayerAlpha, 从“栈”中推出一个Layer,可以使用restore,restoreToCount。但Layer入栈时,后续的DrawXXX操作都发生在这个Layer上,而Layer退栈时,就会把本层绘制的图像“绘制”到上层或是Canvas上,在复制Layer到Canvas上时,可以指定Layer的透明度(Layer),这是在创建Layer时指定的:public int saveLayerAlpha(RectF bounds, int alpha, int saveFlags)本例Layers 介绍了图层的基本用法:Canvas可以看做是由两个图层(Layer)构成的。

3.2 Canvas转换

Canvas的转换主要有旋转、缩放、扭曲、平移、裁剪等,本文主要用到的是旋转(rotate)

4. 思路

4.1 总体思路

我们默认Canvas和系统坐标系是对应的,没有发生任何旋转缩放,因此,初始状态将每个元素绘制在View的12点钟方向,绘制每个元素时都要新建图层,待绘制完成后将图层旋转至当前时间所指示的方向即可。

4.2 当前时间角度的计算

一个圆的角度是360度,我们默认12点钟为0度,那么当前时针、分针、秒针所旋转的角度分别为:

mHourAngle = (Calendar.getInstance().get(Calendar.HOUR) + ((float)Calendar.getInstance().get(Calendar.MINUTE)) / 60) * (360 / 12);
mMinuteAngle = (Calendar.getInstance().get(Calendar.MINUTE) + ((float)Calendar.getInstance().get(Calendar.SECOND)) / 60) * (360 / 60);
mSecondStartAngle = Math.round((Calendar.getInstance().get(Calendar.SECOND) + Calendar.getInstance().get(Calendar.MILLISECOND) / Constants.SECOND) * (360 / 60));

4.3 秒针(三角)的初始化

这里秒针的实现是使用的Canvas#drawPath,在drawPath之前,我们需要穿件一个Path使其成为一个封闭的三角形,Canvas提供了moveTo、lineTo、colse方法帮我们实现这个效果:

        //初始化三角, 该三角形为底边40, 高27的等腰三角形
        mTriangle = new Path();
        mTriangle.moveTo(mGraduationPoint.x , mGraduationPoint.y + 70);// 此点为多边形的起点
        mTriangle.lineTo(mGraduationPoint.x - 20, mGraduationPoint.y + 97);
        mTriangle.lineTo(mGraduationPoint.x + 20, mGraduationPoint.y + 97);
        mTriangle.close(); // 使这些点构成封闭的多边形

初始化完成后,秒针针头指向12点钟方向

4.4 当前时间的绘制

这里用到的是图层的创建和保存,以及Canvs#save方法,首先绘制秒针和中间圆环:

        int layerCount = canvas.saveLayer(0 , 0 , canvas.getWidth() , canvas.getHeight() , mDefaultPaint , Canvas.ALL_SAVE_FLAG);
        Log.d("zyl", "sanjiaolayerCount = " + layerCount);
        // 将图层旋转至秒针所指的方向
        canvas.rotate(mClockAngle + mSecondStartAngle , mCenterPoint.x , mCenterPoint.y);

        //画三角
        canvas.drawPath(mTriangle, mPaint);

        //画中心的圆圈
        canvas.drawBitmap(mCircleBitmap , null , mDstCircleRect , mDefaultPaint);

        canvas.restoreToCount(layerCount); // 恢复图层

注意,调用restoreToCount或restore后,会将图层恢复至save或saveLayer之前的状态

秒针和中间圆环绘制完成后,绘制时针和分针,操作同上:

        //画时针
        layerCount = canvas.saveLayer(0, 0, canvas.getWidth(), canvas.getHeight(), mDefaultPaint, Canvas.ALL_SAVE_FLAG); //新建图层
        Log.d("zyl", "shizhenLayerCount = " + layerCount);
        canvas.rotate(mHourAngle , mCenterPoint.x , mCenterPoint.y);
        canvas.drawBitmap(mHourBitmap , null , mDstHourRect , mDefaultPaint);

        canvas.restoreToCount(layerCount);

        //画分针
        layerCount = canvas.saveLayer(0, 0, canvas.getWidth(), canvas.getHeight(), mDefaultPaint, Canvas.ALL_SAVE_FLAG);
        Log.d("zyl", "fenzhenlayerCount = " + layerCount);
        canvas.rotate(mMinuteAngle , mCenterPoint.x , mCenterPoint.y);
        canvas.drawBitmap(mMinuteBitmap , null , mDstMinuteRect , mDefaultPaint);

        canvas.restoreToCount(layerCount);

4.5 周边刻度的绘制

4.5.1 刻度绘制

圆环周边的刻度共有180个,我们需要新建一个图层,旋转180次,每次旋转2度即可:

        Log.d("zyl", "fenzhenlayerCount = " + layerCount);
        canvas.rotate(mSecondAngle + mSecondStartAngle , mCenterPoint.x, mCenterPoint.y);
        for (int i = 0; i < GRADUATION_COUNT; i++) {
            canvas.drawLine(mGraduationPoint.x, mGraduationPoint.y + 5, mGraduationPoint.x, mGraduationPoint.y + GRADUATION_LENGTH, mGraduationPaint);
            canvas.rotate(-PER_GRADUATION_ANGLE, mCenterPoint.x, mCenterPoint.y);
        }
4.5.2 拖尾效果

刻度有一个从透明度255到透明度120的渐变拖尾效果,因此,我们需要逆时针绘制刻度,并且每次绘制时将透明度 减三,直到透明度到达120:

        //画刻度
        layerCount = canvas.saveLayer(0, 0, canvas.getWidth(), canvas.getHeight(), mDefaultPaint, Canvas.ALL_SAVE_FLAG);
        Log.d("zyl", "fenzhenlayerCount = " + layerCount);
        canvas.rotate(mSecondAngle + mSecondStartAngle , mCenterPoint.x, mCenterPoint.y);
        for (int i = 0; i < GRADUATION_COUNT; i++) {
            int alpha = 255 - i * 3;
            if (alpha > 120) {
                mGraduationPaint.setAlpha(alpha);
            }
            canvas.drawLine(mGraduationPoint.x, mGraduationPoint.y + 5, mGraduationPoint.x, mGraduationPoint.y + GRADUATION_LENGTH, mGraduationPaint);
            canvas.rotate(-PER_GRADUATION_ANGLE, mCenterPoint.x, mCenterPoint.y);
        }
        canvas.restoreToCount(layerCount);

5 动效

注意看效果图,秒针的运动比较圆润丝滑,而刻度的运动时从上一个跳到下一个,有一种秒针指引刻度运动的感觉,因此我们需要定义两个动画,一个秒针动画,使用float值,一个刻度动画,使用int值,这两个动画选择其中一个监听动画变化即可

public void startAnimation() {
        //三角刻度动画
        mClockAnimator = ValueAnimator.ofFloat(0 , GRADUATION_COUNT);
        mClockAnimator.setDuration(Constants.MINUTE);
        mClockAnimator.setInterpolator(new LinearInterpolator());
        mClockAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                mClockAngle = (float) valueAnimator.getAnimatedValue() * PER_GRADUATION_ANGLE;

            }
        });
        mClockAnimator.setRepeatCount(ValueAnimator.INFINITE);

        //圆圈刻度动画
        mSecondAnimator = ValueAnimator.ofInt(0 , GRADUATION_COUNT);
        mSecondAnimator.setDuration(Constants.MINUTE);
        mSecondAnimator.setInterpolator(new LinearInterpolator());
        mSecondAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                mSecondAngle = (int) valueAnimator.getAnimatedValue() * PER_GRADUATION_ANGLE;
                mHourAngle = (Calendar.getInstance().get(Calendar.HOUR) + ((float)Calendar.getInstance().get(Calendar.MINUTE)) / 60) * (360 / 12);
                mMinuteAngle = (Calendar.getInstance().get(Calendar.MINUTE) + ((float)Calendar.getInstance().get(Calendar.SECOND)) / 60) * (360 / 60);
                Log.d("zyl", "second = " + Calendar.getInstance().get(Calendar.SECOND));
                Log.d("zyl", "mMinuteAngle = " + mMinuteAngle);
                invalidate();
            }
        });
        mSecondAnimator.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animator) {
                mHourAngle = (Calendar.getInstance().get(Calendar.HOUR) + ((float)Calendar.getInstance().get(Calendar.MINUTE)) / 60) * (360 / 12);
                mMinuteAngle = (Calendar.getInstance().get(Calendar.MINUTE) + ((float)Calendar.getInstance().get(Calendar.SECOND)) / 60) * (360 / 60);
                mSecondStartAngle = Math.round((Calendar.getInstance().get(Calendar.SECOND) + Calendar.getInstance().get(Calendar.MILLISECOND) / Constants.SECOND) * (360 / 60));
            }

            @Override
            public void onAnimationEnd(Animator animator) {

            }

            @Override
            public void onAnimationCancel(Animator animator) {

            }

            @Override
            public void onAnimationRepeat(Animator animator) {

            }
        });
        mSecondAnimator.setRepeatCount(ValueAnimator.INFINITE);

        mSecondAnimator.start();
        mClockAnimator.start();
    }

完整版代码:

public class MIUIClock extends View {

    private Paint mPaint;
    private Context mContext;
    private Paint mDefaultPaint;
    private Paint mGraduationPaint;
    private Rect mContentRect;
    private Path mTriangle;
    private Point mGraduationPoint;
    private Point mCenterPoint;
    private Rect mDstCircleRect; //时钟中心圆圈所在位置
    private Rect mDstHourRect; //时针所在位置
    private Rect mDstMinuteRect; //分针所在位置
    private ValueAnimator mClockAnimator;
    private ValueAnimator mSecondAnimator;
    private float mSecondStartAngle; //圆环的起始角度
    private float mClockAngle; //三角指针角度
    private int mSecondAngle; //圆环角度
    private float mHourAngle; //时针角度
    private float mMinuteAngle; //分针角度
    private static final int GRADUATION_LENGTH = 50; //圆环刻度长度
    private static final int GRADUATION_COUNT = 180; //一圈圆环刻度的数量
    private static final int ROUND_ANGLE = 360; //圆一周的角度
    private static final int PER_GRADUATION_ANGLE = ROUND_ANGLE / GRADUATION_COUNT; //每个刻度的角度
    private Bitmap mCircleBitmap; //时钟中心的圆圈
    private Bitmap mHourBitmap; //时针
    private Bitmap mMinuteBitmap; //分针

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

    public MIUIClock(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    private void init(Context context) {
        mContext = context;
        mDefaultPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setColor(Color.WHITE);
        mPaint.setStrokeCap(Paint.Cap.ROUND);
        mPaint.setAlpha(120);

        mGraduationPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mGraduationPaint.setColor(Color.WHITE);
        mGraduationPaint.setStrokeWidth(4);
        mGraduationPaint.setStrokeCap(Paint.Cap.ROUND);

        mCircleBitmap = BitmapFactory.decodeResource(mContext.getResources() , R.mipmap.ic_circle);
        mHourBitmap = BitmapFactory.decodeResource(mContext.getResources() , R.mipmap.ic_hour);
        mMinuteBitmap = BitmapFactory.decodeResource(mContext.getResources() , R.mipmap.ic_minute);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mContentRect = new Rect(0 , 0 , w, h); // 本View内容区域
        mGraduationPoint = new Point(w /2 , 0); // 圆圈刻度绘制的参照位置
        mCenterPoint = new Point(w /2 , h /2); // 本View中心点位置
        //初始化三角, 该三角形为底边40, 高27的等腰三角形
        mTriangle = new Path();
        mTriangle.moveTo(mGraduationPoint.x , mGraduationPoint.y + 70);// 此点为多边形的起点
        mTriangle.lineTo(mGraduationPoint.x - 20, mGraduationPoint.y + 97);
        mTriangle.lineTo(mGraduationPoint.x + 20, mGraduationPoint.y + 97);
        mTriangle.close(); // 使这些点构成封闭的多边形

        //初始化circle所在位置, 将圆圈置于View 中心
        int circleWidth = mCircleBitmap.getWidth();
        int circleHeight = mCircleBitmap.getHeight();
        mDstCircleRect = new Rect(mCenterPoint.x - circleWidth /2 , mCenterPoint.y - circleHeight/2 ,
                mCenterPoint.x + circleWidth /2 , mCenterPoint.y  + circleHeight /2);

        //初始化时针所在位置
        int hourWidth = mHourBitmap.getWidth();
        int hourHeight = mHourBitmap.getHeight();
        mDstHourRect = new Rect(mCenterPoint.x - hourWidth / 2 , mCenterPoint.y - hourHeight - circleHeight / 2 - 5,
                mCenterPoint.x + hourWidth / 2, mCenterPoint.y - circleHeight / 2 - 5);

        //初始化分针所在位置
        int minuteWidth = mMinuteBitmap.getWidth();
        int minuteHeight = mMinuteBitmap.getHeight();
        mDstMinuteRect = new Rect(mCenterPoint.x - minuteWidth / 2 , mCenterPoint.y - minuteHeight - circleHeight / 2 - 5,
                mCenterPoint.x + minuteWidth / 2 , mCenterPoint.y - circleHeight / 2 - 5);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        int layerCount = canvas.saveLayer(0 , 0 , canvas.getWidth() , canvas.getHeight() , mDefaultPaint , Canvas.ALL_SAVE_FLAG);
        Log.d("zyl", "sanjiaolayerCount = " + layerCount);
        canvas.rotate(mClockAngle + mSecondStartAngle , mCenterPoint.x , mCenterPoint.y);

        //画三角
        canvas.drawPath(mTriangle, mPaint);

        //画中心的圆圈
        canvas.drawBitmap(mCircleBitmap , null , mDstCircleRect , mDefaultPaint);

        canvas.restoreToCount(layerCount);

        //画时针
        layerCount = canvas.saveLayer(0, 0, canvas.getWidth(), canvas.getHeight(), mDefaultPaint, Canvas.ALL_SAVE_FLAG); //新建图层
        Log.d("zyl", "shizhenLayerCount = " + layerCount);
        canvas.rotate(mHourAngle , mCenterPoint.x , mCenterPoint.y);
        canvas.drawBitmap(mHourBitmap , null , mDstHourRect , mDefaultPaint);

        canvas.restoreToCount(layerCount);

        //画分针
        layerCount = canvas.saveLayer(0, 0, canvas.getWidth(), canvas.getHeight(), mDefaultPaint, Canvas.ALL_SAVE_FLAG);
        Log.d("zyl", "fenzhenlayerCount = " + layerCount);
        canvas.rotate(mMinuteAngle , mCenterPoint.x , mCenterPoint.y);
        canvas.drawBitmap(mMinuteBitmap , null , mDstMinuteRect , mDefaultPaint);

        canvas.restoreToCount(layerCount);

        //画刻度
        layerCount = canvas.saveLayer(0, 0, canvas.getWidth(), canvas.getHeight(), mDefaultPaint, Canvas.ALL_SAVE_FLAG);
        Log.d("zyl", "fenzhenlayerCount = " + layerCount);
        canvas.rotate(mSecondAngle + mSecondStartAngle , mCenterPoint.x, mCenterPoint.y);
        for (int i = 0; i < GRADUATION_COUNT; i++) {
            int alpha = 255 - i * 3;
            if (alpha > 120) {
                mGraduationPaint.setAlpha(alpha);
            }
            canvas.drawLine(mGraduationPoint.x, mGraduationPoint.y + 5, mGraduationPoint.x, mGraduationPoint.y + GRADUATION_LENGTH, mGraduationPaint);
            canvas.rotate(-PER_GRADUATION_ANGLE, mCenterPoint.x, mCenterPoint.y);
        }
        canvas.restoreToCount(layerCount);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        setMeasuredDimension(widthMeasureSpec , heightMeasureSpec);
    }

    public void startAnimation() {
        //三角刻度动画
        mClockAnimator = ValueAnimator.ofFloat(0 , GRADUATION_COUNT);
        mClockAnimator.setDuration(Constants.MINUTE);
        mClockAnimator.setInterpolator(new LinearInterpolator());
        mClockAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                mClockAngle = (float) valueAnimator.getAnimatedValue() * PER_GRADUATION_ANGLE;

            }
        });
        mClockAnimator.setRepeatCount(ValueAnimator.INFINITE);

        //圆圈刻度动画
        mSecondAnimator = ValueAnimator.ofInt(0 , GRADUATION_COUNT);
        mSecondAnimator.setDuration(Constants.MINUTE);
        mSecondAnimator.setInterpolator(new LinearInterpolator());
        mSecondAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                mSecondAngle = (int) valueAnimator.getAnimatedValue() * PER_GRADUATION_ANGLE;
                mHourAngle = (Calendar.getInstance().get(Calendar.HOUR) + ((float)Calendar.getInstance().get(Calendar.MINUTE)) / 60) * (360 / 12);
                mMinuteAngle = (Calendar.getInstance().get(Calendar.MINUTE) + ((float)Calendar.getInstance().get(Calendar.SECOND)) / 60) * (360 / 60);
                Log.d("zyl", "second = " + Calendar.getInstance().get(Calendar.SECOND));
                Log.d("zyl", "mMinuteAngle = " + mMinuteAngle);
                invalidate();
            }
        });
        mSecondAnimator.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animator) {
                mHourAngle = (Calendar.getInstance().get(Calendar.HOUR) + ((float)Calendar.getInstance().get(Calendar.MINUTE)) / 60) * (360 / 12);
                mMinuteAngle = (Calendar.getInstance().get(Calendar.MINUTE) + ((float)Calendar.getInstance().get(Calendar.SECOND)) / 60) * (360 / 60);
                mSecondStartAngle = Math.round((Calendar.getInstance().get(Calendar.SECOND) + Calendar.getInstance().get(Calendar.MILLISECOND) / Constants.SECOND) * (360 / 60));
            }

            @Override
            public void onAnimationEnd(Animator animator) {

            }

            @Override
            public void onAnimationCancel(Animator animator) {

            }

            @Override
            public void onAnimationRepeat(Animator animator) {

            }
        });
        mSecondAnimator.setRepeatCount(ValueAnimator.INFINITE);

        mSecondAnimator.start();
        mClockAnimator.start();
    }

    public void cancelAnimation() {
        if (mClockAnimator != null) {
            mClockAnimator.removeAllUpdateListeners();
            mClockAnimator.removeAllListeners();
            mClockAnimator.cancel();
            mClockAnimator = null;
        }

        if (mSecondAnimator != null) {
            mSecondAnimator.removeAllUpdateListeners();
            mSecondAnimator.removeAllListeners();
            mSecondAnimator.cancel();
            mSecondAnimator = null;
        }
    }
}