Fork me on GitHub

Android时钟动画实现

依自己的使用来看,小米手机的自身很多应用在动画处理和界面处理上都很圆滑舒服,所以估计在未来一段时间内,我都会以小米应用作为模仿练手的目标,这次就先模仿实现一下小米时钟的动画。小米时钟有很多动画,我暂时只完成了一个界面,而且还不完善,所以还会持续改进。

先放上我已经实现的效果图(录制采用的是免费版的icecream,功能很不错,就是水印有点大)

实现思路

这个动画中需要绘制的一共有6个部分:中心环,时针,分针,秒针,内环,外环刻度盘,如下图:

在这6个部分中,中心环、内环属于静态,一经绘制便不需要再作处理;时针、分针、秒针和外环刻度盘则是属于时时刻刻都在运动的部分,是需要重点处理的部分。

代码实现部分

时间

既然是要绘制时钟,就一定需要获取到时间。时间的精度一定要高,要以毫秒为单位,这样才能使动画的变化更加圆滑一些,而不是一秒一跳那样的动画。

//获取当前总时间(超出1970年1月1日的时间)
long time = System.currentTimeMilllis();
//获取当前时间(超出当天0点的时间)
long overtime;
//获取当前系统时间
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
//除以1000,得到的会是以秒为单位的时间;不除,则以毫秒为单位
try {
    overtime = (time - (simpleDateFormat.parse(simpleDateFormat.format(time)).getTime()));
}catch (Exception e){
    e.printStackTrace();
}

//时钟最多展示12个小时的时间,所以只需要留下一个小于12个小时的数据;一天共有12x60x60=86400秒,半天则为43200秒
if(overtime >= 43200000) {
    overtime = overtime - 43200000;
}

中心环与内环

中心环与内环的实现很简单,只需要绘制两个中心圆就可以了,一个半径小一些且画笔宽度粗一些,一个半径大一些且画笔宽度细一些:

//绘制中心环画笔
paints[0].setStyle(Paint.Style.STROKE);
paints[0].setStrokeWidth(centerCircleWidth);
paints[0].setColor(Color.WHITE);
paints[0].setAntiAlias(true);

//绘制内环画笔
paints[1].setStyle(Paint.Style.STROKE);
paints[1].setStrokeWidth(innerCircleWidth);
paints[1].setColor(Color.WHITE);
paints[1].setAntiAlias(true);

······

//绘制中心环
canvas.drawCircle(centerX,centerY,centerCircleRadius,paints[0]);
//绘制内环
canvas.drawCircle(centerX,centerY,innerCircleRadius,paints[1]);

时针

时针的实现有两点:1、时针的形状绘制;2、时针的指向角度绘制

时针的形状,我采用了一个等腰三角形来绘制。绘制等腰三角形,需要计算出三个点的位置。考虑到点的位置是时刻都在变化的,所以要先计算出指针应当指向的角度。

如图所示,A点是左底点,B点是右底点,C点是顶点。其中,∠β是偏差角度,我设定为60度,∠α是时针整体的指向角度,底距是两个底点距原点的距离,时针的臂长则是顶点C距原点的距离。有了这些参数,计算各个点的变化规律就简单多了。比如:

$A_x = O_x + 底距 * sin(∠α - ∠β)$

$A_y = O_y + 底距 * cos(∠α - ∠β)$

但是,Android界面的坐标系y轴与数学系的y轴在正方向上式相反的。所以,第二个算式中,要把”+”变为”-“。

底点的计算已经有了,顶点的计算更简单了,自不用说。不过,计算中的∠β是个未知数,是需要我们自己去计算的。

前面代码中已经给过了overtime这个变量的计算,它代表的是超出当天0点的时间。计算时针的角度,只需要计算overtime能占到一个表格所表示的最大时间的比例,即占12个小时的比例。要注意的是,这里的比例计算要以毫秒为考虑角度,才能使计算出的角度尽可能的细致入微,而不是每过一个小时,跳格30度。代码实现如下:

