效果图:

这里写图片描述

前言:

自定义view,是开发者必备的技能之一,也是找工作时面试官必问的题目。

有文章把自定义控件归纳为三种:

一、自绘控件,即继承View,在onDraw()内使用canvas绘制;

二、组合控件,即把常用的控件组合在一起,变成新的控件;

三、继承控件,即继承一个常用的View,修改、增加某个方法等。

组合控件最常用,自绘控件最体现水平。网上很多入门教程也很详细,本篇也会通过实例细讲绘制过程。总结下来就是更多的:“计算”(计算位置、计算距离等等),所以打开AndroidStudio的同时,也请准备好计算器。

正文

新建StopwatchView 继承View ,除了构造方法外,有两个方法必须得重写:测量尺寸onMeasure(xxx)和绘制图形onDraw(xxx)

public class StopwatchView extends View {
public StopwatchView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}
}

一、onMeasure方法:

系统在绘制图形前,会先测量图形尺寸等相关参数,然后根据尺寸进行绘制。

在Demo中,我们的秒表始终保持圆形,但View的宽高设定可以有三种情况:match_parent、wrap_content、定值,所以我们重写onMeasure()来适配这三种情况

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//重新定义尺寸,保证为正方形
int width = measuredDimension(widthMeasureSpec);
int height = measuredDimension(heightMeasureSpec);
mLen = Math.min(width, height);
//小三角形指针端点到外圆之间的距离,用于计算三角形坐标[这里取整体宽度的1/16]
mTriangleLen = (float) mLen / 16.0f;
//提交设置 新的值
setMeasuredDimension(mLen, mLen);
}
//适配不同尺寸
private int measuredDimension(int measureSpec) {
int defaultSize = 800; //默认大小
int mode = MeasureSpec.getMode(measureSpec); //宽高度设定方式
int size = MeasureSpec.getSize(measureSpec); //宽高度测量大小
switch (mode) {
case MeasureSpec.EXACTLY: //尺寸指定
return size;
case MeasureSpec.AT_MOST: //match_parent
return size;
case MeasureSpec.UNSPECIFIED: //wrap_content
return defaultSize;
default:
return defaultSize;
}
}

说明:1、mLen 是最终外围宽高度。内部其他各元素的宽高、大小等都要以此为基准。简单来说,就是其他各元素都要按照mLen的值进行比例分配,不能设定死。否则可能出现不同尺寸下,内部元素比例不协调的情况 2、MeasureSpec 看起来比较陌生,其实内部只有三个常量、三个方法,如上面的代码所写,重写目的一是保证宽、高相同,二是在wrap_content时给一个默认值

二、StopwatchView构造方法:

在写onDraw()前,先提一下画笔。因为本例是一个动画效果,需要不停的重复执行ondraw(),所以一些不变的对象,如画笔等应该放在构造方法里。分析全局,需要四个画笔:三角形画笔指针(mTrianglePaint)、mLinePaint(mLinePaint)、文字画笔(mTextPaint)、内部圆形画笔(mInnerCirclePaint)

public StopwatchView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
//三角形指针画笔
mTrianglePaint = new Paint();
mTrianglePaint.setColor(Color.WHITE);
mTrianglePaint.setAntiAlias(true); //抗锯齿
//刻度线的画笔
mLinePaint = new Paint();
mLinePaint.setAntiAlias(true);
mLinePaint.setStrokeWidth(2); //设线宽
//文字画笔
mTextPaint = new Paint();
mTextPaint.setTextAlign(Paint.Align.CENTER); //文字居中
mTextPaint.setColor(Color.WHITE);
mTextPaint.setAntiAlias(true);
mTextPaint.setStrokeWidth(2);
//内部圆形画笔
mInnerCirclePaint = new Paint();
mInnerCirclePaint.setColor(Color.WHITE);
mInnerCirclePaint.setStyle(Paint.Style.STROKE); //无填充
mInnerCirclePaint.setAntiAlias(true);
}

三、onDraw方法:

本例主要的变量为秒表计时的毫秒值mMilliseconds,

再根据mMilliseconds值计算出外圆三角形指针的角度outerAngle和内部小圆的角度innerAngle,其他图形的绘制是根据这三个参数来进行;

另一个需要强调的是,参考小米秒钟,共设定240条刻度线,并预先设定好每个角度的值:

float eachLineAngle = 360f / 240f; //两个刻度线之间的角度1.5° 共240条线 240间隔
1、calculateValue() 计算相关值
//计算相关值【根据当前毫秒值,计算外指针角度和内圆指针角度】
private void calculateValue() {
//显示文字
int hours = mMilliseconds / (1000 * 60 * 60);
int minutes = (mMilliseconds % (1000 * 60 * 60)) / (1000 * 60);
int seconds = (mMilliseconds - hours * (1000 * 60 * 60) - minutes * (1000 * 60)) / 1000;
int milliSec = mMilliseconds % 1000 / 100;
if (hours == 0) {
mShowContent = toDoubleDigit(minutes) + ":" + toDoubleDigit(seconds) + "." + milliSec;
} else {
mShowContent = toDoubleDigit(hours) + ":" + toDoubleDigit(minutes) + ":" + toDoubleDigit(seconds) + "." + milliSec;
}
//外角度
outerAngle = 360 * (mMilliseconds % 60000) / 60000;
//内角度
innerAngle = 360 * (mMilliseconds % 1000) / 1000;
}

