本文我们将自定义一个View,来实现一个时钟,先看一下效果图。
这里只是截取了一个静态图,实际上可以秒针是运动的。至于其他的更好看的效果在这基础上可以自己添加。
1、属性设定
在res/values 目录下新建立一个文件attrs.xml, 我们将在里面定义时钟所需要的属性,这里我只是定义了4个属性,分别是时、分、秒、背景颜色。
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="WatchView">
<attr name="hour" format="integer"/>
<attr name="min" format="integer"/>
<attr name="sec" format="integer"/>
<attr name="background_color" format="color"/>
</declare-styleable>
</resources>
2、自定义WatchView
新建一个类WatchView继承自View,然后添加上画笔、指针数字、长度等等成员。在构造函数中,我们获取布局文件中定义的属性值。
public class WatchView extends View {
private static final String TAG = "WatchView";
private Paint mPaint;
private int mCircleWidth = 20;
private int radius = 500;
// 这些下面都会重新计算
private int lenHour = 200;
private int lenMin = 300;
private int lenSec = 400;
private int mProgressSecond = 0;
private int mProgressMin = 30;
private int mProgressHour = 11;
// background
private int mBackgroundColor;
public WatchView(Context context) {
this(context,null);
}
public WatchView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public WatchView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 获取布局文件中定义的属性值并转换
TypedArray array = context.obtainStyledAttributes(attrs,R.styleable.WatchView);
mProgressHour = array.getInteger(R.styleable.WatchView_hour,0);
mProgressHour %= 12;
mProgressMin = array.getInteger(R.styleable.WatchView_min,0);
mProgressMin %= 60;
mProgressSecond = array.getInteger(R.styleable.WatchView_sec,0);
mProgressSecond %= 60;
mBackgroundColor = array.getColor(R.styleable.WatchView_background_color,getResources().getColor(R.color.white));
// 一定不要忘记资源回收
array.recycle();
}
}
接下来初始化画笔的各个属性。
private void Init(){
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeCap(Paint.Cap.ROUND);
mPaint.setColor(getResources().getColor(R.color.black));
mPaint.setStrokeWidth(10);
mPaint.setAntiAlias(true);
mPaint.setTextSize(80);
mPaint.setTextAlign(Paint.Align.CENTER);
}
上面这些属性的含义比较简单,如果有不懂的一看源码就清楚了。
自定义View还需要处理的是wrap_content和match_parent这两个属性,在View的源码中,它对这两个的处理是相同的,所以你会看到自己定义的View设置成wrap_content和match_parent其大小都是一样的,因此我们需要给wrap_content设置一个值,自己确定就好了。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int widthSepcSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
// 继承自自定义View,需要给wrap_content这种模式指定一个具体的值,否则它的大小和match_parent没有区别
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(500,500);
}else if (widthSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(500,heightSpecSize);
}else if (heightSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(widthSepcSize,500);
}
}
接下来我们就需要对最开始定义的那些属性初始化了,圆盘半径啊,指针长度这些,我这里半径是根据整个View的长宽来确定的,那需要在哪里去确定这些数值呢?如果我们在初始化函数或者是onMeasure函数中设置,将会发现getWidth大小是0 , 因为这个时候view 的大小还没有确定完成,而如果我们把这些计算都放在onDraw里面去完成的话,View的绘制效率又会变得很低,因为每次刷新都会调用一次onDraw函数,所以不宜将过多的工作放在里面完成,因此可以将这些工作放在onLayout函数里面完成。
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
// 初始化半径,指针长度等,这些计算不能放到初始化函数和onMeasure中完成,因为那时宽度还没有确定,getWidth = 0.
// 也不要放到onDraw里面做,因为onDraw调用比较频繁,最好不要放太多操作进去
int w = getWidth() - getPaddingLeft() - getPaddingRight();
int h = getHeight() - getPaddingTop() - getPaddingBottom();
if (w > h){
radius = h / 2;
}else {
radius = w / 2;
}
// 确定指针长度
lenSec = (int) ((float)(radius) * 3 / 4);
lenMin = (int) ((float)(radius) / 2);
lenHour = (int) ((float)(radius) / 4);
}
3、开始绘制
开始绘制之前,我们先理解以下View中获取坐标,宽高的一些方法
宽高可以直接通过getwidth()和getHeight()获得,也可以通过
getRight() - getLeft()获取到宽。getX() 和 getY()可以获取到左上角顶点的坐标。
一定要注意的是,这些数据都是相对坐标,都是在父容器中的相对位置。利用canvas绘制图形的时候却又不一样,它会将canvas的左上角坐标定为(0,0),这时考虑坐标点又不是相对于父布局了, 因此绘制过程中要特别注意。如果想要获取到绝对坐标,可以通过
int[] xy = new int[2];
getLocationOnScreen(xy);
Log.d(TAG, "onDraw X: "+xy[0]);
Log.d(TAG, "onDraw y: "+xy[1]);
在处理点击等事件时,MotionEvent中的getRawX()获取到的也是绝对坐标。
在onDraw函数中开始我们的绘制工作,首先是绘制表盘和背景颜色
int center;
if (getWidth() > getHeight()){
center = getHeight() / 2;
}else {
center = getWidth() / 2;
}
// 圆心坐标
float xCenter = center;
float yCenter = center;
// 表框
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(mCircleWidth);
mPaint.setColor(getResources().getColor(R.color.black));
canvas.drawCircle(center,center,radius,mPaint);
// 在指针的旋转中心画一个小圈圈
canvas.drawCircle(xCenter,yCenter,10,mPaint);
// 绘制背景
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(mBackgroundColor);
canvas.drawCircle(center,center,radius - mCircleWidth / 2,mPaint);
mPaint.setStrokeWidth(mCircleWidth);
mPaint.setColor(getResources().getColor(R.color.black));
接下来绘制表盘上的刻度,对于大刻度和小刻度要分开处理,总共绘制了60个刻度,计算时,我是按照时钟的数字从0点开始顺时针计算的,每个刻度之间的间隔是6度。
// 绘制12个大刻度和其他小刻度
for (int i = 0; i < 60; i++) {
// 大刻度
int len = 0;
if (i%5 == 0){
len = 30;
mPaint.setStrokeWidth(20);
}else {
len = 20;
mPaint.setStrokeWidth(10);
}
double hudu = i * 6 * Math.PI / 180;
double sin1 = Math.sin(hudu);
double cos1 = Math.cos(hudu);
float xEnd = (float) (radius * sin1)+xCenter;
float yEnd = -(float) (radius * cos1)+yCenter;
float xStart = (float) ((radius-len) * sin1)+xCenter;
float yStart = -(float) ((radius-len) * cos1)+yCenter;
canvas.drawLine(xStart,yStart,xEnd,yEnd,mPaint);
}
下一步绘制时针、分针、秒钟等,坐标计算的公式都差不多,但是看起来会比较难以理解,比如计算时针角度时,假设现在是8点,以12点为0度开始的地方,那它所占的角度应该是8 / 12 * 360, 但是小时我们是用mProgressHour表示的,它是一个整型,8 / 12 为0 ,除非我们先将它转为float类型,所以计算时我调整了一下计算的顺序,看时需要自己揣摩一下。
/**
* 下面我计算弧度时算式看起来很混乱,这是因为时分秒都是int类型的,
* 比如计算mProgressMin/60, 我们希望得到的结果是0.5, 但是实际上是0, 所以我调整了一下计算顺序,
* 也可以先转换类型再计算
*/
// 绘制秒针
mPaint.setStrokeWidth(10);
double sec = mProgressSecond * 360 * Math.PI / 60 / 180;
float sStart = (float) (lenSec * Math.sin(sec)) + xCenter;
float sEnd = - (float) (lenSec * Math.cos(sec)) + yCenter;
canvas.drawLine(xCenter, yCenter, sStart, sEnd, mPaint);
// 绘制分针
mPaint.setStrokeWidth(20);
double min = mProgressMin * 360 * Math.PI / 60 / 180;
float mStart = (float) (lenMin * Math.sin(min)) + xCenter;
float mEnd = - (float) (lenMin * Math.cos(min)) + yCenter;
canvas.drawLine(xCenter, yCenter, mStart, mEnd, mPaint);
// 绘制时针,时针应该按照分针偏移一定角度
mPaint.setStrokeWidth(30);
// 先计算所占的角度mProgressHour / 12 * 360, 再计算弧度 / 180 * Math.PI
double hour = mProgressHour * 360 * Math.PI / 12 / 180;
// 分会导致时针偏移一定的角度
hour += mProgressMin * 30 * Math.PI / 180 / 60;
float hStart = (float) (lenHour * Math.sin(hour)) + xCenter;
float hEnd = - (float) (lenHour * Math.cos(hour)) + yCenter;
canvas.drawLine(xCenter, yCenter, hStart, hEnd, mPaint);
最后是绘制表盘上的数字,这里需要让数字居中显示,具体的绘制原理和显示方式参考
https://www.jianshu.com/p/8b97627b21c4
// 绘制数字
mPaint.setStrokeWidth(radius/60);
mPaint.setTextSize((float) (radius / 3.5));
mPaint.setStyle(Paint.Style.FILL);
mPaint.setTextAlign(Paint.Align.CENTER);
// 绘制数字时,为了使数字居中对其,应该给y坐标一定的偏移量
Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
float distance = (fontMetrics.bottom - fontMetrics.top)/2 - fontMetrics.bottom;
for (int i = 0; i < 12; i++) {
double d = (i+1) * 30 * Math.PI / 180;
float x = (float) ((radius - 100) * Math.sin(d) + xCenter);
float y = (float) (-(radius - 100) * Math.cos(d) + yCenter);
float baseline = y + distance;
canvas.drawText(String.valueOf(i+1),x,baseline,mPaint);
}
绘制工作到这里就大功告成了。接下来开启一个线程,每秒钟刷新一次数据。
// 绘图线程
new Thread(){
@Override
public void run() {
while (true){
mProgressSecond +=1;
if (mProgressSecond == 60){
mProgressSecond = 0;
mProgressMin += 1;
// 处理时针
if (mProgressMin == 60){
mProgressMin = 0;
mProgressHour += 1;
}
}
// 重新绘制
postInvalidate();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}.start();
最后,附上全部代码。
public class WatchView extends View {
private static final String TAG = "WatchView";
private Paint mPaint;
private int mCircleWidth = 20;
private int radius = 500;
// 这些下面都会重新计算
private int lenHour = 200;
private int lenMin = 300;
private int lenSec = 400;
private int mProgressSecond = 0;
private int mProgressMin = 30;
private int mProgressHour = 11;
// background
private int mBackgroundColor;
public WatchView(Context context) {
this(context,null);
}
public WatchView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public WatchView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
Init();
TypedArray array = context.obtainStyledAttributes(attrs,R.styleable.WatchView);
mProgressHour = array.getInteger(R.styleable.WatchView_hour,0);
mProgressHour %= 12;
mProgressMin = array.getInteger(R.styleable.WatchView_min,0);
mProgressMin %= 60;
mProgressSecond = array.getInteger(R.styleable.WatchView_sec,0);
mProgressSecond %= 60;
mBackgroundColor = array.getColor(R.styleable.WatchView_background_color,getResources().getColor(R.color.white));
array.recycle();
}
public int getmProgressSecond() {
return mProgressSecond;
}
public void setmProgressSecond(int mProgressSecond) {
this.mProgressSecond = mProgressSecond % 60;
}
public int getmProgressMin() {
return mProgressMin;
}
public void setmProgressMin(int mProgressMin) {
this.mProgressMin = mProgressMin % 60;
}
public int getmProgressHour() {
return mProgressHour;
}
public void setmProgressHour(int mProgressHour) {
this.mProgressHour = mProgressHour % 12;
}
private void Init(){
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeCap(Paint.Cap.ROUND);
mPaint.setColor(getResources().getColor(R.color.black));
mPaint.setStrokeWidth(10); // 圆环宽度10
mPaint.setAntiAlias(true);
mPaint.setTextSize(100);
mPaint.setTextAlign(Paint.Align.CENTER);
// 绘图线程
new Thread(){
@Override
public void run() {
while (true){
mProgressSecond +=1;
if (mProgressSecond == 60){
mProgressSecond = 0;
mProgressMin += 1;
// 处理时针
if (mProgressMin == 60){
mProgressMin = 0;
mProgressHour += 1;
}
}
// 重新绘制
postInvalidate();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}.start();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int widthSepcSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
// 继承自自定义View,需要给wrap_content这种模式指定一个具体的值,否则它的大小和match_parent没有区别
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(500,500);
}else if (widthSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(500,heightSpecSize);
}else if (heightSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(widthSepcSize,500);
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
// 初始化半径,指针长度等,这些计算不能放到初始化函数和onMeasure中完成,因为那时宽度还没有确定,getWidth = 0.
// 也不要放到onDraw里面做,因为onDraw调用比较频繁,最好不要放太多操作进去
int w = getWidth() - getPaddingLeft() - getPaddingRight();
int h = getHeight() - getPaddingTop() - getPaddingBottom();
if (w > h){
radius = h / 2;
}else {
radius = w / 2;
}
// 确定指针长度
lenSec = (int) ((float)(radius) * 3 / 4);
lenMin = (int) ((float)(radius) / 2);
lenHour = (int) ((float)(radius) / 4);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int center;
if (getWidth() > getHeight()){
center = getHeight() / 2;
}else {
center = getWidth() / 2;
}
// 圆心坐标
float xCenter = center;
float yCenter = center;
// 表框
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(mCircleWidth);
mPaint.setColor(getResources().getColor(R.color.black));
canvas.drawCircle(center,center,radius,mPaint);
// 在指针的旋转中心画一个小圈圈
canvas.drawCircle(xCenter,yCenter,10,mPaint);
// 绘制背景
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(mBackgroundColor);
canvas.drawCircle(center,center,radius - mCircleWidth / 2,mPaint);
mPaint.setStrokeWidth(mCircleWidth);
mPaint.setColor(getResources().getColor(R.color.black));
// 绘制12个大刻度和其他小刻度
for (int i = 0; i < 60; i++) {
// 大刻度
int len = 0;
if (i%5 == 0){
len = 30;
mPaint.setStrokeWidth(20);
}else {
len = 20;
mPaint.setStrokeWidth(10);
}
double hudu = i * 6 * Math.PI / 180;
double sin1 = Math.sin(hudu);
double cos1 = Math.cos(hudu);
float xEnd = (float) (radius * sin1)+xCenter;
float yEnd = -(float) (radius * cos1)+yCenter;
float xStart = (float) ((radius-len) * sin1)+xCenter;
float yStart = -(float) ((radius-len) * cos1)+yCenter;
canvas.drawLine(xStart,yStart,xEnd,yEnd,mPaint);
}
/**
* 下面我计算弧度时算式看起来很混乱,这是因为时分秒都是int类型的,
* 比如计算mProgressMin/60, 我们希望得到的结果是0.5, 但是实际上是0, 所以我调整了一下计算顺序,
* 也可以先转换类型再计算
*/
// 绘制秒针
mPaint.setStrokeWidth(10);
double sec = mProgressSecond * 360 * Math.PI / 60 / 180;
float sStart = (float) (lenSec * Math.sin(sec)) + xCenter;
float sEnd = - (float) (lenSec * Math.cos(sec)) + yCenter;
canvas.drawLine(xCenter, yCenter, sStart, sEnd, mPaint);
// 绘制分针
mPaint.setStrokeWidth(20);
double min = mProgressMin * 360 * Math.PI / 60 / 180;
float mStart = (float) (lenMin * Math.sin(min)) + xCenter;
float mEnd = - (float) (lenMin * Math.cos(min)) + yCenter;
canvas.drawLine(xCenter, yCenter, mStart, mEnd, mPaint);
// 绘制时针,时针应该按照分针偏移一定角度
mPaint.setStrokeWidth(30);
// 先计算所占的角度mProgressHour / 12 * 360, 再计算弧度 / 180 * Math.PI
double hour = mProgressHour * 360 * Math.PI / 12 / 180;
// 分会导致时针偏移一定的角度
hour += mProgressMin * 30 * Math.PI / 180 / 60;
float hStart = (float) (lenHour * Math.sin(hour)) + xCenter;
float hEnd = - (float) (lenHour * Math.cos(hour)) + yCenter;
canvas.drawLine(xCenter, yCenter, hStart, hEnd, mPaint);
// 绘制数字
mPaint.setStrokeWidth(radius/60);
mPaint.setTextSize((float) (radius / 3.5));
mPaint.setStyle(Paint.Style.FILL);
mPaint.setTextAlign(Paint.Align.CENTER);
// 绘制数字时,为了使数字居中对其,应该给y坐标一定的偏移量
Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
float distance = (fontMetrics.bottom - fontMetrics.top)/2 - fontMetrics.bottom;
for (int i = 0; i < 12; i++) {
double d = (i+1) * 30 * Math.PI / 180;
float x = (float) ((radius - 100) * Math.sin(d) + xCenter);
float y = (float) (-(radius - 100) * Math.cos(d) + yCenter);
float baseline = y + distance;
canvas.drawText(String.valueOf(i+1),x,baseline,mPaint);
}
}
}