自定义 view - 绘制文字

timg.jpeg

canvas 种绘制文字一向是个难题,关于文字大小,轮廓,宽高的问题更是涉及到好几个到API,尤其是我们要绘制多行文字时更是如此,老实说我看到这几个到API后都是懵的,找些好些资料,然后自己测试不同文字打印数据才搞明白,大家跟着我一起来吧,希望能把下面这几个到API讲明白,也让大家不在想我一样糊度~

FontMetrics 文字模型


也可以叫矩阵 FontMetrics,我们给 paint 画笔设置完文字大小就可以获取到这个文字矩阵 对象,文字绘制的基础全部依赖于此,我们首先搞懂这个 FontMetrics 才是王道喵喵喵

FontMetrics 模型图:这是我能找到的最好的图了


图1
图2

这2张图一起看才行哦~

FontMetrics 文字绘制模型涉及到5个概念:

  • top
  • bottom
  • ascent
  • descent
  • baseline

咱们来一个个的说,说的明白了,之后就好做了,就不会再迷糊了

  1. baseline

baseline 也叫基准线,是文字绘制的基准,文字以文字中心内容为核心以baseline为起点进行居中对齐,典型的就是带圈的小写字母了,大家从上面的图中能看出端倪。文字剩下的部分,有的向上占据空间,比如 i,有的向下占据空间,比如 j 。canvas 绘制文字时就是以baseline 的值作为文字 Y坐标的基准

  1. ascent 和 descent

ascent 和 descent 是成对来说的,ascent 叫文字的上坡度,descent 叫文字的下坡度,绘制文字据对不会超出 ascent - descent 的范围,上标,下标,音标除外

文字占据 ascent - descent 空间的情况分3种:

  • aI
    典型小写字母,只会占据 baseline - ascent 的部分空间
  • A
    典型大写字母,会占据 baseline - ascent 的全部空间,也就是撑满从 baseline - ascent
  • jg
    向下占据空间的典型字母,会像 a 一样占据部分 baseline - ascent 的空间,但是向下绘制的部分会占据部分 baseline - descent 的空间,向下最多的如 j 是会占据全部 baseline - descent 的空间

  • 中文基本会填满 ascent - descent 的空间,但是又不会 100% 填满,上下会多少流出一些空隙
  1. top - bottom

top-ascent 叫上标,bottom-descent 叫下标,一般绘制文字不会占这块的空间,但是上标,下标,音标会占用这块空间,好比上图中的罗马字符。top-ascent 和 bottom-descent 上下2块的空间除了绘制特殊部分,基本是作为文字上下分割空间存在的

  1. FontMetrics 的坐标值

我们从画笔 paint 可以获取的矩阵中的值,baseline 处为0,向上为负数,向下为正数,这里要清楚


textsize = 50px

基本上 FontMetrics 文字矩阵就是这样了,大家明白这几条是干啥的就好了~

绘制文字涉及到的 API


这个是重点了啦,大伙都是在这里迷糊的,下面所列 API 功能相近,容易混

// canvas 绘制文字
public void drawText (String text, float x, float y, Paint paint)

// 获取文字矩阵和其中的参数
val fontMetrics = paint.fontMetrics
val top = fontMetrics.top.toInt()
val bottom = fontMetrics.bottom.toInt()
val ascent = fontMetrics.ascent.toInt()
val descent = fontMetrics.descent.toInt()
val leading = fontMetrics.leading.toInt()

// 获取文字占据的大小
paint.getTextBounds(text.toString(), 0, text.length, rect)

// 测量文字的宽度
val measureWidth = paint.measureText(text.toString())

// 获取指定宽度下可以绘制的字符数
var text3: String = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
val breakText = mPaint.breakText(text3, true, 600f, null)

// textview 获取行高
text_name.lineHeight

// paint 画笔获取行高
paint.getFontMetrics(fontMetrics)
paint.getFontMetricsInt(paint.getFontMetricsInt())
paint.getFontSpacing()

// textview 设置行间距
android:lineSpacingExtra="10px"
android:lineSpacingMultiplier="1.5"

// StaticLayout 绘制多行文字辅助类,可以实现在指定宽度限制下绘制多行文字,文字可以实动实现换行
var staticLayout = StaticLayout(text, mPaint, width, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, false)
staticLayout.draw(canvas)

这几个 API ok 了,之后自定义 view 绘制文字部分基本平趟儿~

getTextBounds 获取的是哪块尺寸


