1. Paint.getTextBounds 测量文本宽高

val text = "Hello, Mango !"
val bounds = Rect()
paint.getTextBounds(text, 0, text.length, bounds)
val textWidth = bounds.width()  // 获取文本的宽度
val textHeight = bounds.height() // 获取文本的高度
// 确定文本的垂直边界
val textTop = bounds.top  
val textBottom = bounds.bottom

//得到 text 的 宽 高 上 下 为:199 , 31, -24, 7

  • 用来精确计算给定文本的矩形边界,返回的是文本的最小矩形框(即 Rect 对象)。

  • getTextBounds() 不仅仅测量文本的宽度,还包括了高度(即文本在垂直方向的大小)。

参数:

  • text: 要测量的文本字符串。
  • start: 字符串的起始索引(通常是 0)。
  • end: 字符串的结束索引(通常是 text.length)。
  • bounds: 一个 Rect 对象,用来接收测量后的矩形边界结果。

适用场景:

  • 想知道文本的上、下、左、右边界
  • 复杂的对齐需求(例如多个文本行的布局)
  • 考虑文本的垂直对齐、边界检测等场景

缺点getTextBounds() 返回的 Rect 是基于实际字符内容的最小矩形框,它只测量文字的实际占用空间,但这个矩形可能不会考虑字体的全部空间,尤其是在处理字符的上升部分和下降部分时。

  • bounds.topbounds.bottom 仅限于当前文本的实际字符范围,而不会包含字体的完整高度。
  • 对于像 “g” 或 “y” 这样的字母,getTextBounds()bounds.height() 可能无法涵盖字符的下降部分,因为它只关心文本的显示部分,不会测量到字体的潜在最大高度(例如字体设计中保留的额外空间),因此在某些情况下会裁剪字符的部分内容(特别是下方的部分)。

实际显示效果:

2. Paint.measureText 测量文本宽度

val text = "Hello, Mango !"
val textWidth = paint.measureText(text)

Paint.measureText() ,返回文本的宽度(以像素为单位), 且是float,可以直接用于水平布局或绘制。

Tip:

  • measureText() 返回的宽度是文本所占据的水平空间,包含了每个字符的宽度和字符之间的间距。
  • 它只返回宽度,不返回高度。如果你需要获取文本的垂直尺寸(高度),则需要使用 Paint.getTextBounds()Paint.FontMetrics
  • 主要用于水平布局或对齐时,只关心文本在水平方向上占据多少空间。

measureText 和 getTextBounds的区别

  • getTextBounds的区别 返回的是文字的实际宽度
  • measureText 包含字符更多的额外空间

3. Paint.fontMetrics 测量文本高度

val fontMetrics = paint.fontMetrics  
val bottom = fontMetrics.bottom  
val top = fontMetrics.top  
val ascent = fontMetrics.ascent  
val descent = fontMetrics.descent  
val leading = fontMetrics.leading   // additional space to add between lines of text.

val textHeight = fontMetrics.bottom - fontMetrics.top

fontMetrics 用于获取字体的垂直尺寸信息(如 ascent、descent、leading 等),方便我们计算文本的总高度,以及处理文本基线对齐问题。

通过 fontMetrics,你可以得到文本的完整高度,并考虑到字体的所有垂直偏移信息。

Paint.FontMetrics 是基于字体基线(baseline)来计算文本的高度,它可以考虑到字体的所有元素,包括上升部分(ascenders)下降部分(descenders),例如字母 “g”、”y” 等带有下划线部分的字符。

参数详解

fontMetrics.top

字体中最上面的部分相对于基线的距离。表示字体最上方的那部分位置。

负值baseline 为坐标轴,top 在baseline 方,所以为负。

它表示的是 top 到 baseline 的距离

fontMetrics.bottom

字体的最底部,包含所有字母的下划部分(例如 “g”、”y” 的尾巴)。

正值baseline 为坐标轴,top 在baseline 方,所以为负。

它表示的是 bottom 到 baseline 的距离

fontMetrics.ascent

负值 字形中字符的顶部到基线的距离,表示字符上升的高度。

fontMetrics.descent

正值 字形中字符的底部到基线的距离,表示字符下降的高度。

4. baseline 的计算方法

什么是 baseline ?

基线是文字的绘制参考线,通过基线调整,可以让文字在垂直方向上正确对齐。

在 Android 的 FontMetrics 中,top 是负值,bottom 是正值,这是因为它们的参考点是 基线(baseline),而字体的垂直布局是相对于基线来进行计算的。

字体的垂直布局是以基线为中心的。

文字布局的垂直坐标系

我们可以把它想象成一个二维坐标系:

  • 基线(baseline)是 Y=0 的位置,也就是垂直方向上的原点。
  • 向上 的坐标是负的,表示字符顶部(ascenttop)距离基线多远。
  • 向下 的坐标是正的,表示字符底部(descentbottom)距离基线多远。

如何计算文字高度?

topbottom 是字体在垂直方向上的极限值,分别代表了字体在垂直方向上的最高和最低位置, 因此:

 textHeight = | top | + | bottom |    // 因为 top 是负值,所以去掉绝对值符号后
		=  - top + bottom
		= bottom - top

bottom - top 代表了整个文字的实际高度,包括所有可能的上下空间(如字母 “g” 的下垂部分或字母 “h” 的上伸部分)。

为什么不使用 ascent 和 descent ?

  • ascentdescent 代表的是主要字符部分的高度,但不包括一些上部或者下部的额外空间(例如字母“g”或“p”的下垂部分,或者字体可能有的上方装饰部分)。
  • 使用 topbottom 可以确保你为整个字体的任何部分都预留了足够的空间,避免文字在垂直方向被裁切。