2、drawTriangle(Canvas canvas) 根据角度绘制三角形

//根据角度绘制三角形
private void drawTriangle(Canvas canvas) {
canvas.save();
//确定坐标
canvas.translate(mLen / 2, mLen / 2);
canvas.rotate(outerAngle);
//画三角形
Path p = new Path();
//指针点
p.moveTo(0, mLen / 2 - mTriangleLen);
//左右侧点
p.lineTo(0.5f * mTriangleLen, mLen / 2 - 0.134f * mTriangleLen);
p.lineTo(-0.5f * mTriangleLen, mLen / 2 - 0.134f * mTriangleLen);
p.close();
canvas.drawPath(p, mTrianglePaint);
canvas.restore();
}

说明:mTriangleLen是之前计算的指针顶点到外边缘的距离。因为没有三角形的api,所以根据路径来绘制。其中:0.5f * mTriangleLen 和 mLen / 2 - 0.134f * mTriangleLen 分别表示以三角形指针另两点的x和y的距离[0.5=sin30°,0.134=(1-cos30°)]

3、drawLine(Canvas canvas) 绘制外部刻度线

//绘制外部刻度线
private void drawLine(Canvas canvas) {
canvas.save();
canvas.translate(mLen / 2, mLen / 2);
int totalLines = (int) (360f / eachLineAngle); //240条线
int lastLine = (int) (outerAngle / eachLineAngle); //最亮的线条
int firstLine = lastLine - ((int) (90 / eachLineAngle)); //最暗的一条
boolean negativeFlag = false; //负数标志【即表示跨过了0起始坐标】
if (firstLine < 0) {
negativeFlag = true;
firstLine = totalLines - Math.abs(firstLine);
}
int count = 0;
for (int i = 0; i < totalLines; i++) {
canvas.rotate(eachLineAngle);
int color = 0;
if (!negativeFlag) {
//没有跨过起始点标志
if (i >= firstLine && i <= lastLine && count < (totalLines / 4)) {
count++;
color = Color.argb(255 - ((totalLines / 4 - count) * 3), 255, 255, 255);
} else {
color = Color.argb(255 - (int) (360f * 3 / (eachLineAngle * 4)), 255, 255, 255);
}
} else {
//跨过起始点
if (i >= 0 && i < lastLine) {
if (count == 0) {
count = totalLines / 4 - lastLine;
} else {
count++;
}
color = Color.argb(255 - ((totalLines / 4 - count) * 3), 255, 255, 255);
} else if (mMilliseconds!=0&&i < totalLines && i >= firstLine) { //mMilliseconds!=0 条件限制,目的是初始化时 都是灰色线条
Log.i("TAG6", "firstLine" + firstLine + " lastLine" + lastLine);
count++;
color = Color.argb(255 - ((totalLines / 4 - (i - firstLine)) * 3), 255, 255, 255);
} else {
color = Color.argb(255 - (int) (360f * 3 / (eachLineAngle * 4)), 255, 255, 255);
}
}
mLinePaint.setColor(color);
//mTriangleLen/5距离 目的是为了三角形到线条之间保留的距离
canvas.drawLine(0, (float) (mLen / 2 - (mTriangleLen+mTriangleLen/5)), 0, (float) (mLen / 2 - (2 * mTriangleLen+mTriangleLen/5)), mLinePaint);
}
canvas.restore();
}

说明:绘制线条,先要计算总的线条数,然后for循环,循环中每次旋转eachLineAngle角度。同时要根据当前角度来设定画笔的颜色来达到渐变效果。因为有跨过0°和未跨过0°的情况,所以代码中分别对此做了处理。当然也可能有其它更好的计算方法。其中的有判断 mMilliseconds!=0情况,表示初始情况或重置情况下,颜色不做改变

4、drawText(Canvas canvas) 绘制文字

//绘制文字
private void drawText(Canvas canvas) {
canvas.save();
canvas.translate(mLen / 2, mLen / 2);
mTextPaint.setTextSize(mLen / 10);
canvas.drawText(mShowContent, 0, 0, mTextPaint);
canvas.restore();
}

5、drawSecondHand(Canvas canvas) 根据角度绘制内部秒针

//根据角度绘制内部秒针
private void drawSecondHand(Canvas canvas) {
canvas.save();
canvas.translate(mLen / 2, (float) mLen * 3 / 4.0f - mLen / 16);
canvas.drawCircle(0, 0, mLen / 12, mInnerCirclePaint);
canvas.drawCircle(0, 0, mLen / 80, mInnerCirclePaint);
canvas.rotate(innerAngle);
canvas.drawLine(0, mLen / 80, 0, mLen / 14, mInnerCirclePaint);
canvas.restore();
}

四、增加对外交互的方法

//开始
public void start() {
if (mTimer == null) {
mTimer = new Timer();
mTimer.schedule(new TimerTask() {
@Override
public void run() {
if (!isPause) {![这里写图片描述]()
mMilliseconds += 50;
//工作线程中用postInvalidate(); UI线程用invalidate()
postInvalidate();
}
}
}, 50, 50);
} else {
resume();
}
}
//暂停
public void pause() {
isPause = true;
}
//继续
private void resume() {
isPause = false;
}
//重置
public void reset() {
if (mTimer != null) {
mTimer.cancel();
mTimer = null;
}
isPause = false;
mMilliseconds = 0;
invalidate();
}
//记录
public int record() {
return mMilliseconds;
}