Android TextView limits the maximum number of lines and displays... Full text at the end

Keywords: Java Android

catalogue

1, Scene

2, Implementation of scheme

1. "General" scheme

2. "Optimized" processing scheme

3. Final plan

3, Complete code

1, Scene

We know that there are usually a lot of content on the list page, and each content may be very long. If each content is displayed, the user experience is very bad. Therefore, our usual solution is to limit the number of lines of each content. At this time, if you want to more clearly prompt the user that this content has more content, you can enter the details page and add words such as "full text" at the end of the content. In particular, such scenes are often seen in apps in the community, such as microblog.

2, Implementation of scheme

So if we want to limit the maximum number of rows and display it at the end... How can we achieve the full text? We know that we usually set the maximum number of lines of TextView by setting the maxLines property and android:ellipsize="end" to show at the end of the content. But how can words like "full text" be displayed? I think at this time, everyone will think: spell it at the end of the content! Yes, you need to spell it, how do you spell it? How do you spell it right at the end of the content, without displaying the "full text" in advance and completely?

1. "General" scheme

Most of the online implementations of this requirement are called textView.post after textView.setText().

textView.post(new Runnable() {
            @Override
            public void run() {
                //Intercept and splice the content
            }
     });

Or set addOnGlobalLayoutListener to listen. Pseudo code:

textView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                    //Intercept and splice the content
                }
            }
        });

Its essence and core are to obtain the number of lines of content, judge whether it is greater than the maximum number of lines we want to set, and intercept the content and splice the "full text".

However, this scheme intercepts the content after setText(), that is, the TextView has displayed the content, and then processes the content again. Then, it will have the following obvious disadvantages:

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

2: In this way, there will be two setText() operations, which will increase the loss of performance on a list page with a lot of content.

2. "Optimized" processing scheme

At this time, some people may say that since there will be problems in processing after drawing, wouldn't it be good to get the number of rows of textView in advance for processing? Yes, we can set addOnPreDrawListener to listen to get the number of rows in advance for processing. Pseudo code:

textView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
     @Override
     public boolean onPreDraw() {
        //Intercept and splice the content
        return false
    }
});

However, this scheme is only suitable for a single item of content and is not suitable for use in the list, because it is only effective in the first screen, and returning to the first screen after sliding multiple screens will also reset to the original data.

3. Final plan

Since the scheme of setting addOnPreDrawListener to listen and obtain the number of rows in advance for processing is not feasible in the list, is there any other method? Of course, it is in onMeasure() of textview When measuring the height of textview in, process the content and set the corresponding height, so as to ensure that the performance problem and each content in the list can be processed. First, the code:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    if (lineCount > maxLine) {
        //If greater than the set maximum number of rows
        val (layout, stringBuilder, sb) = clipContent()
        stringBuilder.append(sb)
        setMeasuredDimension(measuredWidth, getDesiredHeight(layout))
        text = stringBuilder
    }
}

/**
 * Crop content
 */
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()
    //Number of bytes
    var countEmoji = 0
    while (indexEnd > offset && offsetWidth <= moreWidth ) {
        //Is the current byte a bit
        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
    )
    //Set font size
    sb.setSpan(
            AbsoluteSizeSpan(moreTextSize, true), 3, sb.length,
            Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
    )
    if (moreCanClick){
        //Set click event
        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()
            //One expression has two characters to avoid garbled code or incomplete display when intercepting half of the characters
            if (offsetWidth1 > moreWidth && PublicMethod.isEmojiCharacter(charText)) {
                offset1++
            }
        }
    } else {
        val charText = tempText[indexEnd - offset1]
        offsetWidth1 += layout.paint.measureText(charText.toString()).toInt()
    }
    return Pair(offset1, offsetWidth1)
}

/**
 * Get content height
 */
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 -> {
            //The number of text lines exceeds the maximum
            layout.getLineTop(maxLine)
        }
        else -> {
            layout.getLineTop(lineCount)
        }
    }
    return (lineTop + compoundPaddingTop).coerceAtLeast(suggestedMinimumHeight)
}

The general idea is to cut the content when the number of content lines is greater than the number of content lines we want. The copy moreText displayed at the end of the content can be configured according to the requirements. We measure the width of moreText, traverse and intercept from the last text with the maximum number of lines until the width of the intercepted text is greater than or equal to the width of moreText, and then we use SpannableString To splice moreText copywriting and moreText click events. Here we also deal with the case of intercepting expression characters. We know that an expression has two characters. If you just intercepted half of the expression, you can put down moreText, which will cause the expression to become a "random code".
In addition, we set the click event of moreText here. What if the textView itself needs to set the click event? At this time, we need to deal with the touch event. The code is as follows:

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

    if (event.action == MotionEvent.ACTION_DOWN) {
        //Finger press
        onDown(spannable, event)
    }

    if (mPressedSpan != null && mPressedSpan is MyLinkClickSpan) {
        //If you have MyLinkClickSpan, go to the onTouchEvent of MyLinkMovementMethod
        return MyLinkMovementMethod.instance
                .onTouchEvent(this, text as Spannable, event)
    }

    if (event.action == MotionEvent.ACTION_MOVE) {
        //Finger movement
        val mClickSpan = getPressedSpan(this, spannable, event)
        if (mPressedSpan != null && mPressedSpan !== mClickSpan) {
            mPressedSpan = null
            Selection.removeSelection(spannable)
        }
    }
    if (event.action == MotionEvent.ACTION_UP) {
        //Finger lift
        onUp(event, spannable)
    }
    return result
}