//绘制时针底部时用到的偏差角度,用于计算底部点
hourHandDeviation = 60;
······
//绘制时针
//以下是简化的公式,原公式为overtime/(12 * 3600 * 1000) * (360 / 1000)
//即先计算当前时间(单位为秒)占12个小时的总时间的多少,再乘以整个圈的度数,即可得到当前时针应该指向的角度
double degree = Math.toRadians(((double)overtime) / 120000.00);
double leftPointX = centerX + handRadius * Math.sin(degree - Math.toRadians(hourHandDeviation));
double leftPointY = centerY - handRadius * Math.cos(degree - Math.toRadians(hourHandDeviation));
double rightPointX = centerX + handRadius * Math.sin(degree + Math.toRadians(hourHandDeviation));
double rightPointY = centerY - handRadius * Math.cos(degree + Math.toRadians(hourHandDeviation));
double topPointX = centerX + hourHandLength * Math.sin(degree);
double topPointY = centerY - hourHandLength * Math.cos(degree);
//通过绘制一个等腰三角形的类似图形来绘制时针
Path path = new Path();
path.moveTo((float)leftPointX,(float)leftPointY);
path.lineTo((float)topPointX,(float)topPointY);
path.lineTo((float)rightPointX,(float)rightPointY);
path.close();
canvas.drawPath(path,paints[3]);

分针

分针的绘制如同时针,只不过偏差角度小一些,臂长长一些,原理上都是一样的。代码实现如下:

//绘制分针
//以下为简化的公式,原公式为overtime mod (3600 * 1000) / 3600 * (360 / 1000),计算当前时间除去小时后,剩余时间占一个小时的多少,再乘以总度数
degree = Math.toRadians(overtime % 3600000 * 0.0001);
leftPointX = centerX + handRadius * Math.sin(degree - Math.toRadians(minuteHandDeviation));
leftPointY = centerY - handRadius * Math.cos(degree - Math.toRadians(minuteHandDeviation));
rightPointX = centerX + handRadius * Math.sin(degree + Math.toRadians(minuteHandDeviation));
rightPointY = centerY - handRadius * Math.cos(degree + Math.toRadians(minuteHandDeviation));
topPointX = centerX + minuteHandLength * Math.sin(degree);
topPointY = centerY - minuteHandLength * Math.cos(degree);
path = new Path();
path.moveTo((float)leftPointX,(float)leftPointY);
path.lineTo((float)topPointX,(float)topPointY);
path.lineTo((float)rightPointX,(float)rightPointY);
path.close();
canvas.drawPath(path,paints[4]);

秒针

秒针的绘制实际上也可以跟以上两个是一样的,不过我为了绘制一个等边三角形,本可以直接给定值的底距,在这里我是通过给定的秒针三角形的高secondHandHeight和底边中心到原点的距离secondHandRadius,间接计算而得出。这样的计算比较麻烦,但能得到一个等边三角形,我觉得还是可以的。

//绘制一个三角形指针
//以下为简化的公式,原公式为overtime mod (60 * 1000) / 60 * (360 / 1000)
degree = Math.toRadians(overtime % 60000 * 0.006);
//计算三角形另外两顶点的偏差角度
double deviation = Math.atan(Math.tan(Math.toRadians(30))*secondHandHeight/secondHandRadius);
//计算三个顶点的坐标变化
topPointX = centerX + (secondHandRadius + secondHandHeight) * Math.sin(degree);
topPointY = centerY - (secondHandRadius + secondHandHeight) * Math.cos(degree);
double secondRadius = Math.tan(Math.toRadians(30))*secondHandHeight/Math.sin(deviation);
leftPointX = centerX + secondRadius * Math.sin(degree - deviation);
leftPointY = centerY - secondRadius * Math.cos(degree - deviation);
rightPointX = centerX + secondRadius * Math.sin(degree + deviation);
rightPointY = centerY - secondRadius * Math.cos(degree + deviation);
path = new Path();
path.moveTo((float)topPointX,(float)topPointY);
path.lineTo((float)leftPointX,(float)leftPointY);
path.lineTo((float)rightPointX,(float)rightPointY);
path.close();

