自定义 view 练手 - 自定义可换行的 textview

项目初衷


自定义 view 呢我是打算写几个练手的,想了想,第一个自定义 view 的练手还是实现一个可换行的简单 textview 为好

刚接触自定义 view 的同学一定会头疼于 view 的测量和绘制,绘制是个复杂的事,但是测量才是初学者们首先要玩顺溜的

我们来再来看看经典的自定义 view 测量写法:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);

    // 获取宽的测量模式
    int wSpecMode = MeasureSpec.getMode(widthMeasureSpec); 
    // 获取符控件提供的 view 宽的最大值
    int wSpecSize = MeasureSpec.getSize(widthMeasureSpec);

    int hSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    int hSpecSize = MeasureSpec.getSize(heightMeasureSpec);

    if (wSpecMode == MeasureSpec.AT_MOST && hSpecMode == MeasureSpec.AT_MOST) {
        setMeasuredDimension(300, 300);
    } else if (wSpecMode == MeasureSpec.AT_MOST) {
        setMeasuredDimension(300, hSpecSize);
    } else if (hSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(wSpecSize, 300);
    }
}

自定义view 测量的难点就是如何处理 warpConten 的情况,在面临 warpConten 时我们如果不做处理那么 view 的宽高就是 matchParent 的,但是不是所有情况我们都能这么粗暴的解决的,有时我们自定义的 view 必要要能处理 warpConten

何为处理 warpConten ,就是在 warpConten 时我们根据需要绘制的内容,计算所需的宽高大小,然后返回通知该自定义view

所以我在这里准备了一个 自定义的 textview 给大家找找感觉,另外也是熟练下 canvas 绘制文字,我认为这是练习自定义view,提高熟练度最好的开始了

我的目标是让萌新们跟着我一起快快乐乐,简简单单的熟悉自定义 view,希望大家多点赞,点个喜欢,关注,github 给个 start 啥的,多谢大家啦,么么哒 ~~

项目地址:BW_Libs

CustomeTextView 思路


这里我们不需要做的很复杂和 textview 一样,我们的目标是实现一个能处理 warpConten ,能实现文字换行,正确显示文字的 view 即可

首先说明一下,不同的字符占用的宽度是不同的,a < A < 我,所以中英文混合的字符串,每行能够显示的文字数量是不同的,大家可以用 mPaint.measureText() 方法自己去试试

在 view 中我们如何做到文字的自动换行,这点其实不复杂,我们根据 view 的最大宽度,计算出 view 每一行能容纳的字符数,然后依次绘制出每一行的文字即可。以前我把这块想的可复杂了,以为有黑科技在里面,但是试过之后才知道,不难嘛 ~ 想的太复杂可不是好事啊

那么我们正式开始讲解思路啦:

1. 处理 warpContent

面临 warpConten ,我们需要根据设置的文字,算出所需要的宽高。这里我们要借助 mPaint.measureText(text) 这个 API

宽好算,我们可以拿到 view 所能获取到的最大宽度 maxWdith,然后用 mPaint.measureText 计算出传入文字的宽度 textWidth

  • textWidth < maxWdith
    说明文字不足一行,我们以文字所需的宽度 textWidth 为 view 的宽度即可
  • textWidth = maxWdith
    说明文字正好一行,view 的最大宽度 maxWdith 就是 view 所需的宽度
  • textWidth > maxWdith
    说明文字一行显示不下,有多行,这时view 所需的宽度就是 view 的最大宽度 maxWdith 了
    /**
     * 计算 view 所需宽度,view 的宽是 warpContent 时需要处理
     */
    fun calculateWidth(width: Int): Int {
        val measureWidth = mPaint.measureText(mText)
        return if (measureWidth >= width) width else measuredWidth
    }

高度其实也好算,我们只要知道了文字绘制的行数 * 每行文字的高度,就是 view 所需的高度了

    /**
     * 计算 view 总共的高度,view 的高是 warpContent 时需要处理
     */
    fun calculateHeight(width: Int): Int {

        val measureWidth = mPaint.measureText(mText).toInt()
        if (measureWidth <= width) {
            return mLineHeight.toInt()
        }
        return (mlinesNumber * mLineHeight).toInt()
    }

整个 view 的测量方法如下:

    /**
     * 计算 view 大小
     */
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        var widthSize = MeasureSpec.getSize(widthMeasureSpec)

        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        var heightSize = MeasureSpec.getSize(heightMeasureSpec)

        // 当 view 的宽高是 warpContent 时,根据文字计算 view 所需大小
        if (widthMode == MeasureSpec.AT_MOST) {
            widthSize = calculateWidth(widthSize)
        }

        if (heightMode == MeasureSpec.AT_MOST) {
            heightSize = calculateHeight(widthSize)
        }

        // 计算文字分成几行
        calculateLines(widthSize)
        // 设置 view 的大小
        setMeasuredDimension(widthSize, heightSize)
    }