/**
 * Finger press logic
 */
private fun onDown(spannable: Spannable, event: MotionEvent) {
    //Note clickSpan when pressed
    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
        }
    }
}

/**
 * Finger lift logic
 */
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... Full text Click event
 */
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
}

among

if (mPressedSpan != null && mPressedSpan is MyLinkClickSpan) {
    //If you have MyLinkClickSpan, go to the onTouchEvent of MyLinkMovementMethod
    return MyLinkMovementMethod.instance
            .onTouchEvent(this, text as Spannable, event)
}

If you have any questions about this, please see my last article on link description #Android imitation microblog text link interaction

3, Complete code

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

    /**
     * Maximum number of rows
     */
    private var maxLine: Int

    private val moreTextSize: Int

    /**
     * More text at the end
     */
    private val moreText: String?

    /**
     * More text colors at the end
     */
    private val moreTextColor: Int

    /**
     * Can I click more text at the end
     */
    private val moreCanClick : Boolean

    private var mPaint: Paint? = null

    /**
     * Click the event interface callback for more text at the end
     */
    private var onAllSpanClickListener: MyClickSpan.OnAllSpanClickListener? = null

    /**
     * Click to implement 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
    }

    /**
     * Set maximum number of rows
     */
    fun setMaxLine (maxLine : Int){
        this.maxLine = maxLine
    }

    /**
     * User active call
     * Be sure to call this method if there are display link requirements
     */
    fun setMovementMethodDefault() {
        movementMethod = MyLinkMovementMethod.instance
        highlightColor = Color.TRANSPARENT
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        if (lineCount > maxLine) {
            //If greater than the set maximum number of rows
            val (layout, stringBuilder, sb) = clipContent()
            stringBuilder.append(sb)
            setMeasuredDimension(measuredWidth, getDesiredHeight(layout))
            text = stringBuilder
        }
    }

    /**
     * Crop content
     */
    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()
        //Number of bytes
        var countEmoji = 0
        while (indexEnd > offset && offsetWidth <= moreWidth ) {
            //Is the current byte a bit
            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
        )
        //Set font size
        sb.setSpan(
                AbsoluteSizeSpan(moreTextSize, true), 3, sb.length,
                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
        )
        if (moreCanClick){
            //Set click event
            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()
                //One expression has two characters to avoid garbled code or incomplete display when intercepting half of the characters
                if (offsetWidth1 > moreWidth && PublicMethod.isEmojiCharacter(charText)) {
                    offset1++
                }
            }
        } else {
            val charText = tempText[indexEnd - offset1]
            offsetWidth1 += layout.paint.measureText(charText.toString()).toInt()
        }
        return Pair(offset1, offsetWidth1)
    }

    /**
     * Get content height
     */
    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 -> {
                //The number of text lines exceeds the maximum
                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) {
            //Finger press
            onDown(spannable, event)
        }

        if (mPressedSpan != null && mPressedSpan is MyLinkClickSpan) {
            //If you have MyLinkClickSpan, go to the onTouchEvent of MyLinkMovementMethod
            return MyLinkMovementMethod.instance
                    .onTouchEvent(this, text as Spannable, event)
        }

        if (event.action == MotionEvent.ACTION_MOVE) {
            //Finger movement
            val mClickSpan = getPressedSpan(this, spannable, event)
            if (mPressedSpan != null && mPressedSpan !== mClickSpan) {
                mPressedSpan = null
                Selection.removeSelection(spannable)
            }
        }
        if (event.action == MotionEvent.ACTION_UP) {
            //Finger lift
            onUp(event, spannable)
        }
        return result
    }

    /**
     * Finger press logic
     */
    private fun onDown(spannable: Spannable, event: MotionEvent) {
        //Note clickSpan when pressed
        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
            }
        }
    }

    /**
     * Finger lift logic
     */
    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... Full text Click event
     */
    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>

Note: if there is a link requirement, actively call this method, otherwise the touch interaction of the link is invalid.

/**
 * User active call
 * Be sure to call this method if there are display link requirements
 */
fun setMovementMethodDefault() {
    movementMethod = MyLinkMovementMethod.instance
    highlightColor = Color.TRANSPARENT
}

In addition, there is no continuous line feed for the content, because I think the list data is the display of the main content. In addition, the client should not do too many time-consuming operations of data processing, which should be avoided by the back-end students or during product design.

4, Code address

Click Get

Posted by Seol on Sun, 05 Dec 2021 02:33:40 -0800