为什么需要 Custom View?
Android 提供了丰富的内置控件(TextView、Button、ImageView 等),但实际开发中经常会遇到「现有控件不够用」的场景:
- 需要一个环形进度条
- 需要一个自定义的图表
- 需要一个特殊形状的按钮
- 需要一个手势识别的画板
这时候就需要 Custom View ------ 自己绘制、自己处理交互。
View 的绘制流程
每个 View 从创建到显示在屏幕上,会经历三个核心阶段:
measure(测量)→ layout(布局)→ draw(绘制)
| 阶段 | 方法 | 作用 |
|---|---|---|
| 测量 | onMeasure() |
确定 View 的宽高 |
| 布局 | onLayout() |
确定子 View 的位置(ViewGroup 用) |
| 绘制 | onDraw() |
在 Canvas 上绘制内容 |
💡 对于自定义单个 View ,主要关注
onMeasure()和onDraw();onLayout()在自定义 ViewGroup 时才需要重写。
第一步:最简单的 Custom View
创建一个继承自 View 的类,重写 onDraw():
kotlin
class SimpleView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint().apply {
color = Color.BLUE
style = Paint.Style.FILL
isAntiAlias = true
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 画一个蓝色圆形
canvas.drawCircle(width / 2f, height / 2f, 100f, paint)
}
}
这就完成了一个最简单的自定义 View ------ 在屏幕中央画一个蓝色圆。
第二步:理解 onMeasure
View 的尺寸不是你想多大就多大,需要和父布局"协商"。这就是 onMeasure() 的作用。
MeasureSpec 三种模式
| 模式 | 含义 | 场景 |
|---|---|---|
EXACTLY |
精确值 | 设置了具体 dp 值或 match_parent |
AT_MOST |
最大值 | wrap_content |
UNSPECIFIED |
不限制 | ScrollView 内嵌 View |
重写 onMeasure
kotlin
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
// 默认尺寸
val desiredWidth = 200
val desiredHeight = 200
val width = when (widthMode) {
MeasureSpec.EXACTLY -> widthSize
MeasureSpec.AT_MOST -> minOf(desiredWidth, widthSize)
else -> desiredWidth
}
val height = when (heightMode) {
MeasureSpec.EXACTLY -> heightSize
MeasureSpec.AT_MOST -> minOf(desiredHeight, heightSize)
else -> desiredHeight
}
setMeasuredDimension(width, height)
}
⚠️ 如果不重写
onMeasure(),使用wrap_content时会占满父布局,表现得和match_parent一样。
第三步:Canvas 与 Paint
自定义绘制的核心就是两个类:Canvas (画布)和 Paint(画笔)。
Canvas 常用绘制方法
kotlin
// 画矩形
canvas.drawRect(left, top, right, bottom, paint)
// 画圆角矩形
canvas.drawRoundRect(rect, rx, ry, paint)
// 画文字
canvas.drawText("Hello", x, y, paint)
// 画路径(最灵活)
val path = Path().apply {
moveTo(100f, 100f)
lineTo(200f, 200f)
lineTo(100f, 200f)
close()
}
canvas.drawPath(path, paint)
// 画 Bitmap
canvas.drawBitmap(bitmap, left, top, paint)
Paint 常用属性
kotlin
val paint = Paint().apply {
color = Color.RED // 颜色
style = Paint.Style.STROKE // 描边 / FILL 填充 / FILL_AND_STROKE
strokeWidth = 4f // 描边宽度
isAntiAlias = true // 抗锯齿(必开)
textSize = 36f // 文字大小
textAlign = Paint.Align.CENTER // 文字对齐
setShadowLayer(8f, 0f, 0f, Color.GRAY) // 阴影
}
Canvas 变换操作
Canvas 还支持平移、旋转、缩放、裁剪,用来实现复杂效果:
kotlin
canvas.save() // 保存当前状态
canvas.translate(cx, cy) // 平移原点到中心
canvas.rotate(angle) // 旋转
canvas.drawArc(rectF, startAngle, sweepAngle, true, paint)
canvas.restore() // 恢复
💡
save()和restore()要配对使用,避免影响后续绘制。
实战:环形进度条
把上面的知识综合起来,画一个环形进度条:
kotlin
class CircleProgressBar(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private var progress: Float = 0f // 0~100
private val bgPaint = Paint().apply {
color = Color.LTGRAY
style = Paint.Style.STROKE
strokeWidth = 20f
isAntiAlias = true
strokeCap = Paint.Cap.ROUND
}
private val progressPaint = Paint().apply {
color = Color.parseColor("#4CAF50")
style = Paint.Style.STROKE
strokeWidth = 20f
isAntiAlias = true
strokeCap = Paint.Cap.ROUND
}
private val textPaint = Paint().apply {
color = Color.DKGRAY
textSize = 48f
textAlign = Paint.Align.CENTER
isAntiAlias = true
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val size = 200
val w = resolveSize(size, widthMeasureSpec)
val h = resolveSize(size, heightMeasureSpec)
val min = minOf(w, h)
setMeasuredDimension(min, min)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val cx = width / 2f
val cy = height / 2f
val radius = minOf(cx, cy) - 30f
val rectF = RectF(cx - radius, cy - radius, cx + radius, cy + radius)
// 背景圆环
canvas.drawArc(rectF, 0f, 360f, false, bgPaint)
// 进度弧
val sweepAngle = 360f * (progress / 100f)
canvas.drawArc(rectF, -90f, sweepAngle, false, progressPaint)
// 文字
val text = "${progress.toInt()}%"
val textY = cy - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(text, cx, textY, textPaint)
}
fun setProgress(value: Float) {
progress = value.coerceIn(0f, 100f)
invalidate() // 触发重绘
}
}
使用方法:
kotlin
val progressBar = findViewById<CircleProgressBar>(R.id.circle_progress)
progressBar.setProgress(75f)
第四步:处理触摸事件
Custom View 不仅可以画,还可以交互。重写 onTouchEvent():
kotlin
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 手指按下
return true // 返回 true 表示消费此事件
}
MotionEvent.ACTION_MOVE -> {
// 手指移动
invalidate() // 根据触摸位置重绘
}
MotionEvent.ACTION_UP -> {
// 手指抬起
}
}
return super.onTouchEvent(event)
}
实战:简单画板
kotlin
class DrawingBoard(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val path = Path()
private val paint = Paint().apply {
color = Color.BLACK
style = Paint.Style.STROKE
strokeWidth = 5f
isAntiAlias = true
strokeJoin = Paint.Join.ROUND
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawPath(path, paint)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
path.moveTo(event.x, event.y)
return true
}
MotionEvent.ACTION_MOVE -> {
path.lineTo(event.x, event.y)
invalidate()
}
}
return true
}
}
性能优化
自定义绘制最容易成为性能瓶颈,注意以下几点:
1. 避免在 onDraw 中创建对象
kotlin
// ❌ 错误:每次 onDraw 都会创建新对象
override fun onDraw(canvas: Canvas) {
val paint = Paint() // 每帧创建,GC 压力大
canvas.drawCircle(...)
}
// ✅ 正确:在 init 或成员变量中创建
private val paint = Paint().apply { ... }
2. 局部刷新
kotlin
// ❌ 全量刷新
invalidate()
// ✅ 只刷新变化区域
invalidate(dirtyRect)
3. 使用硬件加速
Android 默认开启硬件加速,但有些 Canvas 操作不支持 GPU 渲染。遇到性能问题时检查:
kotlin
// 关闭硬件加速(某些复杂绘制需要)
setLayerType(LAYER_TYPE_SOFTWARE, null)
4. 善用 Canvas.saveLayer
saveLayer() 会离屏渲染,开销较大。只在需要混合模式(PorterDuff)等效果时使用。
自定义属性
让 Custom View 支持 XML 配置:
xml
<!-- res/values/attrs.xml -->
<declare-styleable name="CircleProgressBar">
<attr name="cpb_progress" format="float" />
<attr name="cpb_bg_color" format="color" />
<attr name="cpb_progress_color" format="color" />
</declare-styleable>
kotlin
// 在 init 中读取属性
init {
context.obtainStyledAttributes(attrs, R.styleable.CircleProgressBar).apply {
progress = getFloat(R.styleable.CircleProgressBar_cpb_progress, 0f)
bgPaint.color = getColor(R.styleable.CircleProgressBar_cpb_bg_color, Color.LTGRAY)
progressPaint.color = getColor(R.styleable.CircleProgressBar_cpb_progress_color, Color.GREEN)
recycle()
}
}
XML 中使用:
xml
<com.example.CircleProgressBar
android:layout_width="200dp"
android:layout_height="200dp"
app:cpb_progress="60"
app:cpb_progress_color="#FF5722" />
总结
Custom View 的核心就三步:
| 步骤 | 要点 |
|---|---|
| onMeasure | 正确处理 wrap_content,调用 setMeasuredDimension() |
| onDraw | 用 Canvas + Paint 绘制,避免创建对象 |
| onTouchEvent | 处理交互,返回 true 表示消费事件 |
掌握这三个方法,再加上 Canvas 的各种绘制 API,几乎可以实现任何 UI 效果。Custom View 是 Android 开发中从"会用控件"到"理解控件"的关键一步。
系列导航: 上一篇:Hilt 依赖注入 | 下一篇:ContentProvider 与跨进程通信