Android TextView限制最大行数且在最后显示...全文

5,971 阅读7分钟

一、场景

我们知道通常在列表页面会有很多内容,而且每条内容可能会很长,如果每条内容都全部显示用户体验就很不好。所以,我们通常的处理方案是限制每条内容的行数,这个时候如果想更加明显的提示用户该条内容有更多的内容,可以进入详情页查看时会在内容最后加上“全文”之类的字眼。尤其是社区内的APP里经常会看到这样的场景,比如:微博。

二、方案的实现

那如果我们想限制最大行数且在最后显示...全文该怎么实现呢?我们知道我们通常设置TextView的最大行数是设置maxLines属性,并设置android:ellipsize="end"表示在内容最后显示...。但是类似"全文“这样的文字怎么显示呢?我想这时大家肯定会想到:在内容最后拼上去啊!没错,是需要拼上去,那要怎么拼?怎么拼上去正好在内容的最后,既不提前、又完整显示”全文“?

1、”常规”方案

网上大多关于这个需求的实现方案都是在textView.setText()之后调用textView.post方法,伪代码:

textView.post(new Runnable() {
            @Override
            public void run() {
                //进行内容的截取和拼接
            }
     });

或者是设置addOnGlobalLayoutListener监听,伪代码:

textView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                    //进行内容的截取和拼接
                }
            }
        });

其本质和核心都是为了获取内容的行数,来判断是否大于我们想设置的最大行数,来进行内容的截取和”全文“的拼接。

但是该方案是在setText()之后进行的截取,也就是TextView已经显示了内容然后再进行内容的处理再次setText()。那么会有以下明显的缺点:

1:在性能差的设备上会有闪现全部内容然后再显示处理后的内容。

2:这样做会有两次的setText()操作,在内容很多的列表页会加大性能的损耗。

2、"优化"的处理方案

这个时候可能有人会说既然绘制完成后再处理会有问题,提前获取到textView的行数进行处理不就好了吗?没错,我们可以设置addOnPreDrawListener监听提前获取行数来进行处理,伪代码:

textView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
     @Override
     public boolean onPreDraw() {
        //进行内容的截取和拼接
        return false
    }
});

但是这种方案只适合单独一条内容,不适合在列表中使用,因为这样只有在第一屏有效,且滑动多屏后回到第一屏也会重置为原始数据。

3、最终方案

既然设置addOnPreDrawListener监听提前获取行数来进行处理的方案在列表中不可行还有没有其他方法呢?那当然是在TextView的onMeasure()中测量textView的高度时进行内容的处理,并设置相对应的高度了,这样就可以保证性能问题又能保证列表中的每条内容都能得到处理。先上代码:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    if (lineCount > maxLine) {
        //如果大于设置的最大行数
        val (layout, stringBuilder, sb) = clipContent()
        stringBuilder.append(sb)
        setMeasuredDimension(measuredWidth, getDesiredHeight(layout))
        text = stringBuilder
    }
}

/**
 * 裁剪内容
 */
private fun clipContent(): Triple<Layout, SpannableStringBuilder, SpannableString> {
    var offset = 1
    val layout = layout
    val staticLayout = StaticLayout(
            text,
            layout.paint,
            layout.width,
            Layout.Alignment.ALIGN_NORMAL,
            layout.spacingMultiplier,
            layout.spacingAdd,
            false
    )
    val indexEnd = staticLayout.getLineEnd(maxLine - 1)
    val tempText = text.subSequence(0, indexEnd)
    var offsetWidth =
            layout.paint.measureText(tempText[indexEnd - 1].toString()).toInt()
    val moreWidth =
            ceil(layout.paint.measureText(moreText).toDouble()).toInt()
    //表情字节个数
    var countEmoji = 0
    while (indexEnd > offset && offsetWidth <= moreWidth ) {
        //当前字节是否位表情
        val isEmoji = PublicMethod.isEmojiCharacter(tempText[indexEnd - offset])
        if (isEmoji){
            countEmoji += 1
        }
        offset++
        val pair = getOffsetWidth(
                indexEnd,
                offset,
                tempText,
                countEmoji,
                offsetWidth,
                layout,
                moreWidth
        )
        offset = pair.first
        offsetWidth = pair.second
    }
    val ssbShrink = tempText.subSequence(0, indexEnd - offset)
    val stringBuilder = SpannableStringBuilder(ssbShrink)
    val sb = SpannableString(moreText)
    sb.setSpan(
            ForegroundColorSpan(moreTextColor), 3, sb.length,
            Spanned.SPAN_INCLUSIVE_INCLUSIVE
    )
    //设置字体大小
    sb.setSpan(
            AbsoluteSizeSpan(moreTextSize, true), 3, sb.length,
            Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
    )
    if (moreCanClick){
        //设置点击事件
        sb.setSpan(
                MyClickSpan(context, onAllSpanClickListener), 3, sb.length,
                Spanned.SPAN_INCLUSIVE_INCLUSIVE
        )
    }
    return Triple(layout, stringBuilder, sb)
}

