自定义 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
        }
    }

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

所有代码如下:

自定义 view 练手 - 自定义可换行的 textview_第1张图片
device-2018-10-15-012012.png



    


    
        
        
    
class CustomeTextView : View {

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

    @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 - 绘制文字

好了,这次真的没了 ~

你可能感兴趣的:(自定义 view 练手 - 自定义可换行的 textview)