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

相关推荐
2501_916007471 小时前
前端开发常用软件与工具全面指南
android·ios·小程序·https·uni-app·iphone·webview
赏金术士2 小时前
Android Tinker 热修复集成与使用指南 1.9.15.2
android·热修复·tinker
2603_954138393 小时前
安卓误删文件先别慌!5个实用小技巧指南教你补救
android·智能手机
波诺波4 小时前
5-SOFA可变形的3D物体 5-elasticity.scn
android
2501_915909066 小时前
iOS应用性能优化:十大策略提升用户体验与开发效率
android·ios·小程序·https·uni-app·iphone·webview
sun0077006 小时前
打通android全链路,网卡驱动, 内核 , 到上层hal, framework
android
awu的Android笔记6 小时前
Android VpnService:如何把所有流量导入用户态
android
plainGeekDev7 小时前
AlertDialog → DialogFragment
android·java·kotlin
流星白龙7 小时前
【MySQL高阶】13.其他存储引擎
android·数据库·mysql