private fun getOffsetWidth(
        indexEnd: Int,
        offset: Int,
        tempText: CharSequence,
        countEmoji: Int,
        offsetWidth: Int,
        layout: Layout,
        moreWidth: Int
): Pair<Int, Int> {
    var offset1 = offset
    var offsetWidth1 = offsetWidth
    if (indexEnd > offset1) {
        val text = tempText[indexEnd - offset1 - 1].toString().trim()
        if (text.isNotEmpty() && countEmoji % 2 == 0) {
            val charText = tempText[indexEnd - offset1]
            offsetWidth1 += layout.paint.measureText(charText.toString()).toInt()
            //一个表情两个字符,避免截取一半字符出现乱码或者显示不全...全文
            if (offsetWidth1 > moreWidth && PublicMethod.isEmojiCharacter(charText)) {
                offset1++
            }
        }
    } else {
        val charText = tempText[indexEnd - offset1]
        offsetWidth1 += layout.paint.measureText(charText.toString()).toInt()
    }
    return Pair(offset1, offsetWidth1)
}

/**
 * 获取内容高度
 */
private fun getDesiredHeight(layout: Layout?): Int {
    if (layout == null) {
        return 0
    }
    val lineTop: Int
    val lineCount = layout.lineCount
    val compoundPaddingTop = compoundPaddingTop + compoundPaddingBottom - lineSpacingExtra.toInt()
    lineTop = when {
        lineCount > maxLine -> {
            //文字行数超过最大行
            layout.getLineTop(maxLine)
        }
        else -> {
            layout.getLineTop(lineCount)
        }
    }
    return (lineTop + compoundPaddingTop).coerceAtLeast(suggestedMinimumHeight)
}

大概思路就是判断内容行数大于我们想要的内容行数时进行内容的裁剪,内容最后显示的文案moreText可以按照需求配置,我们测量出moreText的宽度,从最大行数的最后一个文字向前遍历截取,直至截取文字的宽度大于等于moreText的宽度,然后我们通过使用SpannableString来拼接moreText文案和moreText的点击事件。这里还处理了截取到表情字符的情况,我们知道一个表情两个字符,如果正好截取到表情的一半可以放下moreText就会导致表情变成一个?的乱码。 另外这里,我们设置了moreText的点击事件,那如果textView本身需要设置点击事件怎么办?这个时候就需要处理触摸事件了,代码如下:

    val text = text
    val spannable = Spannable.Factory.getInstance().newSpannable(text)

    if (event.action == MotionEvent.ACTION_DOWN) {
        //手指按下
        onDown(spannable, event)
    }

    if (mPressedSpan != null && mPressedSpan is MyLinkClickSpan) {
        //如果有MyLinkClickSpan就走MyLinkMovementMethod的onTouchEvent
        return MyLinkMovementMethod.instance
                .onTouchEvent(this, text as Spannable, event)
    }

    if (event.action == MotionEvent.ACTION_MOVE) {
        //手指移动
        val mClickSpan = getPressedSpan(this, spannable, event)
        if (mPressedSpan != null && mPressedSpan !== mClickSpan) {
            mPressedSpan = null
            Selection.removeSelection(spannable)
        }
    }
    if (event.action == MotionEvent.ACTION_UP) {
        //手指抬起
        onUp(event, spannable)
    }
    return result
}

/**
 * 手指按下逻辑
 */