这里有一个小插曲:我这里直接简化了公式,得到的角度是精度很高,类型为double的度数,所以动画看起来很平滑,但最开始的时候,我的算式是这样的:overtime % 60000 * 6 / 1000 ,得到的是这样的:

由于gif的丢帧问题,这里表现的不是很明显,但可以告知的是,因为这样的算式最后计算出的值类型为int,所以表现上会是那种持续跳动的动画,而不是圆滑过渡的动画。这给了我一个教训:动画的过渡要想平滑,数值的精度一定要高

外环刻度盘

外环刻度盘有两种实现方法,一是通过设定绘制区域不变,边旋转画布边绘制刻度的方法;二是通过计算每个刻度的起点和终点并绘制来实现。讲道理按实现容易度来讲,应该按第一种来实现比较简单,不过因为我前几个实现全采用的三角函数计算,所以这里我的思维还是采用三角函数的。

//绘制外环刻度
for(float i =0;i<360;i=i+2){
    degree = Math.toRadians(i);
    double x1 = centerX + outerCircleRadius * Math.sin(degree);
    double y1 = centerY - outerCircleRadius * Math.cos(degree);
    double x2 = centerX + (outerCircleRadius - outerCircleWidth) * Math.sin(degree);
    double y2 = centerY - (outerCircleRadius - outerCircleWidth) * Math.cos(degree);
    if(i >= 270 && i <= 360){
        paints[2].setAlpha((int)((i-270)*1.5+100));
    }else if(i == 0){
        paints[2].setAlpha(240);
    }else {
        paints[2].setAlpha(100);
    }
    canvas.drawLine((float)x1,(float)y1,(float)x2,(float)y2,paints[2]);
}

这样就可以绘制出来一个静态的刻度盘,这个刻度盘有180道刻度,且为了赋予刻度盘一个动画的效果,最后的45道刻度以及第一道刻度赋予不同的透明度,造成淡化的效果。那么接下来,只要让刻度盘转起来就行了。

在最开始我想仿制分针的转动实现,但想了想,计算量大,考虑的东西有点多,后来就转为了转动画布的想法。不过,刻度盘的绘制以及转动的实现一定要放在其他三个指针的实现之前,不然会带动三个指针一起转动,这样,指针的指向就与当前时间不符了。

有了方向,实现转动效果就可以了。转动效果包括两点:1、每秒转动3次,即每333ms为一次转动契机;2、每次都比上次的转动角度大2度。所以,角度计算如下:

//计算当前时间是(几时几分)几秒
long overSecond = overtime % 60000;
//每秒跨格6度,每333ms跨格2度
double degree = overSecond / 1000 * 6 + overSecond % 1000 / 333 * 2;

计算完了,就是画布的旋转。画布的旋转跟想象中的很不一样,但我也不会在这里详解,只贴出来使用流程。

canvas.save();

//计算角度
//以圆心为中心旋转
canvas.rotate((float)degree,centerX,centerY);

//绘制刻度盘

canvas.restore();

这样就可以实现刻度盘的转动了。

(神特么这个转动角度让我钻了牛角尖,想了整整一天才想出来怎么计算)

3D视角的转动

3D视角的转动主要借助于camera和matrix的结合实现。这个部分的动画功能设想是,当点击时钟周围,时钟会侧向点击方向;手指松开屏幕时,时钟会重新回到原来的位置,实现效果如下:

实现思路:给view类添加点击事件的监听,当手指按下时,获取点击点的坐标,与中心点对比后判断时钟旋转方向并旋转;手指松开时,加一个时钟恢复原位的动画,这样就算完成了。

