Android 实现全文按钮在文本后方

背景
最近接了一个需求,需要实现一个全文按钮,如果文字超过指定的行数就只显示对应的行数的字数并在末尾显示一个全文按钮的字样,并且这个《全文》是可以被点击,点击之后展开全文,全文按钮变化为隐藏,展示所有文字。内心OS,这不是很简单嘛,只需动动手指头上网即可搜罗出来一大堆的实现,奈何奈何,资料少之又少,并且已有实现大多数是紧跟着字体的末尾,并不会固定在文本最后。莫得办法,只能自己动手。
思路
作为刚入行两个月的菜鸟一枚,一开始的想法是加一个固定长度的view遮盖住字体,只有在超过两行的时候才显示出来,隐藏也是如此。可是这样会延伸出一系列问题,比如会遮住字的一半、要计算遮盖view的宽度、以及调好背景等等一系列我难以handle的情况。
刚好最近在做一个关于SpannableString的需求,能不能用上去呢?这样就不需要考虑背景的问题,也不会遮盖住半个字符。想法十分美好,那就开干!!
实现
何时展示?
要实现这一个功能,第一步就是需要知道什么时候改添加Spannable,查阅网络资料,有一个onGlobalLayout可以在view开始layout之前监听 ,那就在相对应的textView中setText之后调用,用getLineCount获取设置好的长度,然后再根据行数决定是否要展示Spannable。代码如下:
binding.llSpanTv.viewTreeObserver.addOnGlobalLayoutListener(object :ViewTreeObserver.OnGlobalLayoutListener{
    override fun onGlobalLayout() {
        binding.llSpanTv.viewTreeObserver.removeOnGlobalLayoutListener(this)
        val lineCount = binding.llSpanTv.lineCount
        if(lineCount >= maxLineCount){
            // TODO 添加span
        }
    }
})
 
其中添加之后记得要remove掉,不然会造成循环调用的情况,那我们就解决了监听的问题,在每次调用到seyText的时候就会首先判断行数的多少,超过指定行数再走下一步。
如何添加Spannable在最末尾
既然我们上一步可以实现首先监听textView的行数再继续以后的逻辑,那么我们只需记录当前前maxLine的index,我们取到这个index再截取整个字符串,就得到了两行的文本,然后可以将“全文”添加到文本的末尾,一个一个往前推,直到setText之后计算出来的行数为两行。。。实在是太不优雅了,但是根据实验这个造成的绘制、性能影响微乎其微,放心用吧!
// expandText = "全文",maxLines为所需要限制的最大行数
fun setTextWithLines(text: CharSequence, maxLines: Int = MAX_LINE) {
    setText(text)
    if (layout == null || layout.lineCount <= maxLines) return

    val lineEndIndex = layout.getLineEnd(maxLines - 1)
    val visibleText = text.subSequence(0, lineEndIndex)
    for (i in 0..expandText.length) {
        val str =
            SpannableStringBuilder().append(visibleText.subSequence(0, visibleText.length - i))
                .append(expandText)
        setText(str)
        if (layout != null && layout.lineCount <= maxLines) {
            // 在此处设置Span,这里这是简单设置颜色,下面会设置点击事件
            str.setSpan(ForegroundColorSpan(0xFFC4C7CCL.toInt()), visibleText.length - i, str.length, 0)
            setText(str, BufferType.SPANNABLE)
            break
        }
    }
}
隐藏文本如何固定在末尾
如果说上面这个实现已经让你感到很难受的话,建议快跑hh。
隐藏文本与上面全文文本不一样的是,全文文本只要能够出现就一定是放在最末尾,但是隐藏文本需要人为计算原始文本的最后一个字符到屏幕另一边的距离,然后用全文文本的方法添加进去。那么问题又转移到如何计算原始文本到屏幕另一边的距离了,我们再回忆一下之前的实现,能不能保证原始文本最后一行一定是占满屏幕宽度呢?答案是肯定的,如何实现呢?补空格。这个补空格也相当讲究,如果你使用的是输入的空格“ ”,那么不好意思,安卓非常人性化地帮你去除了,也就是你无论在文本的最后面加上多少个空格“ ”或者换行“\n",最后setText得到的文本行数都是不变的,这时候我们就需要使用html中的字符了。一个空格一个空格添加实在是不够优雅,我们还是用聪明一点儿的方法吧:
  1. 因为我们最后总行数肯定是要加上隐藏文本的,所以我们最终的行数记为finalLine。
  2. 记录除了最后一行之外最后一个字符的index,用这个index除以除最后一行的总行数(也就是总行数-1),得到前n-1行平均每行的字符数量,记为avg,注意,这个时候要记录的行数是没有带上隐藏文本的原始文本行数。
  3. 每次往原始文本的最后添加avg个空格,直到setText之后计算得到的lineCount为finalText+1,记录此时已经添加了x个空格。
  4. 在0与x中使用二分法找出num,这个num是使得原始文本加上num个空格数之后setText之后计算行数刚好为finalLine!
  5. 这时候我们就可以使用添加全文的方法添加隐藏啦!
