Android 自定义View
一、自定义 View 是什么?
Android 中所有界面元素本质上都是 View 或 ViewGroup。
常见控件:
所谓 自定义 View,就是开发者根据业务需求,自己定义一个新的 UI 控件。
它可以是:
1. 在系统控件基础上增强
2. 完全自己绘制一个控件
3. 自己定义一个布局容器
4. 把多个已有控件组合成一个新控件
所以,自定义 View 不等于只写 onDraw()。
onDraw() 自绘只是其中一种方式。
二、自定义 View 的四种常见方式
Android 自定义 View 常见可以分为四类:
可以简单记忆:
能复用系统控件,就不要急着 Canvas 自绘。
能组合出来的 UI,优先使用组合控件。
需要复杂图形,才考虑继承 View 自绘。
需要特殊布局规则,才考虑继承 ViewGroup。
三、方式一:继承已有控件
1. 什么是继承已有控件?
这种方式是在系统控件的基础上做增强。
例如:
TextView + 红点
ImageView + 圆角
Button + 防重复点击
EditText + 清除按钮
它不是从零开始写一个控件,而是复用系统控件已有能力。
比如继承 TextView:
class RedPointTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
style = Paint.Style.FILL
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val radius = 8f
canvas.drawCircle(width - radius - 4f, radius + 4f, radius, paint)
}
}
这个例子是在 TextView 的右上角额外画一个红点。
2. 适合场景
继承已有控件适合对系统控件做轻量增强。
这种方式的优点是:
简单
开发成本低
复用系统控件能力
不需要自己处理太多细节
缺点是:
自由度有限
只适合在已有控件基础上增强
不适合复杂自定义图形
四、方式二:继承 View 自己绘制
1. 什么是继承 View 自绘?
这种方式是直接继承 View,然后在 onDraw() 中通过 Canvas 和 Paint 自己绘制内容。
例如:
class CircleView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
style = Paint.Style.FILL
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawCircle(width / 2f, height / 2f, 100f, paint)
}
}
这个控件不依赖系统已有控件,而是自己画一个圆。
2. 自绘 View 的核心对象
自绘 View 常用对象:
常见绘制方法:
3. Paint 常用属性
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
strokeWidth = 8f
style = Paint.Style.STROKE
textSize = 48f
}
Paint.Style 常见有三种:
4. Canvas 坐标系
Android View 的坐标系是:
左上角是原点
向右是 X 轴正方向
向下是 Y 轴正方向
示意:
(0, 0) -----------------> X
|
|
|
v
Y
例如:
canvas.drawCircle(100f, 100f, 50f, paint)
意思是:
圆心在 View 左上角往右 100px、往下 100px 的位置
半径是 50px
5. save 和 restore
绘制时经常需要临时改变画布状态。
例如:
canvas.save()
canvas.translate(width / 2f, height / 2f)
canvas.rotate(45f)
canvas.drawRect(-50f, -50f, 50f, 50f, paint)
canvas.restore()
含义:
save():保存当前画布状态
restore():恢复之前保存的画布状态
可以理解为:
先保存现场
中间随便平移、旋转、缩放
画完之后恢复现场
这样不会影响后续绘制。
五、方式三:继承 ViewGroup 自定义布局
1. 什么是自定义 ViewGroup?
如果你想定义一种新的布局规则,就需要继承 ViewGroup。
例如:
流式布局
标签自动换行布局
九宫格布局
拖拽布局
瀑布流布局
这种情况下,重点不是自己画内容,而是:
测量子 View
摆放子 View
2. ViewGroup 的核心方法
自定义 ViewGroup 通常需要重写两个方法:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 测量自己和子 View
}
override fun onLayout(
changed: Boolean,
l: Int,
t: Int,
r: Int,
b: Int
) {
// 摆放子 View
}
简单理解:
onMeasure:问每个孩子需要多大
onLayout:告诉每个孩子放在哪里
3. 简单竖向布局示例
class SimpleVerticalLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
var totalHeight = paddingTop + paddingBottom
var maxWidth = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility != GONE) {
measureChild(child, widthMeasureSpec, heightMeasureSpec)
totalHeight += child.measuredHeight
maxWidth = maxOf(maxWidth, child.measuredWidth)
}
}
maxWidth += paddingLeft + paddingRight
val width = resolveSize(maxWidth, widthMeasureSpec)
val height = resolveSize(totalHeight, heightMeasureSpec)
setMeasuredDimension(width, height)
}
override fun onLayout(
changed: Boolean,
l: Int,
t: Int,
r: Int,
b: Int
) {
var currentTop = paddingTop
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility != GONE) {
val left = paddingLeft
val top = currentTop
val right = left + child.measuredWidth
val bottom = top + child.measuredHeight
child.layout(left, top, right, bottom)
currentTop = bottom
}
}
}
}
这个例子实现了一个简单的竖向布局。
它的逻辑类似简化版 LinearLayout。
4. 适合场景
自定义 ViewGroup 适合处理系统布局无法满足的摆放规则。
六、方式四:组合控件
1. 什么是组合控件?
组合控件是把多个已有控件组合起来,封装成一个新的控件。
它也叫:
组合式自定义 View
Compound View
复合控件
比如一个标题栏:
返回按钮 + 标题文字 + 右侧按钮
如果每个页面都写一遍,就会很重复。
可以封装成:
<com.example.CommonTitleBar
android:layout_width="match_parent"
android:layout_height="48dp"
app:titleText="设置"
app:rightText="保存" />
这样使用起来就像系统控件一样。
2. 组合控件通常继承什么?
组合控件一般继承已有布局:
FrameLayout
LinearLayout
ConstraintLayout
RelativeLayout
比如:
class CommonTitleBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
}
它本质上还是一个 ViewGroup。
但是和纯自定义 ViewGroup 不同的是:
组合控件通常不需要自己写复杂的 onMeasure() 和 onLayout()
因为 FrameLayout、LinearLayout、ConstraintLayout 已经帮我们处理了测量和布局。
3. 组合控件适合场景
4. 组合控件示例:标题栏
第一步:内部布局
res/layout/view_common_title_bar.xml
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<TextView
android:id="@+id/tvBack"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="返回" />
<TextView
android:id="@+id/tvTitle"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="标题"
android:textSize="18sp" />
<TextView
android:id="@+id/tvRight"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
android:gravity="center"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="更多" />
</merge>
这里使用 <merge> 是为了减少一层无意义的布局嵌套。
第二步:自定义属性
res/values/attrs.xml
<resources>
<declare-styleable name="CommonTitleBar">
<attr name="titleText" format="string" />
<attr name="rightText" format="string" />
</declare-styleable>
</resources>
第三步:自定义控件代码
class CommonTitleBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private val tvBack: TextView
private val tvTitle: TextView
private val tvRight: TextView
init {
LayoutInflater.from(context).inflate(
R.layout.view_common_title_bar,
this,
true
)
tvBack = findViewById(R.id.tvBack)
tvTitle = findViewById(R.id.tvTitle)
tvRight = findViewById(R.id.tvRight)
val typedArray = context.obtainStyledAttributes(
attrs,
R.styleable.CommonTitleBar
)
val titleText = typedArray.getString(
R.styleable.CommonTitleBar_titleText
)
val rightText = typedArray.getString(
R.styleable.CommonTitleBar_rightText
)
typedArray.recycle()
tvTitle.text = titleText ?: "标题"
tvRight.text = rightText ?: ""
}
fun setTitle(text: String) {
tvTitle.text = text
}
fun setRightText(text: String) {
tvRight.text = text
}
fun setOnBackClickListener(listener: OnClickListener) {
tvBack.setOnClickListener(listener)
}
fun setOnRightClickListener(listener: OnClickListener) {
tvRight.setOnClickListener(listener)
}
}
第四步:XML 中使用
<com.example.CommonTitleBar
android:id="@+id/titleBar"
android:layout_width="match_parent"
android:layout_height="48dp"
app:titleText="个人中心"
app:rightText="编辑" />
Activity 或 Fragment 中:
binding.titleBar.setOnBackClickListener {
finish()
}
binding.titleBar.setOnRightClickListener {
// 点击右侧按钮
}
七、自定义 View 的核心流程
Android View 的工作流程可以概括为三步:
measure -> layout -> draw
测量 布局 绘制
对应方法:
普通自定义 View 重点关注:
onMeasure()
onDraw()
onTouchEvent()
自定义 ViewGroup 重点关注:
onMeasure()
onLayout()
组合控件重点关注:
加载布局
读取属性
封装方法
处理事件
八、自定义 View 的生命周期
一个 View 大致会经历:
构造方法
↓
onAttachedToWindow()
↓
onMeasure()
↓
onSizeChanged()
↓
onLayout()
↓
onDraw()
↓
onDetachedFromWindow()
常见方法说明:
九、构造方法
自定义 View 常见写法:
class CustomView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
}
参数说明:
如果自定义 View 需要在 XML 中使用,一般要支持 AttributeSet 参数。
Kotlin 中常用 @JvmOverloads,方便生成多个构造方法。
十、onMeasure:测量尺寸
1. MeasureSpec 是什么?
onMeasure() 方法中会收到两个参数:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int)
这两个参数不是普通宽高,而是包含两部分信息:
MeasureSpec = 测量模式 + 测量大小
可以这样解析:
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
2. 三种测量模式
简单理解:
EXACTLY:父布局已经明确告诉你多大
AT_MOST:父布局给你一个最大值,你自己决定多大
UNSPECIFIED:父布局不限制你
3. 为什么要处理 wrap_content?
如果自定义 View 不重写 onMeasure(),那么 wrap_content 可能达不到预期效果。
因为系统不知道你的自定义 View 默认应该多大。
所以通常需要提供一个默认尺寸:
class CircleView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val defaultSize = 200
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val width = resolveSize(defaultSize, widthMeasureSpec)
val height = resolveSize(defaultSize, heightMeasureSpec)
setMeasuredDimension(width, height)
}
}
resolveSize() 会根据父布局传来的限制自动处理:
固定大小
match_parent
wrap_content
十一、onDraw:绘制内容
onDraw() 是自绘 View 的核心。
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawCircle(width / 2f, height / 2f, 100f, paint)
}
一般来说:
普通自绘 View:主要写 onDraw()
组合控件:通常不需要写 onDraw()
自定义 ViewGroup:如果需要自己绘制背景或分割线,才写 onDraw()
需要注意:
如果继承的是 ViewGroup,默认可能不会执行 onDraw()。
可以调用:
setWillNotDraw(false)
十二、自定义属性
自定义 View 经常需要支持 XML 属性。
例如:
<com.example.CircleProgressView
android:layout_width="120dp"
android:layout_height="120dp"
app:progress="60"
app:progressColor="@color/purple_500" />
1. 定义属性
res/values/attrs.xml
<resources>
<declare-styleable name="CircleProgressView">
<attr name="progress" format="integer" />
<attr name="progressColor" format="color" />
<attr name="progressWidth" format="dimension" />
</declare-styleable>
</resources>
2. 读取属性
class CircleProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var progress = 0
private var progressColor = Color.BLUE
private var progressWidth = 12f
init {
val typedArray = context.obtainStyledAttributes(
attrs,
R.styleable.CircleProgressView
)
progress = typedArray.getInt(
R.styleable.CircleProgressView_progress,
0
)
progressColor = typedArray.getColor(
R.styleable.CircleProgressView_progressColor,
Color.BLUE
)
progressWidth = typedArray.getDimension(
R.styleable.CircleProgressView_progressWidth,
12f
)
typedArray.recycle()
}
}
注意:
typedArray.recycle()
一定要调用。
十三、invalidate 和 requestLayout
这两个方法非常重要。
简单记忆:
样子变了,用 invalidate()
大小变了,用 requestLayout()
例如进度变化:
fun setProgress(value: Int) {
progress = value.coerceIn(0, 100)
invalidate()
}
如果是内容变化导致尺寸也可能变化:
fun setText(text: String) {
this.text = text
requestLayout()
}
子线程刷新
子线程中不要直接调用:
invalidate()
可以使用:
postInvalidate()
区别:
十四、触摸事件处理
自定义 View 可以通过 onTouchEvent() 处理触摸事件。
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
return true
}
MotionEvent.ACTION_MOVE -> {
val x = event.x
val y = event.y
// 处理移动
}
MotionEvent.ACTION_UP -> {
performClick()
}
MotionEvent.ACTION_CANCEL -> {
// 事件被取消
}
}
return true
}
override fun performClick(): Boolean {
super.performClick()
return true
}
常见事件:
注意:
如果 ACTION_DOWN 不返回 true,后续 MOVE 和 UP 可能收不到。
所以如果当前 View 要处理一整套触摸事件,通常要在 ACTION_DOWN 返回 true。
十五、完整示例:圆形进度条
1. attrs.xml
<resources>
<declare-styleable name="CircleProgressView">
<attr name="progress" format="integer" />
<attr name="progressColor" format="color" />
<attr name="backgroundCircleColor" format="color" />
<attr name="progressWidth" format="dimension" />
</declare-styleable>
</resources>
2. CircleProgressView.kt
class CircleProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var progress = 0
private var progressColor = Color.BLUE
private var backgroundCircleColor = Color.LTGRAY
private var progressWidth = 12f
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
private val rectF = RectF()
init {
val typedArray = context.obtainStyledAttributes(
attrs,
R.styleable.CircleProgressView
)
progress = typedArray.getInt(
R.styleable.CircleProgressView_progress,
0
)
progressColor = typedArray.getColor(
R.styleable.CircleProgressView_progressColor,
Color.BLUE
)
backgroundCircleColor = typedArray.getColor(
R.styleable.CircleProgressView_backgroundCircleColor,
Color.LTGRAY
)
progressWidth = typedArray.getDimension(
R.styleable.CircleProgressView_progressWidth,
12f
)
typedArray.recycle()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val defaultSize = dpToPx(120)
val width = resolveSize(defaultSize, widthMeasureSpec)
val height = resolveSize(defaultSize, heightMeasureSpec)
val size = minOf(width, height)
setMeasuredDimension(size, size)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val halfStroke = progressWidth / 2f
rectF.set(
halfStroke,
halfStroke,
width - halfStroke,
height - halfStroke
)
paint.strokeWidth = progressWidth
paint.color = backgroundCircleColor
canvas.drawArc(rectF, 0f, 360f, false, paint)
paint.color = progressColor
val sweepAngle = progress / 100f * 360f
canvas.drawArc(rectF, -90f, sweepAngle, false, paint)
}
fun setProgress(value: Int) {
progress = value.coerceIn(0, 100)
invalidate()
}
private fun dpToPx(dp: Int): Int {
return (dp * resources.displayMetrics.density + 0.5f).toInt()
}
}
3. XML 使用
<com.example.CircleProgressView
android:id="@+id/circleProgressView"
android:layout_width="120dp"
android:layout_height="120dp"
app:progress="70"
app:progressColor="@color/purple_500"
app:backgroundCircleColor="#DDDDDD"
app:progressWidth="10dp" />
十六、dp、sp、px 的区别
自定义 View 中最终绘制使用的是 px。
所以经常需要转换:
private fun dpToPx(dp: Float): Float {
return dp * resources.displayMetrics.density
}
private fun spToPx(sp: Float): Float {
return sp * resources.displayMetrics.scaledDensity
}
十七、状态保存
屏幕旋转、配置变化时,自定义 View 的状态可能丢失。
可以重写:
override fun onSaveInstanceState(): Parcelable {
val bundle = Bundle()
bundle.putParcelable("super_state", super.onSaveInstanceState())
bundle.putInt("progress", progress)
return bundle
}
override fun onRestoreInstanceState(state: Parcelable?) {
if (state is Bundle) {
progress = state.getInt("progress")
val superState = state.getParcelable<Parcelable>("super_state")
super.onRestoreInstanceState(superState)
} else {
super.onRestoreInstanceState(state)
}
}
适合保存:
进度
选中状态
展开状态
当前页码
用户输入内容
十八、性能优化
1. 不要在 onDraw 中频繁创建对象
不推荐:
override fun onDraw(canvas: Canvas) {
val paint = Paint()
val rectF = RectF()
}
推荐:
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val rectF = RectF()
override fun onDraw(canvas: Canvas) {
// 复用对象
}
因为 onDraw() 可能一秒执行很多次,频繁创建对象会导致 GC。
2. 复杂计算不要放在 onDraw 中
不推荐:
override fun onDraw(canvas: Canvas) {
// 大量路径计算
// Bitmap 解码
// 数据转换
}
推荐放到:
init
onSizeChanged
setData
3. 控制刷新范围
如果只需要刷新局部区域,可以使用:
invalidate(left, top, right, bottom)
而不是刷新整个 View。
4. 注意资源释放
如果自定义 View 内部有动画、Handler、线程、Bitmap 等资源,需要在 View 移除时释放。
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
// 停止动画
// 移除回调
// 释放资源
}
十九、常见问题
1. 为什么自定义 View 不显示?
常见原因:
2. 为什么 wrap_content 无效?
因为系统不知道你的自定义 View 默认应该多大。
解决方式是重写:
onMeasure()
并提供默认宽高。
3. 为什么 onDraw 不执行?
可能原因:
如果继承的是 ViewGroup,可以加:
setWillNotDraw(false)
4. invalidate 和 requestLayout 怎么选?
简单记忆:
样子变了,用 invalidate()
大小变了,用 requestLayout()
二十、学习路线
建议按这个顺序学习自定义 View:
1. View 坐标系
2. Canvas 和 Paint
3. onDraw 绘制
4. onMeasure 测量
5. 自定义属性
6. invalidate 和 requestLayout
7. 触摸事件
8. 组合控件
9. 自定义 ViewGroup
10. 动画结合自定义 View
11. 性能优化
二十一、总结
Android 自定义 View 不是只有 Canvas 绘制。
完整来看,自定义 View 主要有四种方式:
1. 继承已有 View
2. 继承 View 自己绘制
3. 继承 ViewGroup 自己布局
4. 组合多个已有控件
它们的选择原则是:
小增强:继承已有 View
复杂图形:继承 View 自绘
特殊布局:继承 ViewGroup
业务组件复用:组合控件
View 的核心流程是:
measure -> layout -> draw
测量 布局 绘制
一句话总结:
自定义 View 的重点不是只会画图,而是理解 Android 如何测量、布局、绘制和分发事件。掌握了这条主线,再结合不同的自定义方式,就能根据业务场景选择最合适的实现方案。
Android 自定义View
https://lautung.com/archives/t7CwODpn