2. 绘制文字

这里的难点是我们把文字分割成一行一行的,上面我们知道中英文字符占用的宽度是不一样的,view 每一样能显示的字符数是不固定的,这里我们需要动态计算出每一行能显示的文字数,然后根据这个字符数截取字符串,最后再一行行的去绘制

计算每一行最大字符数:

    /**
     * 根据传入的文字,获取一行最多能显示的字符数
     */
    fun getSigleLineTextNumber(text: String, width: Int, centerTextNum: Int): Int {

        // 判断是不是最后一行,最后一行返回字符串长度
        if (text.length <= centerTextNum || mPaint.measureText(text) < width) {
            return text.length
        }

        var index = centerTextNum
        while (true) {
            // 从每行文字的中间数开始,一个字符的一个字符的增加文字测量数,一直到超过或等于指定宽度时,就是 view 每行能显示文字的字数
            val measureWidth = mPaint.measureText(text.substring(0, index) + 0.5f).toInt()
            if (measureWidth > width) {
                return index - 1
                break
            }
            if (measureWidth == width) {
                return index
                break
            }

            index++
        }
    }

分割字符串成一行一行:

    /**
     * 分割文字成一行一行的
     * 为了减少计算量,我们算下每行文字数量的平均数,从这个平均数开始比对
     */
    fun splitText() {

        var centerTextNum = mText.length / mlinesNumber
        var text: String = mText
        while (true) {
            // 先获取每行文字的数量
            val sigleLineTextNumber = getSigleLineTextNumber(text, width, centerTextNum)
            // 然后根据这个数量裁剪文字,把这行文字取出来,
            val lineText = text.substring(0, sigleLineTextNumber)
            // 把取出的每行文字存入集合
            textList.add(lineText)
            // 然后把取出的这行文字从源文字中删除,以便接下来的计算
            text = text.substring(sigleLineTextNumber, text.length)
            if (text.isEmpty()) break
        }
    }

恩,这里大家看注释就行,分割字符串的思路可能绕一点,但是没啥问题,这里我的代码没有经过修饰整理,看着不是非常好,大家谅解

所有代码如下:

device-2018-10-15-012012.png
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent" android:layout_height="match_parent"
    tools:context="com.bloodcrown.bw.customeview.CustomeTextviewActivity">

    <com.bloodcrown.bw.customeview.CustomeTextView
        android:layout_width="800px"
        android:layout_height="wrap_content"
        android:text="A1111111111111111B2222222222222222C333333333333333D444444444444444E55555555555555555F66666666666666"
        android:textSize="18sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

</android.support.constraint.ConstraintLayout>
    <declare-styleable name="CustomeTextView">
        <attr name="android:textSize" tools:ignore="ResourceName"></attr>
        <attr name="android:text" tools:ignore="ResourceName"></attr>
    </declare-styleable>
class CustomeTextView : View {

    var mPaint = TextPaint()
    var mText = ""
    // 行高
    var mLineHeight: Float = 0f
    // 文字拆分行数
    var mlinesNumber: Int = 0
    // 存储每行文字集合
    var textList = arrayListOf<String>()

    @JvmOverloads
    constructor(context: Context, attributeSet: AttributeSet? = null, defAttrStyle: Int = 0)
            : super(context, attributeSet, defAttrStyle) {

        // 初始化画笔
        initPaint()
        // 初始化各种自定义参数
        initAttrs(context, attributeSet, defAttrStyle)
        // 计算行高
        mLineHeight = calculateLineHeight()
    }

    /**
     * 初始化画笔
     */
    fun initPaint() {
        mPaint.color = Color.BLACK
        mPaint.strokeWidth = 1f
        mPaint.style = Paint.Style.FILL
        mPaint.isAntiAlias = true
    }

    /**
     * 初始化各种自定义参数
     */
    private fun initAttrs(context: Context, attributeSet: AttributeSet?, defAttrStyle: Int) {

        val typedArray = context.obtainStyledAttributes(attributeSet, R.styleable.CustomeTextView)
        (0..typedArray.indexCount)
                .asSequence()
                .map { typedArray.getIndex(it) }
                .forEach {
                    when (it) {
                    // 获取文字内容
                        R.styleable.CustomeTextView_android_text -> {
                            mText = typedArray.getString(R.styleable.CustomeTextView_android_text)
                        }
                    // 获取文字大小
                        R.styleable.CustomeTextView_android_textSize -> {
                            var textSize = typedArray.getDimensionPixelSize(R.styleable.CustomeTextView_android_textSize, 0).toFloat()
                            mPaint.textSize = textSize
                        }
                    }
                }
        typedArray.recycle()
    }

