Android 自定义 View:精通文字的测量与高级排版

文字居中

我们来看文字的测量。绘制文字只需使用 drawText() 方法,但我们很难将文字摆放精准。

静态文本

比如我们有以下代码:

kotlin 复制代码
class SportView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)

    companion object {
        // 圆环的半径
        val RING_RADIUS = 150.dp

        // 圆环的宽度
        val RING_WIDTH = 10.dp

        // 圆环的颜色
        val RING_COLOR = Color.parseColor("#90A4AE")

        // 圆环高亮颜色
        val RING_HIGHLIGHT_COLOR = Color.parseColor("#FF4081")
    }

    override fun onDraw(canvas: Canvas) {
        // 绘制圆环
        paint.strokeWidth = RING_WIDTH
        paint.style = Paint.Style.STROKE
        paint.color = RING_COLOR
        canvas.drawCircle(width / 2f, height / 2f, RING_RADIUS, paint)

        // 绘制高亮圆环
        paint.strokeCap = Paint.Cap.ROUND // 圆环两端为圆形
        paint.color = RING_HIGHLIGHT_COLOR
        canvas.drawArc(
            width / 2f - RING_RADIUS,
            height / 2f - RING_RADIUS,
            width / 2f + RING_RADIUS,
            height / 2f + RING_RADIUS,
            -90f,
            225f,
            false,
            paint
        )
    }

}

运行效果:

现在,我们要绘制 "Edge" 文字到圆环的中心,你可能会这样做:

kotlin 复制代码
override fun onDraw(canvas: Canvas) {
    ...

    // 绘制文字
    paint.style = Paint.Style.FILL
    paint.textAlign = Paint.Align.CENTER // 文字居中
    paint.textSize = 100.dp // 文字大小
    // 设置字体
    paint.typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)
    canvas.drawText("Edge", width / 2f, height / 2f, paint)
}

其中,设置文字的大小,我们并没有使用 sp。这是因为 sp 同时受屏幕像素密度系统的文字大小设置的影响,它常用于用户界面的可读文本上。

但在当前场景中,我们不希望用户能够调整字体大小(可能过大,压到环上),所以我们使用了 dp 作为文字的单位,它只会受到屏幕像素密度的影响。

但实际运行效果却是:

文字在纵向上看并不居中,而是靠上,为什么呢?

因为文字的坐标点并不是位于文本的中心,它是由文字对齐方式 以及文字基线 (Baseline) 决定的。

首先在垂直方向上,坐标点的 y 轴坐标一直在文字基线上,如图:

在水平方向上,坐标点的 x 轴坐标根据文字对齐方式变化:

所以在文字居中的情况下,文字坐标点是下图中蓝色小点,会位于圆环的中心位置,所以文本在垂直方向上,会位于圆环中心偏上。

又比如,我们将文字的对齐方式改为居左,那么文字坐标点会位于文本的左下角,效果如图:

那么我们该如何让文字纵向居中于圆环?

其实只需手动给文字添加一个纵向偏移即可,偏移量为文字的垂直中心的相当于基线的距离 。我们可以通过 getTextBounds() 方法获取文字绘制的区域边界,来得到这个偏移量。
bounds.top 是文本顶部相对于基线的距离(通常为负数),bounds.bottom 是文本底部相对于基线的距离(通常为正数)。

代码如下:

kotlin 复制代码
class SportView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    ...

    // 文字的边界
    private val bounds = Rect()

    override fun onDraw(canvas: Canvas) {
        
        ...
        
        // 获取文字的边界
        paint.getTextBounds("Edge", 0, "Edge".length, bounds)
        // 绘制文字
        canvas.drawText("Edge", width / 2f, height / 2f - (bounds.bottom + bounds.top) / 2f, paint)
    }

}

现在,我们就真正实现了文本的垂直居中效果。

不过这种方式,只适用于静态文本。对于动态文本,可能会出现文字抖动或是跳动的情况。因为文字变化时,其边界可能会变化,这样会导致文本的坐标点变化,使得文本的位置改变。

动态文本

对于动态文本,我们会使用 Ascent 和 Descent 的中心作为我们的文本坐标点,因为它们是固定的,不会随着文本内容的变化而变化,它们圈定了文本的核心部分。

具体含义可参考 meaning-of-top-ascent-baseline-descent-bottom-and-leading-in-androids-font

修改后的代码:

kotlin 复制代码
private val metrics = Paint.FontMetrics()

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

    // 获取字体的度量信息
    paint.getFontMetrics(metrics)
    canvas.drawText(
        "Edge",
        width / 2f,
        height / 2f - (metrics.ascent + metrics.descent) / 2f,
        paint
    )
}

运行效果:

可以看到,即使文本内容发生了变化,文本的垂直位置也保持稳定,解决了跳动问题。

文字的贴边

文字的贴边共有四种,分别是:左贴边、右贴边、上贴边、下贴边。我们只需了解左贴边和上贴边就行,因为原理都是相通的。

左贴边

我们先来看左贴边。对于左贴边,你可能会这样做:

kotlin 复制代码
override fun onDraw(canvas: Canvas) {
    // 绘制文字 
    paint.textAlign = Paint.Align.LEFT // 居左对齐
    paint.textSize = 100.dp
    paint.color = RING_HIGHLIGHT_COLOR
    canvas.drawText(
        "Edge",
        0f,
        height / 2f,
        paint
    )
}

运行效果:

其实这样已经可以了,但当两行文本的字体大小不同时,就会出现问题:

kotlin 复制代码
// 文字的边界
private val bounds = Rect()

override fun onDraw(canvas: Canvas) {

    // 绘制文字
    paint.textAlign = Paint.Align.LEFT
    paint.textSize = 15.dp
    paint.color = RING_HIGHLIGHT_COLOR
    canvas.drawText(
        "Edge",
        0f,
        height / 2f,
        paint
    )


    // 绘制文字
    paint.textAlign = Paint.Align.LEFT
    paint.textSize = 100.dp
    paint.color = RING_HIGHLIGHT_COLOR
    paint.getTextBounds("Edge", 0, "Edge".length, bounds)
    canvas.drawText(
        "Edge",
        0f,
        height / 2f - bounds.top,
        paint
    )
}

运行效果:

你会发现字体较大的文本左侧有一个空隙,其实这是字体中规定每个字的左右间隙,要把左侧的这个间隙消除,就需要给文本一个偏移量。

kotlin 复制代码
override fun onDraw(canvas: Canvas) {
    // 绘制文字
    paint.textAlign = Paint.Align.LEFT
    paint.textSize = 15.dp
    paint.color = RING_HIGHLIGHT_COLOR
    paint.getTextBounds("Edge", 0, "Edge".length, bounds)
    canvas.drawText(
        "Edge",
        -bounds.left.toFloat(), // 左侧偏移
        height / 2f,
        paint
    )


    // 绘制文字
    paint.textAlign = Paint.Align.LEFT
    paint.textSize = 100.dp
    paint.color = RING_HIGHLIGHT_COLOR
    paint.getTextBounds("Edge", 0, "Edge".length, bounds)
    canvas.drawText(
        "Edge",
        -bounds.left.toFloat(),  // 左侧偏移
        height / 2f - bounds.top,
        paint
    )
}

运行效果:

这样,两行文本的左侧就实现了精准的贴边效果。虽然仔细看还剩下一丝缝隙,但这通常是字体渲染引擎和字体文件本身的微小差异,通过代码层面是几乎无法完全消除的。

当然,我们可以针对特定的字体和字号,手动减去一个偏移量,来实现视觉上的绝对贴合。

顶部贴边

再就是顶部贴边,只需加上文字 top 线到 baseline 基线的距离即可。

kotlin 复制代码
private val metrics = Paint.FontMetrics()

override fun onDraw(canvas: Canvas) {
    // 绘制文字
    paint.textAlign = Paint.Align.CENTER
    paint.textSize = 100.dp
    paint.color = RING_HIGHLIGHT_COLOR
    paint.getFontMetrics(metrics)
    canvas.drawText(
        "Edge",
        width / 2f,
        0f - metrics.top,
        paint
    )
}

运行效果:

虽说和顶部有一点距离,但这是正常的间隙。

当然你还可以将偏移量改为文字 ascent 线到 baseline 基线的距离,或是文字边界 bounds.top 到 baseline 基线的距离,来进一步减小空隙。

不过如果使用 ascent 线,可能会导致某些文字显示不全。

kotlin 复制代码
// 字体的度量信息
private val metrics = Paint.FontMetrics()

