Android 自定义View:详解尺寸测量 onMeasure

前言

我们经常会遇到原生控件无法满足需求的时候,比如需要一个严格正方形的头像,又或是需要一个能根据内部元素自适应大小的控件。这时,我们就可以通过自定义 View 来解决。

自定义 View 的布局过程通常可分为三种:

  • 继承已有的 View,通过重写 onMeasure 方法简单修改尺寸。

  • 同样是重写 onMeasure 方法,但完全自定义 View 的尺寸计算。

  • 通过重写 onMeasureonLayout 方法,自定义 ViewGroup。

我们现在只讲前两种较为简单的情况。

第一种:修改已有尺寸

这种方式最简单,也最直接,它的流程为:

  1. 重写 onMeasure 方法;

  2. 调用 super.onMeasure() 完成默认测量,通过 getMeasuredWidthgetMeasuredHeight 方法获取到父类计算出的初步测量结果。

  3. 根据测量结果,计算出最终的实际尺寸,调用 setMeasuredDimension 方法把最终结果保存下来。

现在,我们就来实现一个 SquareImageView,它会使得图片总是以正方形显示。

首先,创建 SquareImageView,继承自 AppCompatImageView,并重写 onMeasure 方法。

kotlin 复制代码
class SquareImageView(context: Context, attrs: AttributeSet?) : AppCompatImageView(context, attrs) {
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 让父类测量,获取初步测量结果
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        
    }
}

根据测量结果,计算出最终尺寸并保存。

kotlin 复制代码
class SquareImageView(context: Context, attrs: AttributeSet?) : AppCompatImageView(context, attrs) {
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        // 宽高较小值作为边长
        val size = min(measuredWidth, measuredHeight)

        // 保存最终尺寸
        setMeasuredDimension(size, size)
    }
}

在布局中使用,并将宽高设为不一致。

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.example.customview.SquareImageView
        android:layout_width="400dp"
        android:layout_height="300dp"
        android:scaleType="centerCrop"
        android:src="@drawable/avatar" />

</FrameLayout>

运行效果如下:

可以看到,即使布局要求是 400x300,SquareImageView 仍然是一个正方形。

第二种:完全自定义尺寸

再来看看第二种,它的流程为:

  1. 重写 onMeasure 方法。

  2. 根据内部的元素,计算出自己期望的尺寸。

  3. 使用 resolveSizeresolveSizeAndState 方法,将结果修正。

  4. 使用 setMeasuredDimension(width, height) 方法保存结果。

我们来创建一个 CircleView,它的尺寸由内部的圆以及边距决定。先重写 onDraw 方法绘制一个圆形:

kotlin 复制代码
val Float.dp: Float
    get() = TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_DIP,
        this,
        Resources.getSystem().displayMetrics
    )

class CircleView(
    context: Context,
    attrs: AttributeSet? = null,
) : View(context, attrs) {

    private val myPaint = Paint(Paint.ANTI_ALIAS_FLAG)

    private val circleRadius = 75f.dp
    private val circleColor = Color.parseColor("#FF4081")

    // 圆的边距
    private val circlePadding = 50f.dp

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        myPaint.color = circleColor
        canvas.drawCircle(
            circlePadding + circleRadius,
            circlePadding + circleRadius,
            circleRadius,
            myPaint
        )
    }
}

在布局中使用:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.example.customview.CircleView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#2196F3" />

</FrameLayout>

运行效果为:
为什么 wrap_content 会占满屏幕?

因为 View 的默认 onMeasure 方法实现,并不知道如何处理 wrap_content,当父 View 让它自己决定大小时,它只会变为父 View 允许的最大尺寸,所以看起来像是 match_parent

我们的目标是让 CircleView 的尺寸能够包住圆,并且带有边距。

kotlin 复制代码
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // 计算期望尺寸
    val desiredSize = (circlePadding * 2 + circleRadius * 2).toInt()

    // 保存最终尺寸
    setMeasuredDimension(desiredSize, desiredSize)
}

运行效果:

这样看起来好像没问题,但其实它忽略了开发者传入的尺寸要求。

我们来解析父 View 传来的 MeasureSpec

MeasureSpec 包括了模式以及尺寸,模式有三种:

  • EXACTLY: 精确值,对应 match_parent 或是具体的尺寸值。
  • AT_MOST: 最大值,对应 wrap_content。
  • UNSPECIFIED: 不限制。

