原创发布地址
之前在自定义view之写一个带删除按钮的Edittext中简单介绍了如何继承Edittext实现点击区域删除全部文字。
在自定义view之可伸缩的圆弧与扇形中介绍了如何制作带有动画效果的圆弧和扇形图。
模拟时钟实现思路
前边两篇都是入门文章,这篇算是一个基础文章,我们来制作一个模拟时钟,与手机上的时间保持同步运转。首先看一下我自己的做的效果图(很low的一个界面):
可以看到在53分钟结束到54分钟开始的时候,时针分针秒针基本保持与时间同步(实际在绘制过程中由于三角函数的double类型转float类型,以及π的位数,还是会有误差)。
时钟实现的难点在于如何绘制指针的重点坐标并时刻刷新保持与手机同步。此处我采用了取巧的方式,后边会详细介绍。
初始化工作
首先同样需要继承view类作为父类,并实现几个构造函数。
private static final float threeSqure = 1.7320508075689F;
private static final float PIE = 3.1415926535898F;
public MyClock(Context context) {
super(context);
init();
}
public MyClock(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public MyClock(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
在init函数中定义了一系列的画笔等工具。
private void init() {
bgPaint = new Paint();
bgPaint.setStyle(Paint.Style.STROKE);
bgPaint.setColor(Color.BLACK);
bgPaint.setStrokeWidth(10);
bgPaint.setAntiAlias(true);
boldNumPaint = new Paint();
boldNumPaint.setStyle(Paint.Style.STROKE);
boldNumPaint.setColor(Color.BLACK);
boldNumPaint.setStrokeWidth(20);
boldNumPaint.setAntiAlias(true);
thinNumPaint = new Paint();
thinNumPaint.setStyle(Paint.Style.STROKE);
thinNumPaint.setColor(Color.BLACK);
thinNumPaint.setStrokeWidth(10);
thinNumPaint.setAntiAlias(true);
secondPaint = new Paint();
secondPaint.setStyle(Paint.Style.FILL);
secondPaint.setColor(Color.GREEN);
secondPaint.setAntiAlias(true);
secondPaint.setStrokeWidth(10);
centerPaint = new Paint();
centerPaint.setStyle(Paint.Style.FILL);
centerPaint.setColor(Color.BLACK);
centerPaint.setAntiAlias(true);
innerPaint = new Paint();
innerPaint.setStyle(Paint.Style.FILL);
innerPaint.setColor(Color.WHITE);
innerPaint.setAntiAlias(true);
}
此处指明一些需要注意的地方就是setstyle一定要设置好,FILL是填充,画出来的是实心的,STROKE是描边,画出来的是空心的。其实也可以用一个画笔然后再每次绘制的时候不断重新设置也可以。
画笔中定义width等参数的时候一般是以px为单位,但是更多的时候我们需要以dp为单位,此处可以稍微注意一下,px与dp的转换。
我们知道,要想获得view的实际尺寸要在onsizechange方法中。在onsizechange方法中我们获取了一些在绘图中会用到的尺寸,实际需要的是一个正放形,所以取了区域中上边的一个方形。
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
Log.d(TAG, "onSizeChanged");
super.onSizeChanged(w, h, oldw, oldh);
this.width = Math.min(w, h);
this.height = Math.min(w, h);
inCircle = new RectF(55, 55, width - 55, height - 55);
outCircle = new RectF(5, 5, width - 5, height - 5);
radius = (float) ((width - 110) / 2);
innerCircle = new RectF(100, 100, width - 100, height - 100);
}
暴露接口
为了让时钟启动,我们需要自定一个外部可以访问的方法来启动时钟:startClock()。
public void startClock() {
myTime = new MyTime();
Log.d(TAG, myTime.toString());
animatorSecond = ValueAnimator.ofFloat(setSecond(myTime), setSecond(myTime) + 2 * 60 * PIE);
animatorMinute = ValueAnimator.ofFloat(setMinute(myTime), setMinute(myTime) + 2 * PIE);
animatorHour = ValueAnimator.ofFloat(setHour(myTime), setHour(myTime) + 6 * PIE / 180);
animatorSecond.removeAllUpdateListeners();
animatorMinute.removeAllUpdateListeners();
animatorHour.removeAllUpdateListeners();
animatorSecond.setDuration(60 * 1000 * 60);
animatorMinute.setDuration(60 * 1000 * 60);
animatorHour.setDuration(60 * 1000 * 60);
animatorSecond.setInterpolator(new LinearInterpolator());
animatorMinute.setInterpolator(new LinearInterpolator());
animatorHour.setInterpolator(new LinearInterpolator());
animatorSecond.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
passSecondArc = (float) animation.getAnimatedValue();
postInvalidate();
}
});
animatorMinute.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
passMinuteArc = (float) animation.getAnimatedValue();
}
});
animatorHour.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
passHourArc = (float) animation.getAnimatedValue();
}
});
AnimatorSet set = new AnimatorSet();
set.removeAllListeners();
set.playTogether(animatorSecond, animatorMinute, animatorHour);
set.start();
}
这个方法中首先定义了一个内部类MyTime,用来获取当前时间的时分秒。内部类的核心方法:
public MyTime() {
Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("GMT+8"));
year = calendar.get(Calendar.YEAR);
month = calendar.get(Calendar.MONTH);
day = calendar.get(Calendar.DAY_OF_MONTH);
hour = calendar.get(Calendar.HOUR_OF_DAY);
min = calendar.get(Calendar.MINUTE);
sec = calendar.get(Calendar.SECOND);
}
计算时间的起始位置
我们定义了三个动画时间引擎,这三个引擎分别负责时针、分针、秒针的运动。设置三个指针的起始值要根据我们获取的当前时间来定义:
private float setSecond(MyTime myTime) {
float passSecond = myTime.getSec();
return 6 * passSecond / 180 * PIE + PIE / 2;
}
此处要复习一下三角函数的相关知识。
我们的起始位置是在屏幕的最左边高度的中点,但是这个位置并不是我们需要的12点起始位置,为了公式计算方便,我们需要的是将他顺时针旋转90度以后的位置,也就是屏幕宽度的中点高度的起点位置。
- 秒针的计算:
一周是360度,也就是2π,1分钟60s,每秒经过的角度就是6度。
首先获取当前的秒的时间,计算经过的秒数,然后换算成弧度,最后加上π的一半,就是我们要展现出来的弧度。此处使用的单位是float单精度浮点型。这就是我们设置的时间引擎的起始值。
这个demo中我设定的时间是1个小时的动画,所以一个小时秒针会经过60圈,最后的中点值就设为了起始值+60*2π。
- 分针的计算
private float setMinute(MyTime myTime) {
float passMinute = myTime.getMin() * 6 + myTime.getSec() / 10;
return passMinute / 180 * PIE + PIE / 2;
}
一小时是60分钟,所以每经过1分钟要经过6度。为了使程序看起来更准确,我们还要计算经过的秒数,而不至于在一开始就在一个不准确的位置。60秒钟经过6度,则每秒钟经过0.1度,粗略计算出经过的分钟角度是myTime.getMin() * 6 + myTime.getSec() / 10,然后换算成弧度并加上π/2。
- 时针的计算
时针计算与分针计算相似,只是注意一小时走过的角度是30度,所以在换算的时候要注意经过的小时和经过的分钟的角度关系。
然后我们为每个引擎加上了监听方法,这个方法会将在每一个时刻的具体位置返回给我们。注意默认的插值器是低速-高度-低速这样的速度数值变化,明显不是我们要的结果,我们要用线性插值器来获得一个匀速的变化。然后启动动画引擎集合。
绘制
在onDraw方法中我们要绘制所有的一切图形。
drawBackGround(canvas);
draw0369(canvas);
drawHourGap(canvas);
drawInnerCircle(canvas);
drawM(canvas);
drawS(canvas);
drawH(canvas);
drawCenter(canvas);
- drawBackGround(canvas)
private void drawBackGround(Canvas canvas) {
bgPaint.setColor(Color.WHITE);
bgPaint.setStyle(Paint.Style.FILL);
canvas.drawRect(0, 0, width, height, bgPaint);
bgPaint.setColor(Color.BLACK);
bgPaint.setStyle(Paint.Style.STROKE);
canvas.drawArc(outCircle, 0, 360, false, bgPaint);
canvas.drawArc(inCircle, 0, 360, false, bgPaint);
}
这个是绘制背景圆,效果是这样的
在上一篇文章中已经介绍了如何使用paint来画扇形和弧线,这里就不介绍了,只要设置起点和重点为0-360即可。
- draw0369(canvas)
private void draw0369(Canvas canvas) {
canvas.drawLine(width / 2, 55, width / 2, height - 55, boldNumPaint);
canvas.drawLine(55, height / 2, width - 55, height / 2, boldNumPaint);
}
这个是绘制3点6点9点12点的位置。我们使用line加粗实现的。
完成后效果如下
- drawHourGap(canvas);
private void drawHourGap(Canvas canvas) {
canvas.drawLine(radius * (1 - threeSqure / 2) + 55,
(height - radius) / 2,
width - 55 - radius * (1 - threeSqure / 2),
(height + radius) / 2, thinNumPaint);
canvas.drawLine(radius * (1 - threeSqure / 2) + 55,
(height + radius) / 2,
width - 55 - radius * (1 - threeSqure / 2),
(height - radius) / 2, thinNumPaint);
canvas.drawLine(radius / 2 + 55,
height / 2 - radius * threeSqure / 2,
width - 55 - radius / 2,
height / 2 + radius * threeSqure / 2, thinNumPaint);
canvas.drawLine(radius / 2 + 55,
height / 2 + radius * threeSqure / 2,
width - 55 - radius / 2,
height / 2 - radius * threeSqure / 2, thinNumPaint);
}
这个是绘制其他小时的,用的是细的line实现。注意角度换算关系,因为要计算时间的角度,所以三角函数关系还是要把这些基本的计算掌握。效果如下:
4.drawInnerCircle(canvas)
这个和1是一样的,只是要绘制实心将中间的线挡住,所以paint要设置为FILL。
效果如下:
5.drawM(canvas) drawS(canvas) drawH(canvas);
private void drawM(Canvas canvas) {
secondPaint.setColor(Color.BLUE);
secondPaint.setStrokeWidth(20);
canvas.drawLine(width / 2, height / 2,
height / 2 - (radius - 80) * (float) Math.cos(passMinuteArc),
width / 2 - (radius - 80) * (float) Math.sin(passMinuteArc),
secondPaint);
}
主要看一下这个计算过程,起始坐标是我们的中心点位置,而终点的x轴是中心点减去经过角度的余弦值,同样可计算得到y。
- drawCenter(canvas);
最后我们做一个改在所有指针中心上的盖子。
最终效果:
下一节我们将介绍如何绘制一个日历,并介绍为何暴露出来的方法startTime会在所有的重写方法之前执行。