自定义 View(绘制)
自定义绘制
图形的位置和尺寸测量
绘制的基本要素
- 重写 onDraw() 方法
- 使用 Canvas 来绘制 (drawLine、drawCircle、drawPath …)
- 使用 Paint 来配置(color、textSize …)
- 坐标系 (向右为 x 轴正方向,向下为 y 轴正方向)
- 尺寸单位是像素px,而不是 dp
// dp 转 px
public static float dp2px(float value) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value,
Resources.getSystem().getDisplayMetrics());
}
Path 的方向以及封闭图形的内外判断
fillType(填充类型)
-
FillType.WINDING (默认的填充类型)
从 Path 内的任一点向外做射线,相交于图形的路径,如果相交方向相同的图形路径,路径的穿插次数大于0 表示该点为内部;如果相交方向相反的图形路径,若方向路径的正反穿插次数相同表示该点为外部。 -
FillType.EVEN_ODD
从 Path 内的任一点向外做射线,不考虑路径的穿插方向。穿插奇数次则为内部,偶数次为外部。
private val RADIUS = 100.dp
class TestView(context: Context?, attrs: AttributeSet?)
: View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val rectPath = Path()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 画圆
rectPath.addCircle(width / 2f, height / 2f, RADIUS, Path.Direction.CCW)
// 画矩形
rectPath.addRect(width / 2f - RADIUS, height / 2f,
width / 2f + RADIUS, height / 2f + RADIUS * 2,
Path.Direction.CW) // clockwise 顺时针, counter-clockwise 逆时针
rectPath.fillType = Path.FillType.EVEN_ODD
canvas.drawPath(rectPath, paint)
}
}
PathMeasure
用于对 Path 做测量(如长度的测量)
绘制仪表盘
- 用 drawArc 绘制弧形
- 三角函数的计算,横向的位移是 cos,纵向的位移是 sin
- PathDashPathEffect
加上 PathEffect 后,就只绘制 effect,而不绘制原图形;
绘制 dash 的 x轴 正方向是按原图形顺时针方向的切面,y轴 正方向指向原图形内部
private val RADIUS = 120.dp// 半径
private const val OPEN_ANGLE = 120f// 仪表盘的下面的开口弧度
private val DASH_WIDTH = 2.dp // 刻度线宽度
private val DASH_HEIGHT = 10.dp// 刻度线长度
private val HAND_POS = 5// 指针指向位置
private val HAND_LENGTH = 100.dp// 指针的长度
class DashboardView(context: Context, attrs: AttributeSet) :
View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val path = Path()// 圆弧的路径
private val dash = Path()// 刻度线路径
private lateinit var pathEffect: PathEffect
init {
paint.strokeWidth = 3.dp
paint.style = Paint.Style.STROKE
dash.addRect(0f, 0f, DASH_WIDTH, DASH_HEIGHT, Path.Direction.CW)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
path.reset()
val rect = RectF(
width / 2f - RADIUS, height / 2f - RADIUS,
width / 2f + RADIUS, height / 2f + RADIUS
)
path.addArc(rect, 90 + OPEN_ANGLE / 2f, 360 - OPEN_ANGLE)
// 测量
val pathMeasure = PathMeasure(path, false)
pathEffect = PathDashPathEffect(
dash, (pathMeasure.length - DASH_WIDTH) / 20f, 0f,
PathDashPathEffect.Style.ROTATE
)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 画弧
canvas.drawPath(path, paint)
// 画刻度
paint.pathEffect = pathEffect
canvas.drawPath(path, paint)
paint.pathEffect = null
// 画指针
canvas.drawLine(width / 2f, height / 2f,
HAND_LENGTH * cos(mark2Radio(HAND_POS)).toFloat() + width / 2f,
HAND_LENGTH * sin(mark2Radio(HAND_POS).toFloat()) + height /2f,
paint
)
}
private fun mark2Radio(mark: Int) =
// 将以度测量的角度转换为以弧度测量的近似等效角度
Math.toRadians((90 + OPEN_ANGLE / 2f + (360- OPEN_ANGLE) / 20 * mark).toDouble())
}
饼图
- 用 drawArc() 绘制扇形
- 用 Canvas.translate() 来移动扇形,并用 Canvas.save() 和 Canvas.restore() 来保存和恢复位置
- 用三角函数 cos 和 sin 计算偏移
private val RADIUS = 120.dp
private val ANGLES = floatArrayOf(60f, 100f, 40f, 160f)
private val COLORS = listOf(
Color.parseColor("#f7ad58"),
Color.parseColor("#c75450"),
Color.parseColor("#8776af"),
Color.parseColor("#3ddc84")
)
private val OFFSET_LENGTH = 10.dp
class PieView(context: Context, attrs: AttributeSet) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 画弧
var startAngle = 0f
for ((index, angle) in ANGLES.withIndex()) {
paint.color = COLORS[index]
if (index == 1) {
canvas.save()
canvas.translate(
OFFSET_LENGTH * cos(Math.toRadians(startAngle + angle / 2.toDouble())).toFloat(),
OFFSET_LENGTH * sin(Math.toRadians(startAngle + angle / 2.toDouble())).toFloat()
)
}
canvas.drawArc(
width / 2f - RADIUS, height / 2f - RADIUS,
width / 2f + RADIUS, height / 2f + RADIUS,
startAngle, angle, true, paint
)
startAngle += angle
if (index == 1) {
canvas.restore()
}
}
}
}
Xfermode
对多次绘制进行合成,例如蒙版消息:用 A 的形状和 B 的图案。
使用:
- Canvas.saveLayer() 把绘制区域拉到单独的离屛缓冲里
- 绘制 A 图形
- 用 Paint.setXfermode() 设置 Xfermode
- 绘制 B 图案
- 用 Paint.setXfermode(null) 恢复 Xfermode,防止污染
- 用 Canvas.restoreToCount() 把离屏缓冲中合成的图形放回绘制区域
private val XFERMODE = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
class XfermodeView(context: Context, attrs: AttributeSet) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val bound = RectF(150.dp, 50.dp, 300.dp, 200.dp)
private val circleBitmap = Bitmap.createBitmap(150.dp.toInt(), 150.dp.toInt(), Bitmap.Config.ARGB_8888)
private val squareBitmap = Bitmap.createBitmap(150.dp.toInt(), 150.dp.toInt(), Bitmap.Config.ARGB_8888)
init {
val canvas = Canvas(circleBitmap)
paint.color = Color.parseColor("#d25a61")
canvas.drawOval(50.dp, 0.dp, 150.dp, 100.dp, paint)
paint.color = Color.parseColor("#527087")
canvas.setBitmap(squareBitmap)
canvas.drawRect(0.dp, 50.dp, 100.dp, 150.dp, paint)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val count = canvas.saveLayer(bound, null)
canvas.drawBitmap(circleBitmap, 150.dp, 50.dp, paint)
paint.xfermode = XFERMODE
canvas.drawBitmap(squareBitmap, 150.dp, 50.dp, paint)
paint.xfermode = null
canvas.restoreToCount(count)
}
}
注:为什么要使用 saveLayer() 才能正确绘制
为了把需要互相作用的图形放在单独的位置来绘制,不会受 View 本身的影响。如果不使用 saveLayer(),绘制的目标区域总是整个 View 的范围,两个图形的交叉区域就错误了。
文字的绘制
绘制文字:drawText()
官方文本的绘制是基于字体的 baseLine 来确认 Y 坐标的,暴力认为 y=baseLine 的值,如果想绘制出来的文字和官方提供的TextView布局出来的文字水平居中,那么就必须计算出baseLine 的值。
文字纵向居中对齐
第一种方式:Paint.getTextBounds() 之后,使用 (bounds.top + bounds.bottom) / 2
第二种方式:Paint.getFontMetrics() 之后,使用 (fontMetrics.ascend + fontMetrics.descend) / 2
top:字符最高点到 baseline 的最大距离
ascent:字符最高点到 baseline 的推荐距离
baseline:字符基线,绘制是基于他的坐标绘制的
descent:字符最低点到 baseline 的推荐距离
bottom:字符最低点到 baseline 的最大距离
baseLine 以上为负值,以下为正值
private val CIRCLE_COLOR = Color.parseColor("#90A4AE")
private val HIGHLIGHT_COLOR = Color.parseColor("#FF4081")
private val RING_WIDTH = 20.dp
private val RADIUS = 120.dp
class SportView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 100.dp
textAlign = Paint.Align.CENTER
typeface = ResourcesCompat.getFont(context, R.font.font)
}
private val bounds = Rect()
private val fontMetrics = Paint.FontMetrics()
override fun onDraw(canvas: Canvas) {
// 绘制环
paint.style = Paint.Style.STROKE
paint.strokeWidth = RING_WIDTH
paint.color = CIRCLE_COLOR
canvas.drawCircle(width / 2f, height / 2f, RADIUS, paint)
// 绘制进度
paint.color = HIGHLIGHT_COLOR
paint.strokeCap = Paint.Cap.ROUND
canvas.drawArc(
width / 2f - RADIUS,
height / 2f - RADIUS,
width / 2f + RADIUS,
height / 2f + RADIUS,
-90f, 255f, false, paint
)
// 绘制文字
paint.style = Paint.Style.FILL
paint.textSize = 80.dp
paint.getTextBounds("abab", 0, "abab".length, bounds)// 用于绘制静态文字的垂直居中
paint.getFontMetrics(fontMetrics)// 用于绘制静态文字的垂直居中
canvas.drawText(
"abab",
width / 2f,
height / 2f - (fontMetrics.ascent + fontMetrics.descent) / 2f,// 垂直居中
paint
)
// 绘制文字2
paint.textAlign = Paint.Align.LEFT
paint.getTextBounds("abab", 0, "abab".length, bounds)
paint.getFontMetrics(fontMetrics)
canvas.drawText(
"abab",
0f - bounds.left,
0f - bounds.top,
paint
)
}
}
换行:breakText() 计算
private val IMAGE_SIZE = 120.dp
private val IMAGE_PADDING = 50.dp
class MultilineTextView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
private val text =
"If we show emotion, we ’re called dramatic. If we want to play against men, we’re nuts. And if we dream of " +
"equal opportunity, delusional. When we stand for something, we’re unhinged. When we’re too good, there’s " +
"something wrong with us. And if we get angry, we’re hysterical, irrational, or just being crazy. " +
"But a woman running a marathon was crazy. A woman boxing was crazy. A woman dunking, crazy. " +
"Coaching an NBA team, crazy. A woman competing in a hijab; changing her sport; landing a double-cork" +
"1080; or winning 23 Grand Slams, having a baby, and then coming back for more, crazy, crazy, crazy, crazy " +
"and crazy. " +
"So if they want to call you crazy, fine. Show them what crazy can do."
private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 16.dp
}
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 16.dp
}
private val fontMetrics = Paint.FontMetrics()
@SuppressLint("DrawAllocation")
override fun onDraw(canvas: Canvas) {
// val staticLayout = StaticLayout(text, textPaint, width,
// Layout.Alignment.ALIGN_NORMAL, 1f, 5f, false)
// staticLayout.draw(canvas)
canvas.drawBitmap(getAvatar(IMAGE_SIZE.toInt()), width - IMAGE_SIZE, IMAGE_PADDING, paint)
paint.getFontMetrics(fontMetrics)
val measuredWidth = floatArrayOf(0f)
var start = 0
var count = 0
var verticalOffset = 0f - fontMetrics.top
var maxWidth: Float
while (start < text.length) {
maxWidth =
if (verticalOffset + fontMetrics.bottom < IMAGE_PADDING
|| verticalOffset + fontMetrics.top > IMAGE_PADDING + IMAGE_SIZE
) {
width.toFloat()
} else {
width.toFloat() - IMAGE_SIZE
}
count = paint.breakText(text, start, text.length, true, maxWidth, measuredWidth)
canvas.drawText(text, start, start + count, 0f, verticalOffset, paint)
start += count
verticalOffset += paint.fontSpacing
}
}
private fun getAvatar(width: Int): Bitmap {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeResource(resources, R.drawable.avatar, options)
options.inJustDecodeBounds = false
options.inDensity = options.outWidth
options.inTargetDensity = width.toInt()
return BitmapFactory.decodeResource(resources, R.drawable.avatar, options)
}
}