private fun onDown(spannable: Spannable, event: MotionEvent) {
    //按下时记下clickSpan
    mPressedSpan = getPressedSpan(this, spannable, event)
    if (mPressedSpan != null && mPressedSpan is MyClickSpan) {
        result = true
        Selection.setSelection(
                spannable, spannable.getSpanStart(mPressedSpan),
                spannable.getSpanEnd(mPressedSpan)
        )
    } else {
        result = if (moreCanClick){
            super.onTouchEvent(event)
        }else{
            false
        }
    }
}

/**
 * 手指抬起逻辑
 */
private fun onUp(event: MotionEvent, spannable: Spannable?) {
    result = if (mPressedSpan != null && mPressedSpan is MyClickSpan) {
        (mPressedSpan as MyClickSpan).onClick(this)
        true
    } else {
        if (moreCanClick) {
            super.onTouchEvent(event)
        }
        false
    }
    mPressedSpan = null
    Selection.removeSelection(spannable)
}

/**
 * 设置尾部...全文点击事件
 */
fun setOnAllSpanClickListener(
        onAllSpanClickListener: MyClickSpan.OnAllSpanClickListener
) {
    this.onAllSpanClickListener = onAllSpanClickListener
}

private fun getPressedSpan(
        textView: TextView, spannable: Spannable,
        event: MotionEvent
): ClickableSpan? {
    var mTouchSpan: ClickableSpan? = null

    var x = event.x.toInt()
    var y = event.y.toInt()
    x -= textView.totalPaddingLeft
    x += textView.scrollX
    y -= textView.totalPaddingTop
    y += textView.scrollY
    val layout = layout
    val line = layout.getLineForVertical(y)
    val off = layout.getOffsetForHorizontal(line, x.toFloat())

    val spans: Array<MyClickSpan> =
            spannable.getSpans(
                    off, off,
                    MyClickSpan::class.java
            )
    if (spans.isNotEmpty()) {
        mTouchSpan = spans[0]
    } else {
        val linkSpans = spannable.getSpans(off, off, MyLinkClickSpan::class.java)
        if (linkSpans != null && linkSpans.isNotEmpty()) {
            mTouchSpan = linkSpans[0]
        }
    }
    return mTouchSpan
}

