目录
[二、示例 1:自定义 View(onMeasure + onDraw)](#二、示例 1:自定义 View(onMeasure + onDraw))
[三、示例 2:自定义 ViewGroup(onMeasure + onLayout)](#三、示例 2:自定义 ViewGroup(onMeasure + onLayout))
一、三个方法的分工
| 方法 | 所属阶段 | 核心职责 | 谁需要重写 |
|---|---|---|---|
onMeasure |
测量 | 决定 View 的宽高(处理 wrap_content/match_parent/具体值) |
View / ViewGroup 都需要 |
onLayout |
布局 | 决定子 View 的摆放位置(左上角坐标) | 只有 ViewGroup 需要 |
onDraw |
绘制 | 把内容画到屏幕上(Canvas + Paint) |
调用顺序: onMeasure → onLayout → onDraw
二、示例 1:自定义 View(onMeasure + onDraw)
实现一个带文字的圆形进度条 ,重点解决 wrap_content 时默认占满全屏的问题。
Kotlin
class CircleProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#4CAF50")
strokeWidth = 20f
style = Paint.Style.STROKE // 空心圆环
strokeCap = Paint.Cap.ROUND // 圆角线帽
}
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#E0E0E0")
strokeWidth = 20f
style = Paint.Style.STROKE
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.DKGRAY
textSize = 48f
textAlign = Paint.Align.CENTER // 文字以坐标点为中心对齐
}
var progress: Int = 65
set(value) {
field = value.coerceIn(0, 100)
invalidate() // 进度变化时重绘
}
// ==================== 1. 测量阶段 ====================
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
// 默认内容尺寸:200dp(处理 wrap_content)
val defaultSize = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
).toInt()
// 根据模式计算最终宽高
val width = when (widthMode) {
MeasureSpec.EXACTLY -> widthSize // match_parent 或具体值
MeasureSpec.AT_MOST -> min(defaultSize, widthSize) // wrap_content
else -> defaultSize // UNSPECIFIED
}
val height = when (heightMode) {
MeasureSpec.EXACTLY -> heightSize
MeasureSpec.AT_MOST -> min(defaultSize, heightSize)
else -> defaultSize
}
// 保持正方形,取较小边
val size = min(width, height)
setMeasuredDimension(size, size)
}
// ==================== 2. 绘制阶段 ====================
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val centerX = width / 2f
val centerY = height / 2f
// 半径要减去线宽的一半,防止边缘被截断
val radius = (width / 2f) - progressPaint.strokeWidth
// 1. 画背景圆环
canvas.drawCircle(centerX, centerY, radius, bgPaint)
// 2. 画进度圆弧(从顶部开始,顺时针)
val sweepAngle = (progress / 100f) * 360f
canvas.drawArc(
centerX - radius, centerY - radius,
centerX + radius, centerY + radius,
-90f, sweepAngle, false, progressPaint
)
// 3. 画进度文字(基线居中技巧)
val textY = centerY - (textPaint.descent() + textPaint.ascent()) / 2
canvas.drawText("$progress%", centerX, textY, textPaint)
}
}
onMeasure部分:
MeasureSpec.getMode()返回三种模式:
EXACTLY:父容器给定了精确尺寸(如match_parent或100dp)
AT_MOST:子 View 不能超过这个上限(即wrap_content)
UNSPECIFIED:父容器不限制(如 ScrollView 内部)
setMeasuredDimension()必须调用,否则抛异常。这里把宽高设为相同值,保证是正圆。
onDraw部分:
canvas.drawArc()参数是矩形边界,不是圆心半径。这里构造一个以centerX/Y为中心的正方形边界。
textPaint.ascent()是负值(文字顶部到基线),descent()是正值(基线到底部),两者取平均后能让文字视觉居中。
三、示例 2:自定义 ViewGroup(onMeasure + onLayout)
实现一个简单的流式布局(FlowLayout),子 View 按顺序排列,宽度不够自动换行。
Kotlin
class FlowLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
// 记录每一行的子 View 和行高
private val allLines = mutableListOf<List<View>>()
private val lineHeights = mutableListOf<Int>()
// ==================== 1. 测量阶段:测量所有子 View,计算自身尺寸 ====================
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
allLines.clear()
lineHeights.clear()
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
// 可用宽度(减去左右 padding)
val availableWidth = widthSize - paddingLeft - paddingRight
var currentLineWidth = 0
var currentLineHeight = 0
val currentLineViews = mutableListOf<View>()
var totalWidth = 0 // 所有行中最宽的
var totalHeight = 0 // 所有行高度累加
for (i in 0 until childCount) {
val child = getChildAt(i)
// 测量子 View(考虑 margin)
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, totalHeight)
val childWidth = child.measuredWidth + getMarginHorizontal(child)
val childHeight = child.measuredHeight + getMarginVertical(child)
// 判断是否需要换行
if (currentLineWidth + childWidth > availableWidth && currentLineViews.isNotEmpty()) {
// 保存当前行
allLines.add(currentLineViews.toList())
lineHeights.add(currentLineHeight)
totalWidth = max(totalWidth, currentLineWidth)
totalHeight += currentLineHeight
// 开启新行
currentLineViews.clear()
currentLineWidth = 0
currentLineHeight = 0
}
currentLineViews.add(child)
currentLineWidth += childWidth
currentLineHeight = max(currentLineHeight, childHeight)
}
// 处理最后一行
if (currentLineViews.isNotEmpty()) {
allLines.add(currentLineViews.toList())
lineHeights.add(currentLineHeight)
totalWidth = max(totalWidth, currentLineWidth)
totalHeight += currentLineHeight
}
// 加上 padding
totalWidth += paddingLeft + paddingRight
totalHeight += paddingTop + paddingBottom
// 根据 MeasureSpec 模式确定最终尺寸
val finalWidth = when (widthMode) {
MeasureSpec.EXACTLY -> widthSize
MeasureSpec.AT_MOST -> min(totalWidth, widthSize)
else -> totalWidth
}
val finalHeight = when (heightMode) {
MeasureSpec.EXACTLY -> heightSize
MeasureSpec.AT_MOST -> min(totalHeight, heightSize)
else -> totalHeight
}
setMeasuredDimension(finalWidth, finalHeight)
}
// ==================== 2. 布局阶段:给每个子 View 分配位置 ====================
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var currentTop = paddingTop
var currentLeft: Int
var currentBottom: Int
for ((lineIndex, lineViews) in allLines.withIndex()) {
val lineHeight = lineHeights[lineIndex]
currentLeft = paddingLeft
currentBottom = currentTop + lineHeight
for (child in lineViews) {
val lp = child.layoutParams as MarginLayoutParams
val childLeft = currentLeft + lp.leftMargin
val childTop = currentTop + lp.topMargin
// 摆放子 View:left, top, right, bottom
child.layout(
childLeft,
childTop,
childLeft + child.measuredWidth,
childTop + child.measuredHeight
)
currentLeft += child.measuredWidth + lp.leftMargin + lp.rightMargin
}
// 换行:top 下移
currentTop += lineHeight
}
}
// 支持 MarginLayoutParams
override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
return MarginLayoutParams(context, attrs)
}
override fun generateDefaultLayoutParams(): LayoutParams {
return MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
}
// 辅助方法:获取子 View 的水平/垂直 margin
private fun getMarginHorizontal(child: View): Int {
val lp = child.layoutParams as? MarginLayoutParams ?: return 0
return lp.leftMargin + lp.rightMargin
}
private fun getMarginVertical(child: View): Int {
val lp = child.layoutParams as? MarginLayoutParams ?: return 0
return lp.topMargin + lp.bottomMargin
}
}
onMeasure 方法(测量阶段)
这个方法的作用是计算 FlowLayout 自身需要多大的空间。
- 遍历所有子 View
- 判断是否需要换行:如果当前行放不下,就换到下一行
- 记录每行的信息:
allLines:每一行有哪些 View
lineHeights:每一行的高度
计算总尺寸:
- totalWidth:所有行中最宽的那一行的宽度
- totalHeight:所有行高度累加
简单说:onMeasure 是在规划布局,算出需要多大地方。
onLayout 方法(布局阶段)
这个方法的作用是给每个子 View 安排具体位置。
- 从第一行开始,currentTop 表示当前行的顶部位置
- 从左到右摆放每个 View:
- childLeft:这个 View 距离左边的距离
- childTop:这个 View 距离顶部的距离
- 调用 child.layout() 真正摆放它
更新位置:
- 摆完一个 View,currentLeft 向右移动
- 摆完一行,currentTop 向下移动
简单说:onLayout 是真正动手把每个 View 放到正确的位置。