好多人对 getTextBounds 这个方法有误解,认为 getTextBounds 获取的就是文字的大小,其实不然 getTextBounds 获取的是文字的轮廓。

什么是轮廓,就是真实大小,不是 FontMetrics 某段尺寸,是真实的文字占据多大就是多少。前提说过不同类型的字符占据的 FontMetrics 区域是不一样的,那么 getTextBounds 返回的大小也是不一样的。

我们来测试几个不同的字符,看看相关的数据都是怎么样的:


字符:a
字符:A

这里选2个字符:a 、 A ,很明显能看出 getTextBounds 获取的文字大小是不同的

所以我们使用 getTextBounds 要注意场合,不同的字符获取到的此存是不一样的,有其是我们要是用 getTextBounds 计算行高的时候获取的数据肯定是不对的。

Textsize 设置的是哪部分的值


texeview 的 setTextSize() 方法会指到 paint 的 setTextSize() 方法,最终会调用本地 c 的方法,从代码上我们看不出我们给文字设置完大小指的是 FontMetrics 的哪里,那么我们从结果触发了

我们打印几个不同文字尺寸的 FontMetrics 看看:


18px
25px
35px
45px

从结果上看:textsize 的大小 = baseline 到 top 和 ascent 的中间。我说之前我量的文字大小总是小一点呢,所以大伙在量文字大小之后加一点大小才准确

获取文字宽度


FontMetrics 里面没有设计文字宽度,这个很正常,大家想啊,FontMetrics 是单个字符标准,实际文字字数不固定,FontMetrics 当然表示不了了

那我们怎么获取文字宽度呢,有2个 Paint 的 API 可供选择:

  • measureText
  • getTextBounds

Paint 的这2个方法都可以获取指定数量字符的宽度,getTextBounds 获取的是文字轮廓,measureText 是专门计算文字宽度的,从结果上来看 measureText 获取的结果比 getTextBounds 要多 1-2 个像素,关于这点我找到一个图:


cYnF6.png

图中红线是 getTextBounds 获取的部分,粉线是 measureText 获取的部分,自然 measureText 获取的数据要多一些,这算是留边吧,实际上我看所有人都推荐用 measureText

textview 中的行高


为啥我会写 textview 呢,是因为cavans 绘制文字时我不知道应该以哪个作为标准行高,是 top - bottom,还是 ascent - descent ,所以去 textview 那瞧瞧

获取行高的 API 有4个:

// textview 获取行高
text_name.lineHeight

// paint 画笔获取行高
paint.getFontMetrics(fontMetrics)
paint.getFontMetricsInt(paint.getFontMetricsInt())
paint.getFontSpacing()

paint 的3个方法,getFontMetricsInt 获取的数据最准确,textview 的 getLineHeight 内部调的也是 getFontMetricsInt 这个方法

textview getLineHeight 有这个获取行高的 API ,我跟进去,源码里面太复杂,没看出来是获取的 FontMetrics 的哪块值。

然后我还是从结果入手,打印了几次结果搞清楚了,先说下结果:

  1. textview 即使在 padding = 0时,依然在上留了 top - ascent ,在下留了 descent - bottom 大小的边距
textsize = 20px
textsize = 28px

打印文字2个不同大小时的矩阵信息和 view 大小,从结果能看出来 view 的 height - LineHeight = ( top - ascent)+ (descent - bottom)基本相等,这里用的是绝对值。

  1. textview 的 LineHeight = ascent - bottom 的距离

上面的图就不行了,还是相同的文字大小,我们从单行增加到 3行:


textsize = 20px
textsize = 28px

文字大小是 20px 时,view 的高是 76,view 默认的边距是 28-24=4 ,实际内容高度是 72 ,除以3 = 24 ,正好是 LineHeight 的大小

文字大小我们再换成 28px,view 的高是 104,view 默认的边距是 38-33=5 ,实际内容高度是 99 ,除以3 = 33 ,正好是 LineHeight 的大小

为啥不包含 ascent - top 这块上标的高度呢,我估计是上标一般也用不到,而且看数值上标大小也不小了,既然用不到就不要了,下标 descent - bottom 的大小不大,正好作为每行默认的行间距

有其得到启发,我们 cavans 绘制文字时还是学习 textview 以 ascent - bottom 的大小为行高是最合适的

textview 的行间距


textview 有2个参数可以设置行间距:

android:lineSpacingExtra="10px"
android:lineSpacingMultiplier="1.5"
  • lineSpacingExtra 设置的行间距的绝度数值,默认 = 0
  • lineSpacingMultiplier 设置的是行间距的倍数,默认 = 1.0

