A,

We know that there is usually a lot of content on a list page, and that each piece of content can be very long, and that the user experience is not good if every piece of content is displayed in full. Therefore, our usual solution is to limit the number of lines for each piece of content. At this time, if you want to remind users that there is more content in this piece of content, you can enter the details page and add words like “full text” at the end of the content. In particular, such scenes are often seen in apps in the community, such as Weibo.

Second, the implementation of the scheme

So if we want to limit the maximum number of rows and show it at the end… How to implement the full text? We know that we usually set the maximum number of lines in our TextView by setting the maxLines property and setting Android: EllipSize =”end” to display the content at the end… . But what about text like “full text”? I think at this time we will certainly think: at the end of the content to spell up ah! Yes, it does. How do you spell it? How do you spell it right at the end of the content, without showing “full text” in advance?

1. “Conventional” solutions

Textview.settext () is called after textView.settext ().

Textview.post (new Runnable() {@override public void run() {// To capture the content}});Copy the code

Or set addOnGlobalLayoutListener listening, pseudo code:

textView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override Public void onGlobalLayout() {public void onGlobalLayout() {}});Copy the code

The essence and core of this is to get the number of lines of content, to determine if it is greater than the maximum number of lines we want to set, for interception of content and “full text” concatenation.

However, this method is truncated after setText(), that is, the TextView has already displayed the content and then the content is processed again by setText(). Then there are the following obvious disadvantages:

1: on devices with poor performance, all content will be flashed and then the processed content will be displayed.

2: Doing so requires two setText() operations, which adds to the performance drain on a list page with a lot of content.

2. “Optimized” processing scheme

At this point, some people might say that since there is a problem with processing after drawing, it would be good to get the number of rows in the textView in advance. Yes, we can set the addOnPreDrawListener to listen for the number of rows fetched ahead of time, pseudo-code:

textView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ @Override public boolean OnPreDraw () {return false}});Copy the code

But this works for a single piece of content, not a list, because it only works on the first screen, and swiping multiple screens back to the first screen resets to the original data.

3. Final plan

Is there any other way to set addOnPreDrawListener to listen for the number of rows that are fetched ahead of time in a list that doesn’t work? The onMeasure () of the TextView is used to measure the height of the TextView and set the corresponding height. This ensures performance and ensures that each item in the list is processed. Code first:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {super.onMeasure(widthMeasureSpec, heightMeasureSpec) if (lineCount > maxLine) { 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) {/ / whether the current byte position expression val isEmoji = PublicMethod. IsEmojiCharacter (tempText [indexEnd - offset]) the 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) // Set font size to sb. SetSpan (AbsoluteSizeSpan(moreTextSize, true), 3, sb. Length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) if (moreCanClick){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 () Full text of the 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 -> {linespacingExtra-toint () lineTop = when {lineCount > maxLine -> {layout.getlineTop (maxLine)} else -> { layout.getLineTop(lineCount) } } return (lineTop + compoundPaddingTop).coerceAtLeast(suggestedMinimumHeight) }Copy the code

The moreText displayed at the end of the content can be configured as required. We measure the width of moreText and proceed from the last text of the maximum number of lines until the width of the text is greater than or equal to moreText. We then concatenate moreText copy and moreText click events by using SpannableString. We’re also dealing with the capture of an emoticon, so we know that an emoticon has two characters, and if you capture exactly half of the emoticon you can put it down in moreText it will cause the emoticon to be one, right? Of the code. And here we have moreText click events, so what if the textView itself needs to set click events? At this point, you need to handle the touch event as follows:

val text = text val spannable = Spannable.Factory.getInstance().newSpannable(text) if (event.action == ACTION_DOWN) {// Press onDown(spannable, event)} if (mPressedSpan! = null && mPressedSpan is MyLinkClickSpan) {// If MyLinkClickSpan is present, go to MyLinkMovementMethod onTouchEvent return MyLinkMovementMethod.instance .onTouchEvent(this, text as Spannable, 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) Return result} /** * private fun onDown(spannable: spannable, event: spannable) 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)} / * * * set tail... Click event in full * / 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 }Copy the code

If (mPressedSpan! = null && mPressedSpan is MyLinkClickSpan) {// If MyLinkClickSpan is present, go to MyLinkMovementMethod onTouchEvent return MyLinkMovementMethod. Instance. OnTouchEvent (this text as Spannable, event)} is the link of compatible processing, If you have any questions about this check out my last post on link descriptions

Complete code

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
    }
}
Copy the code
<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>
Copy the code

Note: Call this method actively if there is a link requirement, otherwise the link’s touch interaction will not work.

/ users active call * * * * if have display link needs to invoke this method * / fun setMovementMethodDefault () {movementMethod = MyLinkMovementMethod. The instance highlightColor = Color.TRANSPARENT }Copy the code

In addition, there is no continuous wrapping of the content, because I think the list data is the display of the main content. In addition, the client should not do too much time-consuming operation of data processing, which should be avoided by the backend students or product design.

Effect of four,

Five, code address

Click on the get