自定义多行自动省略的TextView

产品对于列表的展示出了个需求,要满足下面这些条件:
①满3行自动省略,末尾...
②如果大于3行内容,下一行需要显示一个全文的按钮
③内容中如果有话题(#话题#)需要高亮显示并可以点击跳转
④内容中如果有@用户(@用户名)需要高亮显示并可以点击跳转
⑤需要在列表中使用,如recyclerView

效果图就是类似下面这样的:
image.png

其实问题主要就是3个:
1、怎么计算是否大于3行内容,并根据内容的状态暴露回调接口,方便执行其他操作。
2、寻找内容中的高亮内容,并增加对应的点击回调接口。
3、处理特殊内容,如emoji、特殊符号等。

自定义view,继承AppCompatTextView,重写onMeasure方法

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        // 文字计算辅助工具
        val sl = StaticLayout(
                mText, paint, measuredWidth - paddingLeft - paddingRight
                , Layout.Alignment.ALIGN_CENTER, 1F, 0F, false
        )
        // 总计行数
        var lineCount = sl.lineCount
        //比较总行数和设定的最大行数
        if (lineCount > maxLineCount) {
            if (mExpanded) {//展开状态
                text = handleTopicAndAt(mText)
                mCallback?.onExpand()
            } else {//缩略状态
                lineCount = maxLineCount
                // 省略文字的宽度
                val dotWidth = paint.measureText(ellipsizeText)
                // 找出第 showLineCount 行的文字
                val start = sl.getLineStart(lineCount - 1)
                val end = sl.getLineEnd(lineCount - 1)
                val lineText = if (mText.isNotEmpty()) {
                    mText.substring(start, end)
                } else {
                    ""
                }

                // 将第 showLineCount 行最后的文字替换为 ellipsizeText
                var endIndex = 0
                for (i in lineText.length - 1 downTo 0) {
                    val str = if (lineText.isNotEmpty()) {
                        lineText.substring(i, lineText.length)
                    } else {
                        ""
                    }
                    // 找出文字宽度大于 ellipsizeText 的字符
                    if (paint.measureText(str) >= dotWidth) {
                        endIndex = i
                        break
                    }
                }
                // 新的第 showLineCount 的文字
                if (lineText.isNotEmpty()){
                val lastCodePoint = lineText.codePointAt(endIndex)
                if (isEmojiCharacter(lastCodePoint)) endIndex--
                }
                val newEndLineText = if (lineText.isNotEmpty()) {
                    lineText.substring(0, endIndex) + ellipsizeText
                } else {
                    ""
                }
                // 最终显示的文字
                val totalStr = if (mText.isNotEmpty()){
                    mText.substring(0, start) + newEndLineText
                }else{
                    ""
                }
                text = handleTopicAndAt(totalStr)
                mCallback?.onCollapse()
            }
        } else {//不满限制的最大行数
            text = handleTopicAndAt(mText)
            mCallback?.onLoss()
        }
        setMeasuredDimension(measuredWidth, measuredHeight)
    }

先通过StaticLayout来获得行数,然后根据行数和展开状态来处理内容。
文字不满3行:直接全部显示;
文字大于3行并展开状态:全部显示;
文字大于3行并隐藏状态:找出最后一行文字的开始和结束位置,用省略号替换文字末尾,拼接生成3行内容+省略号的最终文本进行显示。