override fun onDraw(canvas: Canvas) {
    // 绘制文字
    paint.textAlign = Paint.Align.CENTER
    paint.textSize = 100.dp
    paint.color = RING_HIGHLIGHT_COLOR
    paint.getFontMetrics(metrics)
    canvas.drawText(
        "Edge",
        width / 2f,
        0f - metrics.ascent,
        paint
    )
}
kotlin 复制代码
// 文字的边界
private val bounds = Rect()

override fun onDraw(canvas: Canvas) {
    // 绘制文字
    paint.textAlign = Paint.Align.CENTER
    paint.textSize = 100.dp
    paint.color = RING_HIGHLIGHT_COLOR
    paint.getTextBounds("Edge", 0, "Edge".length, bounds)
    canvas.drawText(
        "Edge",
        width / 2f,
        0f - bounds.top,
        paint
    )
}

多行绘制

最后是多行绘制,如果只是绘制多行文本,只需这样:

kotlin 复制代码
class MultilineTextView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    companion object {
        // 测试文本
        const val TEXT =
            "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id laoreet ante, a elementum libero. Donec fermentum pharetra libero, et rhoncus mauris lacinia et. Sed feugiat arcu orci. Maecenas vulputate orci lacus, sit amet hendrerit dolor egestas sed. Duis molestie rutrum ante aliquam pharetra. Suspendisse in arcu facilisis, dictum nibh nec, condimentum nulla. Curabitur rhoncus tellus a purus eleifend, sed porttitor sem ultricies. Proin rutrum a nunc at blandit. Pellentesque aliquet faucibus diam, at consequat nunc feugiat quis. Nam auctor ultricies ante, pharetra ultrices libero blandit ut. Maecenas vel consequat est. Aliquam porttitor risus nisl, vitae tempor nibh malesuada condimentum. Sed sed sagittis magna, sed tempus velit. Quisque ligula nisi, pellentesque quis interdum ut, accumsan vel lorem. Cras laoreet laoreet quam vitae interdum."

        // 文字大小
        val TEXT_SIZE = 16.dp
    }

    private val textPaint by lazy {
        TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
            textSize = TEXT_SIZE
        }
    }

    override fun onDraw(canvas: Canvas) {
        // 创建StaticLayout
        val staticLayout = StaticLayout.Builder
            .obtain(
                TEXT,
                0,
                TEXT.length,
                textPaint,
                width // 文字绘制的最大宽度
            ).setAlignment(Layout.Alignment.ALIGN_NORMAL) // 文字对齐方式
            .build()
        // 绘制StaticLayout
        staticLayout.draw(canvas)
    }
}

文本是通过 Lorem Ipsum 网站生成的。

运行效果:

但我们往往需要控制每行文字的绘制,也就是文字的排版。就比如往文本中贴了一张图片,我们要让文本绕过图片。

这时就需要通过 Canvas.drawText()Paint.breakText() 方法来控制每一行文本的绘制。

先绘制一张图片,代码如下:

kotlin 复制代码
class MultilineTextView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    companion object {
        // 测试文本
        const val TEXT =
            "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id laoreet ante, a elementum libero. Donec fermentum pharetra libero, et rhoncus mauris lacinia et. Sed feugiat arcu orci. Maecenas vulputate orci lacus, sit amet hendrerit dolor egestas sed. Duis molestie rutrum ante aliquam pharetra. Suspendisse in arcu facilisis, dictum nibh nec, condimentum nulla. Curabitur rhoncus tellus a purus eleifend, sed porttitor sem ultricies. Proin rutrum a nunc at blandit. Pellentesque aliquet faucibus diam, at consequat nunc feugiat quis. Nam auctor ultricies ante, pharetra ultrices libero blandit ut. Maecenas vel consequat est. Aliquam porttitor risus nisl, vitae tempor nibh malesuada condimentum. Sed sed sagittis magna, sed tempus velit. Quisque ligula nisi, pellentesque quis interdum ut, accumsan vel lorem. Cras laoreet laoreet quam vitae interdum."

        // 文字大小
        val TEXT_SIZE = 16.dp

        // 图片宽度,也是图片高度
        val IMAGE_WIDTH = 120.dp

