一、基础准备
1.1 自定义View相关API
首先来学一下自定义 View 基本的 API,推荐看看下面这几篇博客,介绍得非常详细:
自定义 View 1-1 绘制基础
自定义 View 1-2 Paint 详解
自定义 View 1-4 Canvas 对绘制的辅助 clipXXX() 和 Matrix
1.2 三角函数相关知识
三角函数属于基本数学的范畴,这里我们重新回顾三角函数的计算和推导出来的定理,用来理解计算机程序中图形学方面的计算。首先我们来看一些基本名词的讲解:
角可以看作平面内一条射线绕着它的端点从一个位置旋转到另一个位置所形成的图形,射线的端点叫做角的顶点,旋转开始时的射线叫做角的起始边,终止时的射线叫做角的终止边。普遍规定按逆时针旋转的角为正角,顺时针旋转的角为负角,当射线没有任何旋转时则为零角。
角的象限:把角的顶点和坐标原点重合,角的初始边与x轴的正半轴重合,角的终止边落在第几象限,就称这个角为第几象限角,如果角的终止边在坐标轴上,就称这个角不属于任何象限。如下图:
弧度制:规定长度等于半径的圆弧所对应的圆心角为1个弧度(radian)的角,那么根据圆周长为2πr的圆心角为360°得到1弧度为(360/2π)°。
建立直角坐标系,以坐标原点为圆心画半径为1的圆,那么任意角a的终止边与圆相交的点P(x,y),则有:
Java 中的三角函数在 java.lang.Math 中:
Math.sin(double a);
Math.cos(double a);
Math.tan(double a);
Math.asin(double a);
Math.acos(double a);
Math.atan(double a);
//以上方法的参数都是弧度,传入度数时需要用下面这个方法转换为弧度
Math.toRadians(double angdeg);
public static double toRadians(double angdeg) {
return angdeg / 180.0 * PI;
}
二、仪表盘
如上图所示是一个简单模拟的仪表盘图形,我们可以将它的绘制分为3步:绘制弧线、绘制刻度、绘制指针,下面我们来逐一分析。首先是绘制弧线,绘制弧线有这些方式:
Canvas.drawArc(float left, float top, float right, float bottom, float startAngle, float sweepAngle, boolean useCenter, Paint paint) //绘制弧形或扇形
drawArc() 是使用一个椭圆来描述弧形的。
left, top, right, bottom 描述的是这个弧形所在的椭圆;
startAngle 是弧形的起始角度(x 轴的正向,即正右的方向,是 0 度的位置;顺时针为正角度,逆时针为负角度);
sweepAngle 是弧形划过的角度;
useCenter 表示是否连接到圆心,如果不连接到圆心,就是弧形,如果连接到圆心,就是扇形。
Canvas.drawPath(Path path, Paint paint) //画自定义图形 Path.addArc(float left, float top, float right, float bottom, float startAngle, float sweepAngle)
接下来是绘制刻度,绘制刻度如果我们一个个去绘制的话会很麻烦,Paint 中可以设置一个 PathEffect 来达到这样的效果:
PathEffect 分为两类,单一效果的 CornerPathEffect、DiscretePathEffect、DashPathEffect、PathDashPathEffect ,和组合效果的 SumPathEffect、ComposePathEffect。这里我们要用到 PathDashPathEffect。
它的构造方法 PathDashPathEffect(Path shape, float advance, float phase, PathDashPathEffect.Style style) 中, shape 参数是用来绘制的 Path ; advance是两个相邻的 shape 段之间的间隔,不过注意,这个间隔是两个 shape 段的起点的间隔,而不是前一个的终点和后一个的起点的距离; phase 和 DashPathEffect 中一样,是虚线的偏移;最后一个参数 style,是用来指定拐弯改变的时候 shape 的转换方式。style 的类型为 PathDashPathEffect.Style,是一个 enum ,具体有三个值:TRANSLATE:位移、ROTATE:旋转、MORPH:变体
最后是绘制指针,直接用 Canvas.drawLine 就可以了,其中要利用到三角函数的知识来确定指针的终点。
下面放出完整代码:
class DashBoard(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
val ANGLE = 120
val RADIUS = Utils.dp2px(150)
val LENGTH = Utils.dp2px(138)
var mPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
var mDashPath: Path = Path()
var mArcPath: Path = Path()
lateinit var mPathEffect: PathDashPathEffect
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
Log.i(this.javaClass.name, "onSizeChanged")
mPaint.style = Paint.Style.STROKE
mPaint.strokeWidth = Utils.dp2px(2)
mArcPath.addArc(
width / 2 - RADIUS, getHeight() / 2 - RADIUS, getWidth() / 2 + RADIUS,
getHeight() / 2 + RADIUS, 90 + ANGLE / 2f, 360f - ANGLE
)
mDashPath.addRect(0f, 0f, Utils.dp2px(2), Utils.dp2px(10), Path.Direction.CW)
//Path测量工具
val pathMeasure = PathMeasure(mArcPath, false)
//pathMeasure.length - 2dp 是减去最后一个刻度所占宽度
mPathEffect = PathDashPathEffect(
mDashPath, (pathMeasure.length - Utils.dp2px(2)) / 20, 0f,
PathDashPathEffect.Style.ROTATE
)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
//1、绘制弧线
canvas.drawPath(mArcPath, mPaint)
//2、绘制刻度
mPaint.pathEffect = mPathEffect
canvas.drawPath(mArcPath, mPaint)
mPaint.pathEffect = null
//3、绘制指针
canvas.drawLine(
width / 2f, height / 2f,
(width / 2 + Math.cos(Math.toRadians(getAngleFromMark(5.5f))) * LENGTH).toFloat(),
(height / 2 + Math.sin(Math.toRadians(getAngleFromMark(5.5f))) * LENGTH).toFloat(),
mPaint
)
}
fun getAngleFromMark(mark: Float): Double {
return (90 + ANGLE / 2 + (360 - ANGLE) / 20 * mark).toDouble()
}
}
运行结果:
三、饼状图
如上图所示,要实现这样一个饼状图可以分为两步:画扇形,将其中某一块往外位移一段距离。
画扇形的方法上面已经说过了,不熟悉API的可以去看看开头部分说的博客。这里主要是要将其中一块往外位移,
使用 Canvas 来做常见的二维变换:
Canvas.translate(float dx, float dy) 平移,参数里的 dx 和 dy 表示横向和纵向的位移。
canvas.save(); canvas.translate(200, 0); canvas.draw... canvas.restore();
那么我们这里要变换的位置如何计算呢?也要利用到三角函数的知识,偏移的方向是扇形的角平分线。完整代码如下:
class PieChart(context: Context, attributes: AttributeSet) : View(context, attributes) {
val RADIUS = Utils.dp2px(150)
val OUT_LENGTH = Utils.dp2px(20)
val PULL_OUT_INDEX = 2
var mPaint = Paint(Paint.ANTI_ALIAS_FLAG)
var mBounds = RectF();
var angles = arrayOf(60f, 100f, 120f, 80f)
var colors = arrayOf(
Color.parseColor("#2979FF"), Color.parseColor("#C2185B"),
Color.parseColor("#009688"), Color.parseColor("#FF8F00")
)
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
mBounds.set(width / 2 - RADIUS, height / 2 - RADIUS, width / 2 + RADIUS, height / 2 + RADIUS);
}
override fun draw(canvas: Canvas) {
super.draw(canvas)
var curStartAngle = 0f
for (i in angles.indices) {
mPaint.color = colors[i]
canvas.save()
if(i == PULL_OUT_INDEX){
canvas.translate(
(Math.cos(Math.toRadians((curStartAngle + angles[i]/2).toDouble()))* OUT_LENGTH).toFloat(),
(Math.sin(Math.toRadians((curStartAngle + angles[i]/2).toDouble()))* OUT_LENGTH).toFloat()
)
}
canvas.drawArc(mBounds, curStartAngle, angles[i], true, mPaint)
canvas.restore()
curStartAngle += angles[i]
}
}
}
四、头像图
如上图所示,需要一个图片,并将图片切成圆形,另外在绘制一个外边框。需要涉及到的知识主要是:
setXfermode(Xfermode xfermode)
以及 Canvas.saveLayer() 设置离屏缓冲
代码如下:
class AvaterView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
val WIDTH = Utils.dp2px(300)
val PADDING = Utils.dp2px(50)
val EDGE_WIDTH = Utils.dp2px(10)
var mPaint = Paint(Paint.ANTI_ALIAS_FLAG)
var mBitmap: Bitmap
var mXfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
init {
mBitmap = Utils.decodeBitmap(resources, R.drawable.avatar, WIDTH.toInt())
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
//绘制外圆
canvas.drawOval(PADDING, PADDING, PADDING + WIDTH, PADDING + WIDTH, mPaint)
//离屏缓冲
val saved = canvas.saveLayer(PADDING, PADDING, PADDING + WIDTH, PADDING + WIDTH, mPaint)
canvas.drawOval(
PADDING + EDGE_WIDTH, PADDING + EDGE_WIDTH,
PADDING + WIDTH - EDGE_WIDTH, PADDING + WIDTH - EDGE_WIDTH, mPaint
)
mPaint.xfermode = mXfermode
canvas.drawBitmap(mBitmap, PADDING, PADDING, mPaint)
mPaint.xfermode = null
canvas.restoreToCount(saved)
}
}
object Utils {
fun dp2px(dp: Int): Float {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp.toFloat(),
Resources.getSystem().displayMetrics
)
}
fun decodeBitmap(res: Resources, resId: Int, reqWidth: Int): Bitmap {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeResource(res, resId, options)
options.inJustDecodeBounds = false
options.inDensity = options.outWidth
options.inTargetDensity = reqWidth
return BitmapFactory.decodeResource(res, resId, options)
}
}