catalogue
2. "Optimized" processing scheme
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