Android自定义 View 三核心方法详解

目录

一、三个方法的分工

[二、示例 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)

调用顺序: onMeasureonLayoutonDraw


二、示例 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_parent100dp

    • 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 放到正确的位置。

相关推荐
爱勇宝18 小时前
我做了一个只用来搜歌词的小 App
android·前端·后端
众少成多积小致巨21 小时前
JNI (Java Native Interface) 技术手册中文参考指南
android·java·c++
Coffeeee1 天前
如何使用Glide和Coil加载WebP动图
android·kotlin·glide
Kapaseker1 天前
5 分钟搞懂 Kotlin DSL
android·kotlin
恋猫de小郭1 天前
AI Agent 开发究竟是啥?如何用 AI 开发 Agent ?深入浅出给你一套概念
android·前端·ai编程
黄林晴1 天前
Android 17 正式发布!target 37 一大批旧代码直接不能用了
android
Carson带你学Android1 天前
Android 17 正式发布:AI 终于成了系统能力
android·前端·ai编程
三少爷的鞋1 天前
当 UseCase 开始长期监听,它可能已经不是 UseCase 了
android
恋猫de小郭2 天前
Android 限制侧载新进展,谷歌联合国内厂商推验证计划
android·前端·flutter
恋猫de小郭2 天前
解读 Android 17 全新内存限制,有没有“豁免”后门?
android·前端·flutter