        // 图片顶部
        val IMAGE_TOP = 60.dp
    }


    private val bitmap by lazy {
        getAvatar(R.drawable.avatar, IMAGE_WIDTH.toInt())
    }

    private val paint by lazy {
        Paint(Paint.ANTI_ALIAS_FLAG).apply {
            textSize = TEXT_SIZE
        }
    }

    override fun onDraw(canvas: Canvas) {
        // 绘制图片
        canvas.drawBitmap(bitmap, width - IMAGE_WIDTH, IMAGE_TOP, paint)
    }


    private fun getAvatar(@DrawableRes imageId: Int, targetWidth: Int): Bitmap {
        val options = BitmapFactory.Options()
        options.inJustDecodeBounds = true
        BitmapFactory.decodeResource(resources, imageId, options)
        options.inJustDecodeBounds = false
        options.inDensity = options.outWidth
        options.inTargetDensity = targetWidth
        return BitmapFactory.decodeResource(resources, imageId, options)
    }
}

运行效果:

然后,先尝试绘制两行文字,我们可以通过 breakText() 方法获取在指定的宽度下,能够绘制的文本长度。

kotlin 复制代码
override fun onDraw(canvas: Canvas) {
    ...

    // 文本绘制到一行
//        canvas.drawText(TEXT, 0f, 0f - paint.fontMetrics.top, paint)
    // 绘制第一行
    // 注意,breakText的第三个参数end是前闭后开的,所以应传入字符串长度
    val count = paint.breakText(
        TEXT,
        0,
        TEXT.length,
        true, // 是否测量剩余文本 (向前测量)
        width.toFloat(),  // 可用宽度
        null // 测量结果
    )
    canvas.drawText(TEXT, 0, count, 0f, -paint.fontMetrics.top, paint)

    val countSecond = paint.breakText(
        TEXT,
        count,
        TEXT.length,
        true,
        width.toFloat(),
        null
    )
    // 绘制第二行,paint.fontSpacing 是行间距
    canvas.drawText(
        TEXT,
        count,
        count + countSecond,
        0f,
        -paint.fontMetrics.top + paint.fontSpacing,
        paint
    )
}

breakText 的最后一个参数 measuredWidth 可以接收一个 FloatArray,可获取测量文本的精确像素宽度。

运行效果:

最后,我们考虑避让图片。只要当前行的文本底部位于图片顶部下方,并且文本顶部位于图片的底部上方,我们就认为当前行需要避让图片,也就是减小文字的可用宽度。

代码如下:

kotlin 复制代码
companion object {
    ...

    // 图片高度
    val IMAGE_HEIGHT = 120.dp
}

override fun onDraw(canvas: Canvas) {
    // 绘制图片
    canvas.drawBitmap(bitmap, width - IMAGE_WIDTH, IMAGE_TOP, paint)

    paint.textAlign = Paint.Align.LEFT

    var start = 0
    var count: Int
    var offsetY = -paint.fontMetrics.top
    var maxWidth: Float 

    while (start < TEXT.length) {
        // 判断当前行是否与图片区域重叠
        val lineTop = offsetY + paint.fontMetrics.top
        val lineBottom = offsetY + paint.fontMetrics.bottom

        maxWidth =
            if (lineBottom < IMAGE_TOP || lineTop > IMAGE_TOP + IMAGE_HEIGHT) {
                // 当前行与图片不重叠,可以使用整行宽度
                width.toFloat()
            } else {
                // 当前行与图片重叠,需要为图片留出空间
                width.toFloat() - IMAGE_WIDTH
            }

        count = paint.breakText(
            TEXT,
            start,
            TEXT.length,
            true,
            maxWidth,
            null
        )

        canvas.drawText(TEXT, start, start + count, 0f, offsetY, paint)

        // 换行
        start += count
        offsetY += paint.fontSpacing
    }
}

运行效果:

相关推荐
东京老树根1 小时前
Android - 用Scrcpy 将手机投屏到Windows电脑上
android
Wgllss2 小时前
完整烟花效果,Compose + 协程 + Flow + Channel 轻松实现
android·架构·android jetpack
扛麻袋的少年2 小时前
6.Kotlin的Duration类
android·开发语言·kotlin
独自破碎E2 小时前
得物25年春招-安卓部分笔试题1
android
Jasonakeke3 小时前
【重学MySQL】八十八、8.0版本核心新特性全解析
android·数据库·mysql
一条上岸小咸鱼5 小时前
Kotlin 类型检查与转换
android·kotlin
闲暇部落6 小时前
android studio配置 build
android·android studio·build
_祝你今天愉快7 小时前
Android FrameWork - Zygote 启动流程分析
android
龙之叶8 小时前
Android系统模块编译调试与Ninja使用指南
android