上文提到的 textview 的行高计算方式都是在没有设置行间距时的算法,那么这2个值我们要是设置之后行间距怎么算呢,公式就是下面这个啦:

LineHeight = LineHeight(原来的行间距) * lineSpacingMultiplier 倍数+ lineSpacingExtra

那么我们不用 textview 用 canvas 绘制文字时,行间距就根据我们自己的习惯来吧,因为推荐使用 textview 的行高计算方式,所以我们绘制出来的文字默认是带一些行间距的,如果需要我们可以加一个行间距的偏移量进来即可

canvas 单行文字居中


终于到重头戏了,就是我们用 canvas 画文字时最常见的居中问题,先来单行的,再来多行的。

Snip20181009_22.png

看图例,我画了一个示意图,红线是 view 中心,黄线是文字的 baseline 基线,在文字居中时,基线的情况就是这样子的,我们算的就是 baseline 基线相对 view 中心线的偏移量

要算这个值我们是离不开 FontMetrics 的

FontMetrics

首先别忘了 FontMetrics 中 baseline 为0,top 和 ascent 的值都是负数,这里我们取 ascent - descent 的距离为行高,因为文字不会超过这个范围的

因为文字要居中,所以 view 的中心线也就是文字的中心线,也就是行高的一半 = ( - ascent + descent )/ 2 。中心线到 baseline 距离 = ascent 的高度 - 行高的一半 = - ascent - ( - ascent + descent )/ 2

最后计算下得到最后的公式:

-ascent / 2 - descent / 2

代码如下:

class MyTextview : View {

    var mPaint: TextPaint = TextPaint()

    constructor(context: Context?) : super(context)
    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)

    init {
        mPaint.textSize = 58f
        mPaint.isAntiAlias = true
        mPaint.strokeWidth = 1f
    }

    override fun onDraw(canvas: Canvas?) {

        canvas?.drawColor(Color.BLUE)

        var text: String = "AAAgggg我我哦我我我我我"

        var drawTextX: Float = 0f
        var drawTextY: Float = 0f
        
        // 拿到文字的宽度
        val textWidth = getTextWidth(text, mPaint)
        //  baseline x 坐标
        drawTextX = (width / 2 - textWidth / 2)
        
        // baseline y 坐标 = view 的中心 + baseline 的偏移量
        drawTextY = (height / 2 + calculateBaselineOffsetY(mPaint))

        mPaint.color = Color.WHITE
        canvas?.drawText(text, 0, text.length, drawTextX, drawTextY, mPaint)

        // 绘制2条参考线
        mPaint.color = Color.RED
        canvas?.drawLine(0f, (height / 2).toFloat(), width.toFloat(), (height / 2).toFloat(), mPaint)

        mPaint.color = Color.YELLOW
        canvas?.drawLine(0f, drawTextY, width.toFloat(), drawTextY, mPaint)

    }

    // 拿到文字的宽度
    fun getTextWidth(text: String, paint: Paint): Float {

        if (TextUtils.isEmpty(text)) {
            return 0f
        }
        return paint.measureText(text)
    }

    // 计算 baseline 的相对文字中心的偏移量
    fun calculateBaselineOffsetY(paint: Paint): Float {

        val fontMetrics = paint.fontMetrics
        val ascent = fontMetrics.ascent
        val descent = fontMetrics.descent

        return -ascent / 2 - descent / 2
    }

}

canvas 多行文字居中


多行和单行计算起来没什么太大区别,这里我来个简单点的,之后会写一个模拟 textview 的例子,那里会复杂一些

这里绘制3行同样的文字,每行起点的 X 坐标是相同的。我使用 ascent - bottom 的距离做为一行的高度,这样第二行第三行绘制时 Y 坐标就是在前一行的基础上加上个行高就行了

那么重点就是计算第一行的绘制坐标了,X 坐标不用说了,Y 坐标的计算也不难,这里我们知道了一行的高度,一共有几行,那么我们就可以计算出文字总得高度

var textlist = arrayListOf<String>("AAAgggg我我哦我我我我我", "AAAgggg我我哦我我我我我", "AAAgggg我我哦我我我我我")

    var totalHeight = getLineHeight(mPaint) * textlist.size

    fun getLineHeight(paint: Paint): Float {

        val fontMetrics = paint.fontMetrics
        val ascent = fontMetrics.ascent
        val bottom = fontMetrics.bottom

        return Math.abs(ascent) + Math.abs(bottom)
    }