    /**
     * 计算 view 大小
     */
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        var widthSize = MeasureSpec.getSize(widthMeasureSpec)

        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        var heightSize = MeasureSpec.getSize(heightMeasureSpec)

        // 当 view 的宽高是 warpContent 时,根据文字计算 view 所需大小
        if (widthMode == MeasureSpec.AT_MOST) {
            widthSize = calculateWidth(widthSize)
        }

        if (heightMode == MeasureSpec.AT_MOST) {
            heightSize = calculateHeight(widthSize)
        }

        // 计算文字分成几行
        calculateLines(widthSize)
        // 设置 view 的大小
        setMeasuredDimension(widthSize, heightSize)
    }

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

        // 把传入的文字根据 view 的宽度,分割成一行一行的,便于绘制,我们一次只能绘制一行,多行文字就是一行行绘制出来的
        splitText()

        // 第一行文字的 baseline 的起始坐标
        var startX = 0f
        var startY = 0f - mPaint.fontMetrics.ascent

        // 遍历储存每行文字的集合,绘制每一行文字
        for ((index, text) in textList.withIndex()) {
            canvas?.drawText(text, startX, startY + index * mLineHeight, mPaint)
        }
    }

    /**
     * 计算行高
     */
    fun calculateLineHeight(): Float {
        return -mPaint.fontMetrics.ascent + mPaint.fontMetrics.bottom
    }

    /**
     * 计算文字会分割成几行绘制,由余数的话行数 +1
     */
    fun calculateLines(width: Int) {
        val measureWidth = mPaint.measureText(mText).toInt()
        mlinesNumber = if (measureWidth % width != 0) measureWidth / width + 1 else measureWidth / width
    }

    /**
     * 计算 view 所需宽度,view 的宽是 warpContent 时需要处理
     */
    fun calculateWidth(width: Int): Int {
        val measureWidth = mPaint.measureText(mText)
        return if (measureWidth >= width) width else measuredWidth
    }

    /**
     * 计算 view 总共的高度,view 的高是 warpContent 时需要处理
     */
    fun calculateHeight(width: Int): Int {

        val measureWidth = mPaint.measureText(mText).toInt()
        if (measureWidth <= width) {
            return mLineHeight.toInt()
        }
        return (mlinesNumber * mLineHeight).toInt()
    }

    /**
     * 分割文字成一行一行的
     */
    fun splitText() {

        var centerTextNum = mText.length / mlinesNumber
        var text: String = mText
        while (true) {
            // 先获取每行文字的数量
            val sigleLineTextNumber = getSigleLineTextNumber(text, width, centerTextNum)
            // 然后根据这个数量裁剪文字,把这行文字取出来,
            val lineText = text.substring(0, sigleLineTextNumber)
            // 把取出的每行文字存入集合
            textList.add(lineText)
            // 然后把取出的这行文字从源文字中删除,以便接下来的计算
            text = text.substring(sigleLineTextNumber, text.length)
            if (text.isEmpty()) break
        }
    }

    /**
     * 根据传入的文字,获取一行最多能显示的字符数
     */
    fun getSigleLineTextNumber(text: String, width: Int, centerTextNum: Int): Int {

        // 判断是不是最后一行,最后一行返回字符串长度
        if (text.length <= centerTextNum || mPaint.measureText(text) < width) {
            return text.length
        }

        var index = centerTextNum
        while (true) {
            // 从每行文字的中间数开始,一个字符的一个字符的增加文字测量数,一直到超过或等于指定宽度时,就是 view 每行能显示文字的字数
            val measureWidth = mPaint.measureText(text.substring(0, index) + 0.5f).toInt()
            if (measureWidth > width) {
                return index - 1
                break
            }
            if (measureWidth == width) {
                return index
                break
            }

            index++
        }
    }


}

最后


可能大家都不会看到这里,因为谁会在一大段代码后面接着写文字呢

这里我们使用 StaticLayout 来绘制多行文字的话会方面很多啊,不用我们自己去算有多少行,不用我们自己去截取每一行的文字再去绘制了,StaticLayout 都帮我们做了,并且通过 StaticLayout 我们可以获取文字实际会占用的宽高是多少

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

预知 StaticLayout 的详细请看:自定义 view - 绘制文字

好了,这次真的没了 ~

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

推荐阅读更多精彩内容