写在前面
这是笔者在学习自定义View以来,分享的第四篇不太复杂但是“长的”还算可以的View效果。之前分享过一篇图片合并效果的自定义View,如果有兴趣的可以看看:
Android自定义View分享——仿微信朋友圈图片合并效果
今天要做这样效果,一个已经被无数人写过的例子:
虽然被很多人写过,但还要写,因为经典,写完还要总结出些东西出来。下面开始。
需求分析
- 首先我们要有一个红色的圆,作为钟表的基本轮廓,圆盘上面要有刻度线,还要有对应的数字。
- 在圆的中心有指针,一长一短,时针分针。
- 还有一个会动态变化的绿色的圆,这个圆其实表示秒针变化,别人一般画三个指针,我们来搞一下特殊,不要秒针,画成动态变化的圆。
- 我们需要每隔一段时间重绘一次控件,以此体现秒钟动态变化效果。
- 前面步骤讲的都是onDraw()方法里的操作,当然我们还要重写onMeasured()方法。
所涉及的知识点
根据我们做的需求分析,来分析下会用到那些知识点(哪些类以及方法)
- 为了画圆,我们需要用到canvas.drawCircle(cx, cy, radius, paint)方法,前面三个参数分别表示圆心坐标,半径,第四个参数是画笔。
- 为了绘制刻度线,我们需要用到canvas.drawLine(startX, startY, stopX, stopY, paint)方法。两点确定一条直线,所以前面两个参数表示起点的坐标,第三、四个参数表是终点坐标,最后一个参数表示画笔。
- 为了写字,我们需要用到canvas.drawText(text, x, y, paint)方法,第一个参数就是要绘制的文字内容,第二、三个参数表是文字坐标,第四个参数是画笔。需要注意的是文字的坐标表示和其他不太一样,关于文字坐标计算不在这里记录,因为这是一个单独的知识点,拉开来将很大篇幅。
- 为了要围绕圆周来画线,写字,需要利用canvas.rotate(degree)、canvas.save()、canvas.restore()方法简化“围绕圆周绘制xx”这类型的操作所导致的数学运算。
拆解步骤,分析代码
在绘制所有的东西之前,我们需要先做一件事情:调用
canvas.translate(getMeasuredWidth()/2, getMeasuredHeight()/2)
方法,将坐标原点移动到控件中心,对于大量圆周类的操作,这将简化很多计算。以下各步骤都以坐标原点在控件中心为前提来描述。
绘制红色的背景圆
首先我们需要初始化画笔,然后取一个比控件宽度的一半还要小一点点的距离,作为圆周的半径(因为圆周如果直接贴着控件边上,很不好看,所以需要小一点点),那么就能得到如下一段代码。
//画笔初始化
Paint circlePaint = new Paint();
circlePaint.setColor(Color.RED);
circlePaint.setStrokeWidth(circleWidth);
circlePaint.setStyle(Paint.Style.STROKE);
//绘制圆周,paddingSpace就是前面所讲的一点点距离
float radius = getMeasuredWidth()/2 - paddingSpace - circleWidth/2;
canvas.drawCircle(0, 0, radius, circlePaint);
绘制刻度线以及文字
同样地我们需要有绘制刻度线的画笔,绘制文字的画笔。然后就是利用canvas.rotate()、canvas.save()、canvas.restore()方法完成围绕圆周画线、写字这样子的一个操作。我们得到如下一段代码:
//绘制刻度线画笔初始化
Paint linePaint = new Paint();
linePaint.setColor(Color.BLACK);
linePaint.setStrokeWidth(4);
//绘制文字的画笔初始化
Paint textPaint = new Paint();
textPaint.setColor(Color.BLACK);
textPaint.setTextSize(30);
textPaint.setStyle(Paint.Style.FILL);
textPaint.setTextAlign(Paint.Align.CENTER);
//画刻度以及写字,普通刻度线长为10,整点刻度线长度为20
canvas.save();
//将刻度1转到最上面,30是相应的角度
canvas.rotate(30);
//在转动坐标系过程里刻度线的起点位置、普通线的终点位置,20表示线的长度,可以根据自己需要修改
float lineStart = -(radius-circleWidth/2), commonLineEnd = lineStart+20;
Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
//这里的20表示的是整点的刻度线比普通刻度线长多少,可以修改,但是记住在循环里面的划线部分需要跟着变化
float textY = commonLineEnd+20-fontMetrics.ascent;//计算基线Y轴坐标
for(int lineNumber = 5, clockNumber = 1; lineNumber < 65; lineNumber++){
if(lineNumber%5 == 0){
//如果画到了整点
canvas.drawLine(0, lineStart, 0, commonLineEnd+20, linePaint);
canvas.drawText(clockNumber+"", 0, textY, textPaint);
clockNumber++;
}else{
//绘制普通的刻度
canvas.drawLine(0, lineStart, 0, commonLineEnd, linePaint);
}
canvas.rotate(6);
}
//将坐标系还原回去
canvas.restore();
绘制指针
其实绘制指针就是画线,真的和前面画刻度线没有什么很大区别,需要做的只是根据当前时间决定旋转角度,代码如下:
//画笔初始化
Paint pointerPaint = new Paint();
//绘制指针画笔的初始化
pointerPaint.setColor(Color.BLACK);
pointerPaint.setStrokeWidth(10);
//存储当前的状态,开始画指针
canvas.save();
//画时针
float pointerDegree = ( hour+minute/60f )*360/12; //计算时针的角度
canvas.rotate(pointerDegree);
canvas.drawLine(0, 40, 0, -radius/3, pointerPaint);
canvas.restore();
//画分针
canvas.save();
pointerDegree = minute/60f*360;
canvas.rotate(pointerDegree);
canvas.drawLine(0, 40, 0, -radius/2, pointerPaint);
//将坐标系状态还原
canvas.restore();
绘制绿色的弧线来表示秒钟
这个操作其实就是“在360°表示60秒的前提下,根据当前的秒数来计算弧线所扫过的角度”,将得到如下的代码:
//绘制弧线用的画笔、矩形初始化
Paint arcPaint = new Paint();
RectF arcRectF = new RectF();
arcPaint.setColor(Color.GREEN);
arcPaint.setStrokeWidth(circleWidth);
arcPaint.setStyle(Paint.Style.STROKE);
//用弧线的形式来填充秒数
float position = halfMeasuredWidth-paddingSpace-circleWidth/2;
arcRectF.set(-position, -position, position, position);
canvas.drawArc(arcRectF, -90, millisecond /60000f*360, false, arcPaint);
重写onMeasure()方法
这里没有什么很新的知识点,都是很通用的写法
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getMeasuredSize(widthMeasureSpec), getMeasuredSize(heightMeasureSpec));
}
//都是比较常规的测量代码
private int getMeasuredSize(int measureSpec){
int measuredMode = MeasureSpec.getMode(measureSpec);
//默认大小要稍微大一些
int measuredSize = 800;
if(measuredMode == MeasureSpec.EXACTLY){
measuredSize = MeasureSpec.getSize(measureSpec);
}else if(measuredMode == MeasureSpec.AT_MOST){
measuredSize = Math.min(measuredSize, MeasureSpec.getSize(measureSpec));
}
return measuredSize;
}
完整代码
经过拆解步骤之后,其实基本绘制流程都结束了,然而我们的控件还不能使用,那么还欠缺什么呢?
- 我们还没进行时分秒的计算,以及定时执行重绘。
- onDraw()方法里的代码绝对不能像上面那么写,我们不能在onDraw()方法里面创建大量对象,前面的代码只是为了方便讲解这么写。
- 也许我们还要定义一些public方法,提供一些操作时钟的方法。
由于前面所提到的东西不是某一块的内容,而是整体,所以放在完整代码里面一起展现
/**
* 一个平凡的时钟
*/
public class ClockView extends View{
private final float paddingSpace = 10; //控件内容到控件边界的距离
private final float circleWidth = 20; //圆周宽度
private final Paint circlePaint = new Paint(); //绘制圆周用的画笔
private final Paint linePaint = new Paint(); //绘制刻度线用的笔
private final Paint textPaint = new Paint(); //绘制文字用的画笔
private final Paint pointerPaint = new Paint(); //绘制指针用个画笔
private final Paint arcPaint = new Paint(); //画弧线的笔
private final RectF arcRectF = new RectF(); //画弧线用的矩形
//时、分、毫秒,为了技术方便没有使用秒,而是毫秒,不过对外提供的是秒
private int hour = 8;
private int minute = 0;
private int millisecond = 0;
//标记时钟是否是在运行
private boolean isRun;
public ClockView(Context context) {
super(context);
init();
}
public ClockView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
//对绘图所用的工具做初始化
private void init(){
//绘制时钟盘的画笔的初始化
circlePaint.setColor(Color.RED);
circlePaint.setStrokeWidth(circleWidth);
circlePaint.setStyle(Paint.Style.STROKE);
//绘制刻度线画笔的初始化
linePaint.setColor(Color.BLACK);
linePaint.setStrokeWidth(4);
//绘制文字画笔的初始化
textPaint.setColor(Color.BLACK);
textPaint.setTextSize(30);
textPaint.setStyle(Paint.Style.FILL);
textPaint.setTextAlign(Paint.Align.CENTER);
//绘制指针画笔的初始化
pointerPaint.setColor(Color.BLACK);
pointerPaint.setStrokeWidth(10);
//绘制弧线画笔的初始化
arcPaint.setColor(Color.GREEN);
arcPaint.setStrokeWidth(circleWidth);
arcPaint.setStyle(Paint.Style.STROKE);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getMeasuredSize(widthMeasureSpec), getMeasuredSize(heightMeasureSpec));
}
//都是比较常规的测量代码
private int getMeasuredSize(int measureSpec){
int measuredMode = MeasureSpec.getMode(measureSpec);
//默认大小要稍微大一些
int measuredSize = 800;
if(measuredMode == MeasureSpec.EXACTLY){
measuredSize = MeasureSpec.getSize(measureSpec);
}else if(measuredMode == MeasureSpec.AT_MOST){
measuredSize = Math.min(measuredSize, MeasureSpec.getSize(measureSpec));
}
return measuredSize;
}
//这里是核心代码
@Override
protected void onDraw(Canvas canvas) {
int halfMeasuredWidth = getMeasuredWidth()/2;
canvas.translate(halfMeasuredWidth, getMeasuredHeight()/2);
//画圆
float radius = halfMeasuredWidth - paddingSpace - circleWidth/2;
canvas.drawCircle(0, 0, radius, circlePaint);
//画刻度以及写字,普通刻度线长为10,整点刻度线长度为20
canvas.save();
//将刻度1转到最上面,30是相应的角度
canvas.rotate(30);
//在转动坐标系过程里刻度线的起点位置、普通线的终点位置,20表示线的长度,可以根据自己需要修改
float lineStart = -(radius-circleWidth/2), commonLineEnd = lineStart+20;
Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
//这里的20表示的是整点的刻度线比普通刻度线长多少,可以修改,但是记住在循环里面的划线部分需要跟着变化
float textY = commonLineEnd+20-fontMetrics.ascent;//计算基线Y轴坐标
for(int lineNumber = 5, clockNumber = 1; lineNumber < 65; lineNumber++){
if(lineNumber%5 == 0){
//如果画到了整点
canvas.drawLine(0, lineStart, 0, commonLineEnd+20, linePaint);
canvas.drawText(clockNumber+"", 0, textY, textPaint);
clockNumber++;
}else{
//绘制普通的刻度
canvas.drawLine(0, lineStart, 0, commonLineEnd, linePaint);
}
canvas.rotate(6);
}
canvas.restore();
//画指针
canvas.save();
//画时针
float pointerDegree = ( hour+minute/60f )*360/12; //计算时针的角度
canvas.rotate(pointerDegree);
canvas.drawLine(0, 40, 0, -radius/3, pointerPaint);
canvas.restore();
//画分针
canvas.save();
pointerDegree = minute/60f*360;
canvas.rotate(pointerDegree);
canvas.drawLine(0, 40, 0, -radius/2, pointerPaint);
canvas.restore();
//用弧线的形式来填充秒数
float position = halfMeasuredWidth-paddingSpace-circleWidth/2;
arcRectF.set(-position, -position, position, position);
canvas.drawArc(arcRectF, -90, millisecond /60000f*360, false, arcPaint);
super.onDraw(canvas);
//如果时钟需要继续走动
if(isRun){
calculateTime();
postInvalidateDelayed(100);
}
}
//在时间变化过程中计算时间用的方法
private void calculateTime(){
if(millisecond == 60000){
millisecond = 0;
if(minute == 59){
minute = 0;
if(hour == 11) hour = 0;
else hour++;
}else{
minute++;
}
}else{
millisecond+=100;
}
}
/**
* 设置要显示的时间
* @param hour 小时(0-11)
* @param minute 分钟(0-59)
* @param second 秒钟(0-59)
*/
public void setTime(int hour, int minute, int second){
//输入有误
if(hour<0 || hour>11 || minute<0 || minute>59 || second<0 || second>59){
return;
}
this.hour = hour;
this.minute = minute;
this.millisecond = second*1000;
invalidate();
}
/**
* 让时钟开始运行
*/
public void star(){
isRun = true;
invalidate();
}
/**
* 让时钟停止运行
*/
public void paus(){
isRun = false;
}
}
项目源码:
https://github.com/kingfarou/SimpleCustomView
小结
- 其实整个例子做下来,除了一些数学计算比较繁琐,并没有很大的难度,主要就是API的调用。
- 通过时钟的例子,我们学习到怎么样“围绕圆周绘制各种东西”,这种需求如果你不用些技巧,你可能要进行很繁琐的三角函数计算。
- 你有没有注意到,我们至始至终没有调用任何动画相关的东西,然而我们做出了动态的效果?核心的问题就在于绿色弧线的绘制,通过不断地调用invalidate()方法或者postInvalidateDelayed()方法执行重绘,在每一次的重绘里一点点地改变弧线角度,就达到了动画效果。为什么说时钟效果是一个很经典的自定义View?因为现在一些很流行的效果比如音频图、心电图、进度条,其实他们在动态变化这方面和时钟的逻辑都是一样的,都是通过在不断地重绘当中,一点点的改变状态,达到了动态的效果。如果你能很好地理解时钟动态效果的实现逻辑,你能实现很多其他View的效果。
自定义View并没有想象当中那么难,今天的分享到此结束。