实现如下:
private fun getSpaceCount(avg: Int, finalLines: Int, showText: CharSequence): Int {
    val sb = SpannableStringBuilder(showText)
    var startIdx = 0
    var endIdx = 0
    for (i in 0 until Int.MAX_VALUE) {
        sb.append(Html.fromHtml(("&#160")) * avg)
        text = sb
        measureManually()
        if (layout.lineCount > finalLines) {
            endIdx = i
            break
        }
    }
    // 起始点为0,终点为endIdx
    endIdx = (endIdx + 1) * avg
    var middle = 0
    sb.clear()
    while (endIdx > startIdx) {
        middle = (endIdx + startIdx) / 2
        sb.append(showText)
        sb.append(Html.fromHtml(("&#160")) * middle)
        setText(sb, BufferType.SPANNABLE)
        measureManually()
        if (layout.lineCount < finalLines) {
            startIdx = middle
        } else if (layout.lineCount > finalLines) {
            endIdx = middle
        } else if (layout.lineCount == finalLines) {
            startIdx = middle
            sb.append(Html.fromHtml(("&#160")))
            setText(sb, BufferType.SPANNABLE)
            measureManually()
            if (layout.lineCount > finalLines) {
                break
            }
        }
        sb.clear()
    }
    return middle
}

fun setTextInEnd(text: CharSequence) {
    if (fullSeq != null) {
        setText(fullSeq, BufferType.SPANNABLE)
        return
    }
    // 当前是隐藏状态,将隐藏放在最后方
    // 首先查看隐藏状态下放在最后方需要多少行,然后填充直到最后一位
    // 计算平均每行存储多少字符,因为这是需要展示全文,所以最少两行,不会出现分母为0

    setText(text)
    val rawLines = layout.lineCount
    val secondIndex = layout.getLineEnd(rawLines - 2)
    val avg: Int = secondIndex / (rawLines - 1)
    val countLineText = SpannableStringBuilder(text).append(hideText)
    setText(countLineText)
    val finalLines = layout.lineCount
    val smartBuilder = SpannableStringBuilder(text)
    val spaceCount = getSpaceCount(avg, finalLines, text)
    smartBuilder.append(Html.fromHtml(("&#160")) * spaceCount)

    // 设置为Int_MAX是因为空格相对于字符很小,只要达到条件就会break
    for (i in 0..Int.MAX_VALUE) {
        val str = SpannableStringBuilder()
            .append(smartBuilder.subSequence(0, smartBuilder.length - i))
            .append(hideText)
        setText(str)
        if (layout != null && layout.lineCount <= finalLines) {
            str.setSpan(
                MySpannable(),
                smartBuilder.length - i,
                str.length,
                Spannable.SPAN_INCLUSIVE_EXCLUSIVE
            )
            fullSeq = str
            setText(str, BufferType.SPANNABLE)
            break
        }
    }
}

 

需要注意的两个bug!
第一个:如果maxLine行数的最后一行最后一个字符为换行!
比如:
红豆生南国,
春来发几枝。(第二行为maxLine,此时这里有换行)
下一行了。
出现这个情况的话,那么按照我们的全文添加逻辑并不会添加到最后,而是贴着”发几枝。",明显不符合我们的要求,怎么办呢怎么办呢,嘿嘿,当然是使用我们隐藏文本的实现方式啦,首先判断最后一个字符是不是换行!然后走隐藏文本的添加逻辑,搞定!
fun setTextWithLimit(text: CharSequence, maxLines: Int = MAX_LINE) {
    if (limitSeq != null) {
        setText(limitSeq, BufferType.SPANNABLE)
        return
    }
    setText(text)
    if (layout == null || layout.lineCount <= maxLines) return
    val lineEndIndex = layout.getLineEnd(maxLines - 1)
    val testBuilder = text.subSequence(0, lineEndIndex)
    var visibleText = text.subSequence(0, lineEndIndex)
    if (testBuilder[lineEndIndex - 1] == '\n') {
        // 更换换行符
        visibleText =
            SpannableStringBuilder(testBuilder.subSequence(0, testBuilder.length - 1)).append(
                Html.fromHtml(("&#160"))
            )
    }
    val avg = layout.getLineEnd(0)
    val smartBuilder = SpannableStringBuilder(visibleText)
    val middle = getSpaceCount(avg, 2, visibleText)
    smartBuilder.append(Html.fromHtml(("&#160")) * middle)
    for (i in 0..smartBuilder.length) {
        val str = SpannableStringBuilder()
            .append(smartBuilder.subSequence(0, smartBuilder.length - i))
            .append(expandText)
        setText(str)
        measureManually()
        if (layout != null && layout.lineCount <= maxLines) {
            str.setSpan(
                MySpannable(),
                smartBuilder.length - i,
                str.length,
                Spannable.SPAN_INCLUSIVE_EXCLUSIVE
            )
            limitSeq = str
            setText(str, BufferType.SPANNABLE)
            break
        }
    }

}
第二个:如果输入一堆英文字符,安卓的分词策略会给你带来惊吓!
比如:abcdefghiklmnopqrstuvwxyz。。。。。
此时可能这些字符设置的时候可能是两行,
可能他本来是这么显示的:
abcdefg
hijklmn
。。。。
 
你以为你插入的全文:
abcdefg
hijk全文
lmn。。。。
 
实际上用api23以上默认的分词策略会变成
abcdefg
全文
hijklmn 。。。。
 
出现这个根本原因是安卓默认把这些连起来的字符判断为一个单词了,并且显示的时候让他们连起来显示,这就导致了全文后面明明还有很多空位,但是setText之后获得lineCount仍然是3!
太坑了太坑了!我找了一晚上!
解决方法不算优雅,就是更换分词策略,这里又有一个坑,记录一下
总结
总于把这个需求做完了,虽然不算优雅,但是抓到老鼠就是好猫!网上大部分方法其实还是会出现我所说的问题,算是把他们解决了一下,而且对于分词策略这个,即使你使用StaticLayout测量字体宽度也会出现那个问题,所以调整策略才是最好的方法。或者大伙儿有啥想法可以一起交流一下呀。
posted @ 2022-09-13 10:01  码虫垒起代码之城  阅读(297)  评论(0编辑  收藏  举报