代码实现如下:

1、添加点击事件监听。

private ValueAnimator shakeAnim;
...
@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()){
        case MotionEvent.ACTION_DOWN:       //手指按下时,而非向下的手势
            if(shakeAnim != null && shakeAnim.isRunning()){
                shakeAnim.cancel();
            }
            //获取点击点的坐标并进行处理
            getCameraRotate(event);
            break;
        case MotionEvent.ACTION_MOVE:       //手指移动时
            getCameraRotate(event);
            break;
        case MotionEvent.ACTION_UP:         //手指抬起时,而非向上的手势
            //抬起后进行时钟恢复原位的动画
            startShakeAnim();
            break;
    }
    return true;
}

2、获取点击点坐标并进行角度判断。与中心点坐标进行对比,为防止时钟偏移角度过大,设定最大旋转角度为30度,最后获取到角度后,在onDraw()方法中对视图进行视角的旋转操作。

private float cameraRotateX;                //camera视角中x轴转动的角度
private float cameraRotateY;                //camera视角中y轴转动的角度
private final maxCameraRotate = 30;            //最大旋转角度
...
//获取点击点
private void getCameraRotate(MotionEvent event){
    if(shakeAnim != null && shakeAnim.isRunning()){
        shakeAnim.cancel();
    }
    //与中心点坐标进行对比
    float rotateX = -(event.getY() - 640);
    float rotateY = (event.getX() - 360);
    //求出此时旋转的大小与半径之比
    float percentX = rotateX / outerCircleRadius;
    float percentY = rotateY / outerCircleRadius;
    if(percentX > 1){
        percentX = 1;
    }else if(percentX < -1){
        percentX = -1;
    }
    if(percentY > 1){
        percentX = 1;
    }else if(percentY < -1){
        percentY = -1;
    }
    //最终旋转的大小按比例匀称改变
    cameraRotateX = percentX * maxCameraRotate;
    cameraRotateY = percentY * maxCameraRotate;
}

//设置3D时钟效果
private void setCameraRotate(){
    cameraMatrix.reset();
    //camera的旋转与canvas的旋转用法很相似,先save()再旋转最后restore()
    camera.save();
    camera.rotateX(cameraRotateX);
    camera.rotateY(cameraRotateY);
    camera.getMatrix(cameraMatrix);
    camera.restore();

    //时钟旋转后视图会发生很大的视角偏差,所以需要通过平移来设定旋转后的时钟中心点
    cameraMatrix.preTranslate(-getWidth()/2,-getHeight()/2);
    cameraMatrix.postTranslate(getWidth()/2,getHeight()/2);
}

3、时钟恢复原位的动画。

private void startShakeAnim(){
    PropertyValuesHolder cameraRotateXHolder = PropertyValuesHolder.ofFloat("cameraRotateX",cameraRotateX,0);
    PropertyValuesHolder cameraRotateYHolder = PropertyValuesHolder.ofFloat("cameraRotateY",cameraRotateY,0);
    shakeAnim = ValueAnimator.ofPropertyValuesHolder(cameraRotateXHolder,cameraRotateYHolder);
    shakeAnim.setInterpolator(new OvershootInterpolator(10));
    shakeAnim.setDuration(500);
    shakeAnim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            cameraRotateX = (float) animation.getAnimatedValue("cameraRotateX");
            cameraRotateY = (float) animation.getAnimatedValue("cameraRotateY");
        }
    });
    shakeAnim.start();
}

这里的ValueAnimator.ofPropertyValuesHolder(PropertyValuesHolder… values)方法可以同时管理多个动画属性,比起之前的分散式管理,代码量少,也更好阅读。

后记

终于把3D效果的实现部分补上了。沉迷了几天的Win10 UWP应用,这才回来补上没写完的部分。

哦对了,PPT的绘画真好用,以后就用PPT画图好了。

-------------本文结束感谢您的阅读-------------