高亮处理@和##话题

    private fun handleTopicAndAt(handleText: String): SpannableStringBuilder {
        val spannableStringBuilder = SpannableStringBuilder(handleText)
        if (mTopicList.isNullOrEmpty() && mAtList.isNullOrEmpty()) return spannableStringBuilder
        //处理话题
        rendererTopicSpan(handleText, spannableStringBuilder, mTopicList)
        //处理@用户
        rendererAtSpan(handleText, spannableStringBuilder, mAtList)
        return spannableStringBuilder
    }
    private fun rendererTopicSpan(contentText: String, spannableStringBuilder: SpannableStringBuilder, topicList: List) {
        if (topicList.isEmpty()) {
            return
        }
        //查找##
        //+ 匹配前面的子表达式一次或多次(大于等于1次)。例如,“zo+”能匹配“zo”以及“zoo”,但不能匹配“z”。+等价于{1,}。
        //[^xyz] 负值字符集合。匹配未包含的任意字符。例如,“[^abc]”可以匹配“plain”中的“plin”任一字符。
//        val pattern = Pattern.compile("#[^#]+#") // #[^#]+#  #[^\s]+?#
//        val matcherSub = pattern.matcher(contentText)
        val topics = HashMap()
        for (topic in topicList) {
            topics[topic.id] = "#" + topic.title + "#"
        }
        for ((key, value) in topics) {
            var startIndex = 0
            val pKey = Pattern.compile(escapeExprSpecialWord(value), Pattern.CASE_INSENSITIVE)
            val mKey = pKey.matcher(contentText)
            while (startIndex < contentText.length) {
                if (mKey.find(startIndex)) {
                    val start = mKey.start()
                    val end = mKey.end()
                    startIndex = end
                    val clickableSpan: ClickableSpan = object : ClickableSpan() {
                        override fun onClick(widget: View) {
                            topicList.find { "#" + it.title + "#" == value }?.run {
                                onTextClickListener?.onTopicClick(this)
                            }
                        }

                        override fun updateDrawState(ds: TextPaint) {
                            ds.color = topicColor
                            ds.isUnderlineText = false
                        }
                    }
                    spannableStringBuilder.setSpan(clickableSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
                    spannableStringBuilder.setSpan(ForegroundColorSpan(topicColor), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
                } else {
                    startIndex = spannableStringBuilder.length
                }
            }
        }
    }
    private fun rendererAtSpan(contentText: String, spannableStringBuilder: SpannableStringBuilder, atList: List) {
        if (atList.isEmpty()) {
            return
        }
        //查找@
        atList.forEach {
            var startIndex = 0
            val keyss = escapeExprSpecialWord(it.userName)
//            val pKey = Pattern.compile("@$keyss", Pattern.CASE_INSENSITIVE)
            val pKey = Pattern.compile(keyss, Pattern.CASE_INSENSITIVE)
            val mKey = pKey.matcher(contentText)
            while (startIndex < contentText.length) {
                if (mKey.find(startIndex)) {
                    val start = mKey.start()
                    val end = mKey.end()
                    startIndex = end
                    val clickableSpan: ClickableSpan = object : ClickableSpan() {
                        override fun onClick(widget: View) {
                            onTextClickListener?.onAtClick(it.userName, it.userId)
                        }

                        override fun updateDrawState(ds: TextPaint) {
                            ds.color = atColor
                            ds.isUnderlineText = false
                        }
                    }
                    spannableStringBuilder.setSpan(clickableSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
                    spannableStringBuilder.setSpan(ForegroundColorSpan(atColor), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
                } else {
                    startIndex = spannableStringBuilder.length
                }
            }
        }
    }

这里的逻辑也很好理解,无非就是遍历内容,找出需要高亮的词,为其改变颜色和添加点击响应方法。

处理特殊符号
文本中的特殊内容需要单独处理,如末尾的省略号长度计算时,需要判断最后的是否emoji表情,控制好长度,避免把emoji表情从中间截取了,造成最后的文本乱码;还有匹配@和##的时候,也需要对内容中的特殊符号进行处理,不然会程序出错。

    private fun isEmojiCharacter(codePoint: Int): Boolean {
        return (codePoint in 0x2600..0x27BF // 杂项符号与符号字体
                || codePoint == 0x303D || codePoint == 0x2049 || codePoint == 0x203C || codePoint in 0x2000..0x200F //
                || codePoint in 0x2028..0x202F //
                || codePoint == 0x205F //
                || codePoint in 0x2065..0x206F //
                /* 标点符号占用区域 */
                || codePoint in 0x2100..0x214F // 字母符号
                || codePoint in 0x2300..0x23FF // 各种技术符号
                || codePoint in 0x2B00..0x2BFF // 箭头A
                || codePoint in 0x2900..0x297F // 箭头B
                || codePoint in 0x3200..0x32FF // 中文符号
                || codePoint in 0xD800..0xDFFF // 高低位替代符保留区域
                || codePoint in 0xE000..0xF8FF // 私有保留区域
                || codePoint in 0xFE00..0xFE0F // 变异选择器
                || codePoint >= 0x10000) // Plane在第二平面以上的,char都不可以存,全部都转
    }
    /**
     * 转义正则特殊字符 ($()*+.[]?\^{},|)
     *
     * @param keyword
     * @return
     */
    private fun escapeExprSpecialWord(keyword: String): String {
        var word = keyword
        if (!TextUtils.isEmpty(keyword)) {
            val fbsArr = arrayOf("\\", "$", "(", ")", "*", "+", ".", "[", "]", "?", "^", "{", "}", "|")
            for (key in fbsArr) {
                if (keyword.contains(key)) {
                    word = word.replace(key, "\\" + key)
                }
            }
        }
        return word
    }

基本的关键代码就是这些,例子代码我放到Git以供参考:
https://github.com/TonyDash/MultiTextView

你可能感兴趣的:(自定义多行自动省略的TextView)