计算 baseline

在自定义 View 中绘制文字时,文字的绘制位置是从 基线(baseline) 开始的,而不是从文字的顶部或底部。因此,如果想让文字垂直居中,必须要先考虑基线的位置,再根据字体的高度来调整绘制的起点。

计算文字在自定义 View 中垂直居中的 Y 坐标,使得文字看起来位于 View 的中间:

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

	val fontMetrics = paint.fontMetrics  
	val bottom = fontMetrics.bottom  
	val top = fontMetrics.top
	val textHeight = bottom - top
	  
	val baseline = (height - textHeight) / 2 - top   

	canvas.drawText(text, 0f, baseline, paint)
}

height 是自定义 View 的高度,由父布局决定 它不是坐标值,而是View 从顶部到底部的垂直尺寸,单位是像素,是在布局时由父布局传递给子 View 的 在这里的 heightonMeasure() 里修改后的高度,也就是布局高度,在这里 布局高度 = 文字高度。

(height - textHeight) / 2 表示将多余的空白区域均匀分布在文字的上下两端,使文字垂直居中。

- top view上方到text的空白 + text 顶部到基线的距离,即 | top | ,就是 基线(baseline)的 Y 坐标

baseline 是一个坐标值,为了确定文字相对于 View 的垂直居中位置

让MyTextView显示在屏幕中间

在代码中设置Gravity

val textView = MyTextView(this, null).apply {  
    setText("Hello, Mango !")  
    setTextSize(32f)  
    setBackgroundColor(Color.GRAY)  
  
    val params = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)  
    params.gravity = Gravity.CENTER  
    layoutParams = params  // 设置 MyTextView 居中  
}

将 MyTextView 设置为居中显示,无论 MyTextView 在屏幕的哪个位置,它的文本的坐标起始点都是那个文本Rect的左上角
因为我们在OnMeasure()里重新绘制了MyTextView的宽高,所以它只会是文本宽高大小
无论在代码中用LayoutParams怎么移动MyTextView,它的drawText起始点都是 0f, baseline

是否需要调用 super.onMeasure()?

super.onMeasure(widthMeasureSpec, heightMeasureSpec)
  • 如果你需要完全自定义测量逻辑,比如在 CustomTextView 中根据文本的内容动态决定视图的宽高,并且你已经在 onMeasure() 方法内完成了宽高的计算(通过 setMeasuredDimension()来设置视图的测量宽高,已经足够告诉系统你希望视图的尺寸是多少),那么就不需要再调用 super.onMeasure()。这样,你的视图将根据你自己定义的测量逻辑来确定大小。

  • 如果你的自定义视图依赖父类的一些测量逻辑(例如某些复杂视图继承自 ViewGroup 或其他带有默认测量行为的控件),你可能需要调用 super.onMeasure() 来确保继承自父类的默认测量行为被执行。

在你的示例中,你已经完全控制了视图的测量逻辑,动态计算文本的宽高并设置给视图,因此不再需要调用 super.onMeasure()

示例:动态根本文本内容控制宽高

class CustomTextView @JvmOverloads constructor(
    context: Context, 
    attrs: AttributeSet? = null, 
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    private var customText: String = "Hello, Custom TextView!"
    private var customTextColor: Int = Color.BLACK
    private var customTextSize: Float = 50f
    private val paint = Paint()

    init {
        paint.color = customTextColor
        paint.textSize = customTextSize
        paint.isAntiAlias = true
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 1. 获取文本宽高
        val textWidth = paint.measureText(customText)
        val fontMetrics = paint.fontMetrics
        val textHeight = fontMetrics.bottom - fontMetrics.top

        // 2. 获取宽高模式
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)

        // 3. 默认的宽高
        var desiredWidth = textWidth.toInt() + paddingLeft + paddingRight
        var desiredHeight = textHeight.toInt() + paddingTop + paddingBottom

        // 4. 根据模式处理宽高测量逻辑
        val width = when (widthMode) {
            MeasureSpec.EXACTLY -> MeasureSpec.getSize(widthMeasureSpec) // 父布局要求精确大小
            MeasureSpec.AT_MOST -> minOf(MeasureSpec.getSize(widthMeasureSpec), desiredWidth) // 父布局设置了最大值,我们取较小的那个
            MeasureSpec.UNSPECIFIED -> desiredWidth // 没有限制时使用测量的文本宽度
            else -> desiredWidth
        }

        val height = when (heightMode) {
            MeasureSpec.EXACTLY -> MeasureSpec.getSize(heightMeasureSpec) // 父布局要求精确大小
            MeasureSpec.AT_MOST -> minOf(MeasureSpec.getSize(heightMeasureSpec), desiredHeight) // 父布局设置了最大值,我们取较小的那个
            MeasureSpec.UNSPECIFIED -> desiredHeight // 没有限制时使用测量的文本高度
            else -> desiredHeight
        }

        // 5. 设置最终测量的宽高
        setMeasuredDimension(width, height)
    }

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

        // 计算绘制文本的位置
        val x = (width - paint.measureText(customText)) / 2
        val y = (height / 2) - (paint.descent() + paint.ascent()) / 2

        // 绘制文本
        canvas.drawText(customText, x, y, paint)
    }
}

typography

Serif : 衬线,图1红色部分 Sans SerifSans没有 的意思,即 font without serif Tracking :字符之间的空间 Kerning :字距调整