其中 if (mPressedSpan != null && mPressedSpan is MyLinkClickSpan) { //如果有MyLinkClickSpan就走MyLinkMovementMethod的onTouchEvent return MyLinkMovementMethod.instance .onTouchEvent(this, text as Spannable, event) }是对链接的兼容处理,如果对这个有疑问请看我的上一篇关于链接描述的文章#Android 仿微博正文链接交互

三、完整代码

class ListMoreTextView @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = R.attr.MoreTextViewStyle
) :
        AppCompatTextView(context, attrs, defStyleAttr) {

    /**
     * 最大行数
     */
    private var maxLine: Int

    private val moreTextSize: Int

    /**
     * 尾部更多文字
     */
    private val moreText: String?

    /**
     * 尾部更多文字颜色
     */
    private val moreTextColor: Int

    /**
     * 是否可以点击尾部更多文字
     */
    private val moreCanClick : Boolean

    private var mPaint: Paint? = null

    /**
     * 尾部更多文字点击事件接口回调
     */
    private var onAllSpanClickListener: MyClickSpan.OnAllSpanClickListener? = null

    /**
     * 实现span的点击
     */
    private var mPressedSpan: ClickableSpan? = null
    private var result = false


    init {
        val array = getContext().obtainStyledAttributes(
            attrs,
            R.styleable.ListMoreTextView, defStyleAttr, 0
        )
        maxLine = array.getInt(R.styleable.MoreTextView_more_action_text_maxLines, Int.MAX_VALUE)
        moreText = array.getString(R.styleable.MoreTextView_more_action_text)
        moreTextSize = array.getInteger(R.styleable.MoreTextView_more_action_text_size, 13)
        moreTextColor = array.getColor(R.styleable.MoreTextView_more_action_text_color, Color.BLACK)
        moreCanClick = array.getBoolean(R.styleable.MoreTextView_more_can_click,false)
        array.recycle()
        init()
    }

    private fun init() {
        mPaint = paint
    }

    /**
     * 设置最大行数
     */
    fun setMaxLine (maxLine : Int){
        this.maxLine = maxLine
    }

    /**
     * 使用者主动调用
     * 如果有显示链接需求一定要调用此方法
     */
    fun setMovementMethodDefault() {
        movementMethod = MyLinkMovementMethod.instance
        highlightColor = Color.TRANSPARENT
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        if (lineCount > maxLine) {
            //如果大于设置的最大行数
            val (layout, stringBuilder, sb) = clipContent()
            stringBuilder.append(sb)
            setMeasuredDimension(measuredWidth, getDesiredHeight(layout))
            text = stringBuilder
        }
    }

    /**
     * 裁剪内容
     */
    private fun clipContent(): Triple<Layout, SpannableStringBuilder, SpannableString> {
        var offset = 1
        val layout = layout
        val staticLayout = StaticLayout(
                text,
                layout.paint,
                layout.width,
                Layout.Alignment.ALIGN_NORMAL,
                layout.spacingMultiplier,
                layout.spacingAdd,
                false
        )
        val indexEnd = staticLayout.getLineEnd(maxLine - 1)
        val tempText = text.subSequence(0, indexEnd)
        var offsetWidth =
                layout.paint.measureText(tempText[indexEnd - 1].toString()).toInt()
        val moreWidth =
                ceil(layout.paint.measureText(moreText).toDouble()).toInt()
        //表情字节个数
        var countEmoji = 0
        while (indexEnd > offset && offsetWidth <= moreWidth ) {
            //当前字节是否位表情
            val isEmoji = PublicMethod.isEmojiCharacter(tempText[indexEnd - offset])
            if (isEmoji){
                countEmoji += 1
            }
            offset++
            val pair = getOffsetWidth(
                    indexEnd,
                    offset,
                    tempText,
                    countEmoji,
                    offsetWidth,
                    layout,
                    moreWidth
            )
            offset = pair.first
            offsetWidth = pair.second
        }
        val ssbShrink = tempText.subSequence(0, indexEnd - offset)
        val stringBuilder = SpannableStringBuilder(ssbShrink)
        val sb = SpannableString(moreText)
        sb.setSpan(
                ForegroundColorSpan(moreTextColor), 3, sb.length,
                Spanned.SPAN_INCLUSIVE_INCLUSIVE
        )
        //设置字体大小
        sb.setSpan(
                AbsoluteSizeSpan(moreTextSize, true), 3, sb.length,
                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
        )
        if (moreCanClick){
            //设置点击事件
            sb.setSpan(
                    MyClickSpan(context, onAllSpanClickListener), 3, sb.length,
                    Spanned.SPAN_INCLUSIVE_INCLUSIVE
            )
        }
        return Triple(layout, stringBuilder, sb)
    }

    private fun getOffsetWidth(
            indexEnd: Int,
            offset: Int,
            tempText: CharSequence,
            countEmoji: Int,
            offsetWidth: Int,
            layout: Layout,
            moreWidth: Int
    ): Pair<Int, Int> {
        var offset1 = offset
        var offsetWidth1 = offsetWidth
        if (indexEnd > offset1) {
            val text = tempText[indexEnd - offset1 - 1].toString().trim()
            if (text.isNotEmpty() && countEmoji % 2 == 0) {
                val charText = tempText[indexEnd - offset1]
                offsetWidth1 += layout.paint.measureText(charText.toString()).toInt()
                //一个表情两个字符,避免截取一半字符出现乱码或者显示不全...全文
                if (offsetWidth1 > moreWidth && PublicMethod.isEmojiCharacter(charText)) {
                    offset1++
                }
            }
        } else {
            val charText = tempText[indexEnd - offset1]
            offsetWidth1 += layout.paint.measureText(charText.toString()).toInt()
        }
        return Pair(offset1, offsetWidth1)
    }

    /**
     * 获取内容高度
     */
    private fun getDesiredHeight(layout: Layout?): Int {
        if (layout == null) {
            return 0
        }
        val lineTop: Int
        val lineCount = layout.lineCount
        val compoundPaddingTop = compoundPaddingTop + compoundPaddingBottom - lineSpacingExtra.toInt()
        lineTop = when {
            lineCount > maxLine -> {
                //文字行数超过最大行
                layout.getLineTop(maxLine)
            }
            else -> {
                layout.getLineTop(lineCount)
            }
        }
        return (lineTop + compoundPaddingTop).coerceAtLeast(suggestedMinimumHeight)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        val text = text
        val spannable = Spannable.Factory.getInstance().newSpannable(text)

        if (event.action == MotionEvent.ACTION_DOWN) {
            //手指按下
            onDown(spannable, event)
        }

        if (mPressedSpan != null && mPressedSpan is MyLinkClickSpan) {
            //如果有MyLinkClickSpan就走MyLinkMovementMethod的onTouchEvent
            return MyLinkMovementMethod.instance
                    .onTouchEvent(this, text as Spannable, event)
        }

        if (event.action == MotionEvent.ACTION_MOVE) {
            //手指移动
            val mClickSpan = getPressedSpan(this, spannable, event)
            if (mPressedSpan != null && mPressedSpan !== mClickSpan) {
                mPressedSpan = null
                Selection.removeSelection(spannable)
            }
        }
        if (event.action == MotionEvent.ACTION_UP) {
            //手指抬起
            onUp(event, spannable)
        }
        return result
    }

    /**
     * 手指按下逻辑
     */
    private fun onDown(spannable: Spannable, event: MotionEvent) {
        //按下时记下clickSpan
        mPressedSpan = getPressedSpan(this, spannable, event)
        if (mPressedSpan != null && mPressedSpan is MyClickSpan) {
            result = true
            Selection.setSelection(
                    spannable, spannable.getSpanStart(mPressedSpan),
                    spannable.getSpanEnd(mPressedSpan)
            )
        } else {
            result = if (moreCanClick){
                super.onTouchEvent(event)
            }else{
                false
            }
        }
    }

    /**
     * 手指抬起逻辑
     */
    private fun onUp(event: MotionEvent, spannable: Spannable?) {
        result = if (mPressedSpan != null && mPressedSpan is MyClickSpan) {
            (mPressedSpan as MyClickSpan).onClick(this)
            true
        } else {
            if (moreCanClick) {
                super.onTouchEvent(event)
            }
            false
        }
        mPressedSpan = null
        Selection.removeSelection(spannable)
    }

    /**
     * 设置尾部...全文点击事件
     */
    fun setOnAllSpanClickListener(
            onAllSpanClickListener: MyClickSpan.OnAllSpanClickListener
    ) {
        this.onAllSpanClickListener = onAllSpanClickListener
    }

    private fun getPressedSpan(
            textView: TextView, spannable: Spannable,
            event: MotionEvent
    ): ClickableSpan? {
        var mTouchSpan: ClickableSpan? = null

        var x = event.x.toInt()
        var y = event.y.toInt()
        x -= textView.totalPaddingLeft
        x += textView.scrollX
        y -= textView.totalPaddingTop
        y += textView.scrollY
        val layout = layout
        val line = layout.getLineForVertical(y)
        val off = layout.getOffsetForHorizontal(line, x.toFloat())

        val spans: Array<MyClickSpan> =
                spannable.getSpans(
                        off, off,
                        MyClickSpan::class.java
                )
        if (spans.isNotEmpty()) {
            mTouchSpan = spans[0]
        } else {
            val linkSpans = spannable.getSpans(off, off, MyLinkClickSpan::class.java)
            if (linkSpans != null && linkSpans.isNotEmpty()) {
                mTouchSpan = linkSpans[0]
            }
        }
        return mTouchSpan
    }
}
<declare-styleable name="ListMoreTextView">
    <attr name="more_action_text_maxLines" format="integer"/>
    <attr name="more_action_text" format="string"/>
    <attr name="more_action_text_color" format="color"/>
    <attr name="more_action_text_size" format="integer"/>
    <attr name="more_can_click" format="boolean"/>
</declare-styleable>

注意:如果是有链接需求要主动调用该方法,否则链接的触摸交互无效。

/**
 * 使用者主动调用
 * 如果有显示链接需求一定要调用此方法
 */
fun setMovementMethodDefault() {
    movementMethod = MyLinkMovementMethod.instance
    highlightColor = Color.TRANSPARENT
}

另外,这里没有对内容连续换行的处理,因为个人觉得列表数据是对主要内容的显示,另外客户端不要做太多的数据处理的耗时操作,应该是由后端的同学或者产品设计时避免这种情况的产生。

四、效果

SM-G9500_20211202222428.gif

五、代码地址

点击获取