我们可以通过 MeasureSpec.getModeMeasureSpec.getSize 方法来获取这些信息,然后手动处理逻辑。

kotlin 复制代码
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // 计算在 wrap_content 时期望的默认尺寸
    val desiredWidth = (circlePadding * 2 + circleRadius * 2)
    val desiredHeight = (circlePadding * 2 + circleRadius * 2)

    // 获取父 View 的约束
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)

    // 根据不同的模式,计算最终的宽高
    val finalWidth = when (widthMode) {
        MeasureSpec.EXACTLY -> {
            // 父 View 要求了精确尺寸,我们必须遵守
            widthSize
        }

        MeasureSpec.AT_MOST -> {
            // 父 View 给了一个最大值,我们不能超过它
            min(desiredWidth.toInt(), widthSize)
        }

        else -> { // UNSPECIFIED
            // 父 View 不作限制,我们用自己的期望尺寸
            desiredWidth.toInt()
        }
    }

    val finalHeight = when (heightMode) {
        MeasureSpec.EXACTLY -> {
            heightSize
        }

        MeasureSpec.AT_MOST -> {
            min(desiredHeight.toInt(), heightSize)
        }

        else -> {
            desiredHeight.toInt()
        }
    }

    // 保存最终结果
    setMeasuredDimension(finalWidth, finalHeight)
}

其实这部分代码都是固定的。为此,Android 给我们提供了 resolveSize() 方法,封装了上述 when 代码块逻辑判断,我们只需这样即可:

kotlin 复制代码
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // 计算期望的尺寸
    val desiredSize = (circlePadding * 2 + circleRadius * 2).toInt()

    // 使用 resolveSize 自动结合父View的约束,得出最终尺寸
    val width = resolveSize(desiredSize, widthMeasureSpec)
    val height = resolveSize(desiredSize, heightMeasureSpec)

    // 保存最终尺寸
    setMeasuredDimension(width, height)
}

再来看效果,非常符合预期。

  1. 在宽高都为 wrap_content 的情况下,尺寸刚好包住内容。

  2. 在宽为 300dp,高为 200dp 的情况下,能够遵守精确尺寸。

前面我们还提到了 resolveSizeAndState,它的源码为:

java 复制代码
public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {
    // ...
    switch (specMode) {
        case MeasureSpec.AT_MOST:
            if (specSize < size) {
                result = specSize | MEASURED_STATE_TOO_SMALL; // 🔎
            } else {
                result = size;
            }
            break;
        // ...
    }
    return result | (childMeasuredState & MEASURED_STATE_MASK);
}

在空间不够时,它会加上一个 MEASURED_STATE_TOO_SMALL 状态标记。

这个标记可以被父 ViewGroup 拿到,从而稍后可以以更大的尺寸重新测量当前 View。但遗憾的是,连很多官方的 ViewGroup 都没有实现这个逻辑,所以一般用不上这个方法,通常使用 resolveSize 方法就行。

相关推荐
CYRUS_STUDIO21 小时前
一步步带你移植 FART 到 Android 10,实现自动化脱壳
android·java·逆向
CYRUS_STUDIO21 小时前
FART 主动调用组件深度解析:破解 ART 下函数抽取壳的终极武器
android·java·逆向
蓝倾9761 天前
淘宝/天猫店铺商品搜索API(taobao.item_search_shop)返回值详解
android·大数据·开发语言·python·开放api接口·淘宝开放平台
Propeller1 天前
【Android】LayoutInflater 控件实例化的桥梁类
android
国家二级编程爱好者1 天前
Android开机广播是有序还是无序?广播耗时原因是什么?
android
猿小蔡-Cool1 天前
Robolectric如何启动一个Activity
android·单元测试·rebolectric
Industio_触觉智能1 天前
瑞芯微RK3576开发板Android14三屏异显开发教程
android·开发板·瑞芯微·rk3576·多屏异显·rk3576j·三屏异显
AI视觉网奇1 天前
android adb调试 鸿蒙
android
NRatel1 天前
GooglePlay支付接入记录
android·游戏·unity·支付·googleplay
在下历飞雨1 天前
为啥选了Kuikly?2025“液态玻璃时代“六大跨端框架横向对比
android·harmonyos