然后我们可以计算出文字顶部的 Y 坐标,那么所有文字居中时 Y 坐标就出来了

var centerY = height / 2 - getLineHeight(mPaint) * textlist.size / 2

然后我们再算第一行文字 baseline 的偏移量也就好算了,baseline 距离每行最顶部有 ascent 的距离,这里我们再加上一个 ascent 的偏移量就是绘制第一行文字 Y 的起始坐标

var  fristTextStartY = height / 2 - getLineHeight(mPaint) * textlist.size / 2 + getFontMetricsAscent(mPaint)

这个不难写,因为这里有几行,每行文字分割,每行文字 X 轴起始坐标都不用考虑,只算 Y 的值,也是尽量简单点,好理解嘛,之后有个例子会复杂一些,把上述参数考虑进去

代码如下:

class MyTextview3 : View {

    var mPaint: TextPaint = TextPaint()

    constructor(context: Context?) : super(context)
    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)

    init {
        mPaint.textSize = 50f
        mPaint.isAntiAlias = true
        mPaint.strokeWidth = 1f

    }

    override fun onDraw(canvas: Canvas?) {

        canvas?.drawColor(Color.BLUE)

        var textlist = arrayListOf<String>("AAAgggg我我哦我我我我我", "AAAgggg我我哦我我我我我", "AAAgggg我我哦我我我我我")
        var drawTextX: Float = 0f
        var drawTextY: Float = 0f

        // 计算第一行文字绘制的起始位置
        drawTextX = (width / 2 - getTextWidth(textlist[0], mPaint) / 2)
        drawTextY = (height / 2 - getLineHeight(mPaint) * textlist.size / 2 + getFontMetricsAscent(mPaint))

        // 绘制 view 中心线
        mPaint.color = Color.RED
        canvas?.drawLine(0f, (height / 2).toFloat(), width.toFloat(), (height / 2).toFloat(), mPaint)

        // 绘制文字
        mPaint.color = Color.WHITE
        for ((index, text) in textlist.withIndex()) {
            // 绘制每行文字时,添加 Y 轴向下偏移量 = 自身行数-1的行高,第一行是0,所以没有偏移量,或者大家在循环里自己一行一行的填也可以
            canvas?.drawText(text, 0, text.length, drawTextX, drawTextY + index * getLineHeight(mPaint), mPaint)
        }

    }

    fun getTextWidth(text: String, paint: Paint): Float {

        if (TextUtils.isEmpty(text)) {
            return 0f
        }
        return paint.measureText(text)
    }

    fun getLineHeight(paint: Paint): Float {

        val fontMetrics = paint.fontMetrics
        val ascent = fontMetrics.ascent
        val bottom = fontMetrics.bottom

        return Math.abs(ascent) + Math.abs(bottom)
    }

    fun getFontMetricsAscent(paint: Paint): Float {
        return Math.abs( paint.fontMetrics.ascent )
    }

}

breakText


var text3: String = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
val breakText = mPaint.breakText(text3, true, 600f, null)

这个方法让我们设置一个最大宽度在不超过这个宽度的范围内返回实际测量值否则停止测量,参数很多但是都很好理解,text表示我们的字符串,measureForwards表示向前还是向后测量,maxWidth表示一个给定的最大宽度在这个宽度内能测量出几个字符,measuredWidth为一个可选项,可以为空,不为空时返回真实的测量值

这个方法在一些结合文本处理的应用里比较常用,比如文本阅读器的翻页效果,我们需要在翻页的时候动态折断或生成一行字符串,这就派上用场了~~~

StaticLayout


系统中有一个StaticLayout方法,可以在设置宽度,当前行文本超过此宽度后,进行自动换行,提供ALIGN_CENTER(居中)、ALIGN_NORMAL(标准)、ALIGN_OPPOSITE(与标准相反)三种对齐方式。

StaticLayout 的使用不难,看下面的 API 介绍就 OK ~

        var text = "AAAAAAA"
        var width: Int = 150
        var staticLayout = StaticLayout(text, mPaint, width, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, false)
        staticLayout.draw(canvas)

        // 可以获取文字在指定宽度限制下所需空间
        staticLayout.width
        staticLayout.height

        // 若是使用 StaticLayout 绘制文字居中,可以通过移动 canvas 实现文字位置的变化
        canvas?.save()
        canvas?.translate(staticLayout.getWidth() / 2.toFloat(), staticLayout.getHeight() / 2.toFloat());
        staticLayout.draw(canvas)
        canvas?.restore()

文字方面的自定义 view 例子

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容