阅读 3111

Android TextView用"查看更多"替代自带ellipsize

效果

这篇文章可以帮你实现下面的效果。末尾固定显示“查看更多”,或者任何你自定义的文字,而且高效实现了一个展开折叠动画。

效果图
效果动图参考这里:github.com/aesean/Acti…

不关心原理,只需要实现的参考这里: TextViewExtensions

使用示例: ShowMoreAnimationActivity,或者参考下面的使用示例

Kotlin用法

    TextViewSuffixWrapper(textView).apply wrapper@{
            this.mainContent = getString(R.string.sample_text)
            this.suffix = "...查看更多"
            this.suffix?.apply {
                suffixColor("...".length, this.length, R.color.md_blue_500, listener = View.OnClickListener { view ->
                    toast("click ${this}")
                })
            }
            this.transition?.duration = 5000
            sceneRoot = this.textView.parent.parent.parent as ViewGroup
            collapse(false)
            this.textView.setOnClickListener {
                toast("click view")
                this@wrapper.toggle()
            }
        }
复制代码

Java版用法

        TextViewSuffixWrapper wrapper = new TextViewSuffixWrapper(textView);
        wrapper.setMainContent("mainContent");
        String suffix = "...查看更多";
        wrapper.setSuffix(suffix);
        wrapper.suffixColor("...".length(), suffix.length(), R.color.colorAccent, new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                
            }
        });
        wrapper.collapse(false);
        
        textView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                wrapper.toggle();
            }
        });
复制代码

产生问题

Android的TextView支持最大行数,比如你希望一个TextView最大行数是2行。

    textView.maxLines = 2
复制代码

这时候textView最多显示两行。这时候你可以通过设置ellipsize,让文字超过两行的时候用“...”代替最后几个字。设置之后,TextView依然只有两行。

    textView.maxLines = 2
    textView.ellipsize = TextUtils.TruncateAt.END
复制代码

那问题来了,我觉得...的提示不够明显,我想换成类似查看更多这样的文字怎么办?而且我希望给查看更多加上颜色,怎么办?比如显示成文章开头第一个图。这个需求其实还是非常常见的,Android原生并提供类似的实现,那我们只能自己动手了。

分析问题

我们用下面的文本作为textView要显示的文本,我们把这段文字取个名字叫:mainContent

"欧阳峰(独白):离开白驼山之后,我去了这个沙漠,开始了另一种生活。欧阳峰(独白):初六日,惊蛰。每年这个时候,都会有一个人来找我喝酒,他的名字叫黄药师。这个人很奇怪,每次总从东边而来,这习惯已经维持了好多年。今年,他给我带了一份手信。黄药师:不久前,我遇上一个人,送给我一坛酒,她说那叫"醉生梦死",喝了之后,可以叫你忘掉以做过的任何事。我很奇怪,为什么会有这样的酒。她说人最大的烦恼,就是记性太好,如果什么都可以忘掉,以后的每一天将会是一个新的开始,那你说这有多开心。这坛酒本来打算送给你的,看起来,我们要分来喝了。欧阳峰(独白):对于太古怪的东西,我向来很难接受,所以这坛"醉生梦死"我一直没有喝。可能这酒真的有效,从那天晚上开始,黄药师开始忘记了很多事情。

要显示成下面这个图的效果,我们可以把问题拆成两部分。

效果图

  • mainContent中找选取多少个字的时候,后面加上...查看更多可以刚好是两行,而且再多加一个字都会导致后面加上...查看更多会换行。
  • 改变最后的查看更多的颜色为蓝色

解决问题

问题已经被抽象出来了。先看第二个问题,改变文字颜色,这个简单,我们通过SpannableStringBuilder配合ForegroundColorSpan很容易实现。但第一个问题,找前面到底放多少个字,加上后面的后缀刚好可以放两行,这个怎么处理?

假如我们有个方法能知道放若干个字后的TextView行数,那我们假如从mainContent里找到这样一个index,能够满足下面的条件

    textView.text = mainContent.substring(0, index) + "...查看更多"
    // textView.lineCount == 2

    textView.text = mainContent.substring(0, index+1) + "...查看更多"
    // textView.lineCount == 3
复制代码

(0, index)放到TextView,textView行数是2,(0, index+1)放到TextView,textView行数是3。那么这个index其实就是我们要到的mainContent应该截断的位置。少一个字或者多一个字都不对。那TextView有提供获取行数的方法吗?

有提供,我们可以通过下面的方法

    textView.text = "111"
    val lineCount = textView.layout.lineCount // lineCount == 1
复制代码

而且这个方法可以同步获取到lineCount,简单说就是只要能获取到layout,那么setText后可以立刻读取到lineCount,不需要等待下一次消息循环。那么我们可以简单的通过下面的代码找到刚好能放下的index。

    fun findIndex(textView: TextView, mainContent: CharSequence, suffix: CharSequence): Int {
        for (i in mainContent.length downTo 1) {
            textView.text = mainContent.subSequence(0, i).toString() + suffix.toString()
            val lineCount0 = textView.layout.lineCount
            textView.text = mainContent.subSequence(0, i + 1).toString() + suffix.toString()
            val lineCount1 = textView.layout.lineCount
            if (lineCount0 == 2 && lineCount1 == 3) {
                return i
            }
        }
        return -1
    }
复制代码

逻辑非常简单,一个个去试,直到找到多一个都放不下的index。

优化

到这里已经可以非常粗略的实现出这样效果的demo了,但实际使用还会有非常多问题,比如layout并不是总能拿到,onCreate中setText后拿到的layout是null,需要等到TextView的onMeasure之后才能生成layout。另外layout过程是很消耗资源的,我能不能把layout过程放在子线程?还有这一个个文本的尝试实在是有点蠢,有没有更优化的算法?

onCreate中setText后getLayout是null怎么办?

查看TextView源码,我们可以发现layout第一次被创建是在onMeasure里,那我们在onCreate里setText后当然是拿不到Layout的,那怎么办?我的做法是判断到当Layout为null的时候添加LayoutChangeListener,在LayoutChangeListener中重新拿Layout。

layout过程很消耗资源,我们能不能自己new一个Layout,然后把测量过程放子线程?而且setText这么多次会影响TextChangedListener,怎么办?

如果你用过新TextView的autoTextSize,如果用过的话,你可能会发现这个功能在AppCompatTextView中做了低版本兼容实现。里面有个类叫AppCompatTextViewAutoSizeHelper,通过反射等一系列方法,新创建了一个StaticLayout出来,然后通过新创建的这个StaticLayout配合二分查找来计算最合适的文字大小。

那我们能不能模仿AppCompatTextViewAutoSizeHelper的实现,我们也new一个StaticLayout出来?new一个StaticLayout出来没什么问题,但问题是,TextView并不总是使用StaticLayout来layout文本,比如我们需要改变查看更多的颜色,同时需要能点击查看更多,那么我们会用到SpannableString,这时候TextView会使用DynamicLayout来layout文本,不同的Layout得出的layout结果有可能是不同的。那么我们就需要不光new一个StaticLayout,还需要在合适的时候new一个DynamicLayout出来,这一系列的原因会让这个问题复杂化。最后我放弃了new一个新DynamicLayout出来。而是直接利用TextView自己的layout,这样能大大降低问题复杂性,但带来的问题就是不能在子线程测量,同时由于会多次setText,那么就会导致因为多次TextChangedListener回调。

一个个尝试实在是太蠢了,怎么优化?

优化很简单,直接二分查找。二分的过程还是比较简单的,具体如何二分这里不详细介绍了,可以在logcat中过滤关键字TextViewLayout,在debug级别的日志中可以看到整个二分查找过程。

添加动画

既然解决了如何折叠,那么你可能也会需要展开。展开非常简单,直接把所有文本设置上去就可以了。如果需要加动画怎么办?可以用Animator吗?或者Animation?

不管用Animator还是Animation,动画过程中,需要改变的是什么?是高度,但问题来了,没动之前我怎么知道要动到什么高度?这是个非常棘手的问题。

好在Google给我们提供了另外一个实现动画的方式,TransitionManager

    TransitionManager.beginDelayedTransition(sceneRoot, transition)
复制代码

sceneRoot是你希望动画的目标View,transition是动画方式,transition通常我们默认使用AutoTransition就可以了。那为啥TransitionManager能这么动画呢?主要是下面一行代码

    sceneRoot.getViewTreeObserver().addOnPreDrawListener(listener)
复制代码

这个动画性能非常好,它在改变高度宽度等动画的时候,只触发一次layout,可以大大降低性能消耗,更详细具体的原理回头有空了再分析。

结束

整个过程就介绍结束了,有什么其他疑问欢迎给我留言评论。