Android中TextView限制最大行數(shù)并在最后用顯示...全文
一、場景
我們知道通常在列表頁面會有很多內(nèi)容,而且每條內(nèi)容可能會很長,如果每條內(nèi)容都全部顯示用戶體驗就很不好。所以,我們通常的處理方案是限制每條內(nèi)容的行數(shù),這個時候如果想更加明顯的提示用戶該條內(nèi)容有更多的內(nèi)容,可以進入詳情頁查看時會在內(nèi)容最后加上“全文”之類的字眼。尤其是社區(qū)內(nèi)的APP里經(jīng)常會看到這樣的場景,比如:微博。
二、方案的實現(xiàn)
那如果我們想限制最大行數(shù)且在最后顯示...全文該怎么實現(xiàn)呢?我們知道我們通常設(shè)置TextView的最大行數(shù)是設(shè)置maxLines屬性,并設(shè)置android:ellipsize="end"表示在內(nèi)容最后顯示...。但是類似"全文“這樣的文字怎么顯示呢?我想這時大家肯定會想到:在內(nèi)容最后拼上去??!沒錯,是需要拼上去,那要怎么拼?怎么拼上去正好在內(nèi)容的最后,既不提前、又完整顯示”全文“?
1、”常規(guī)”方案
網(wǎng)上大多關(guān)于這個需求的實現(xiàn)方案都是在textView.setText()之后調(diào)用textView.post方法,偽代碼:
textView.post(new Runnable() { @Override public void run() { //進行內(nèi)容的截取和拼接 } });
或者是設(shè)置addOnGlobalLayoutListener監(jiān)聽,偽代碼:
textView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { //進行內(nèi)容的截取和拼接 } } });
其本質(zhì)和核心都是為了獲取內(nèi)容的行數(shù),來判斷是否大于我們想設(shè)置的最大行數(shù),來進行內(nèi)容的截取和”全文“的拼接。
但是該方案是在setText()之后進行的截取,也就是TextView已經(jīng)顯示了內(nèi)容然后再進行內(nèi)容的處理再次setText()。那么會有以下明顯的缺點:
1:在性能差的設(shè)備上會有閃現(xiàn)全部內(nèi)容然后再顯示處理后的內(nèi)容。
2:這樣做會有兩次的setText()操作,在內(nèi)容很多的列表頁會加大性能的損耗。
2、"優(yōu)化"的處理方案
這個時候可能有人會說既然繪制完成后再處理會有問題,提前獲取到textView的行數(shù)進行處理不就好了嗎?沒錯,我們可以設(shè)置addOnPreDrawListener監(jiān)聽提前獲取行數(shù)來進行處理,偽代碼:
textView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ @Override public boolean onPreDraw() { //進行內(nèi)容的截取和拼接 return false } });
但是這種方案只適合單獨一條內(nèi)容,不適合在列表中使用,因為這樣只有在第一屏有效,且滑動多屏后回到第一屏也會重置為原始數(shù)據(jù)。
3、最終方案
既然設(shè)置addOnPreDrawListener監(jiān)聽提前獲取行數(shù)來進行處理的方案在列表中不可行還有沒有其他方法呢?那當然是在TextView的onMeasure()中測量textView的高度時進行內(nèi)容的處理,并設(shè)置相對應(yīng)的高度了,這樣就可以保證性能問題又能保證列表中的每條內(nèi)容都能得到處理。先上代碼:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) if (lineCount > maxLine) { //如果大于設(shè)置的最大行數(shù) val (layout, stringBuilder, sb) = clipContent() stringBuilder.append(sb) setMeasuredDimension(measuredWidth, getDesiredHeight(layout)) text = stringBuilder } } /** * 裁剪內(nèi)容 */ 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() //表情字節(jié)個數(shù) var countEmoji = 0 while (indexEnd > offset && offsetWidth <= moreWidth ) { //當前字節(jié)是否位表情 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 ) //設(shè)置字體大小 sb.setSpan( AbsoluteSizeSpan(moreTextSize, true), 3, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) if (moreCanClick){ //設(shè)置點擊事件 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() //一個表情兩個字符,避免截取一半字符出現(xiàn)亂碼或者顯示不全...全文 if (offsetWidth1 > moreWidth && PublicMethod.isEmojiCharacter(charText)) { offset1++ } } } else { val charText = tempText[indexEnd - offset1] offsetWidth1 += layout.paint.measureText(charText.toString()).toInt() } return Pair(offset1, offsetWidth1) } /** * 獲取內(nèi)容高度 */ 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 -> { //文字行數(shù)超過最大行 layout.getLineTop(maxLine) } else -> { layout.getLineTop(lineCount) } } return (lineTop + compoundPaddingTop).coerceAtLeast(suggestedMinimumHeight) }
大概思路就是判斷內(nèi)容行數(shù)大于我們想要的內(nèi)容行數(shù)時進行內(nèi)容的裁剪,內(nèi)容最后顯示的文案moreText可以按照需求配置,我們測量出moreText的寬度,從最大行數(shù)的最后一個文字向前遍歷截取,直至截取文字的寬度大于等于moreText的寬度,然后我們通過使用SpannableString來拼接moreText文案和moreText的點擊事件。這里還處理了截取到表情字符的情況,我們知道一個表情兩個字符,如果正好截取到表情的一半可以放下moreText就會導(dǎo)致表情變成一個?的亂碼。 另外這里,我們設(shè)置了moreText的點擊事件,那如果textView本身需要設(shè)置點擊事件怎么辦?這個時候就需要處理觸摸事件了,代碼如下:
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) } /** * 設(shè)置尾部...全文點擊事件 */ 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) }
是對鏈接的兼容處理,如果對這個有疑問請看我的上一篇關(guān)于鏈接描述的文章#Android 仿微博正文鏈接交互
三、完整代碼
class ListMoreTextView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = R.attr.MoreTextViewStyle ) : AppCompatTextView(context, attrs, defStyleAttr) { /** * 最大行數(shù) */ 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 /** * 尾部更多文字點擊事件接口回調(diào) */ private var onAllSpanClickListener: MyClickSpan.OnAllSpanClickListener? = null /** * 實現(xiàn)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 } /** * 設(shè)置最大行數(shù) */ fun setMaxLine (maxLine : Int){ this.maxLine = maxLine } /** * 使用者主動調(diào)用 * 如果有顯示鏈接需求一定要調(diào)用此方法 */ fun setMovementMethodDefault() { movementMethod = MyLinkMovementMethod.instance highlightColor = Color.TRANSPARENT } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) if (lineCount > maxLine) { //如果大于設(shè)置的最大行數(shù) val (layout, stringBuilder, sb) = clipContent() stringBuilder.append(sb) setMeasuredDimension(measuredWidth, getDesiredHeight(layout)) text = stringBuilder } } /** * 裁剪內(nèi)容 */ 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() //表情字節(jié)個數(shù) var countEmoji = 0 while (indexEnd > offset && offsetWidth <= moreWidth ) { //當前字節(jié)是否位表情 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 ) //設(shè)置字體大小 sb.setSpan( AbsoluteSizeSpan(moreTextSize, true), 3, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) if (moreCanClick){ //設(shè)置點擊事件 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() //一個表情兩個字符,避免截取一半字符出現(xiàn)亂碼或者顯示不全...全文 if (offsetWidth1 > moreWidth && PublicMethod.isEmojiCharacter(charText)) { offset1++ } } } else { val charText = tempText[indexEnd - offset1] offsetWidth1 += layout.paint.measureText(charText.toString()).toInt() } return Pair(offset1, offsetWidth1) } /** * 獲取內(nèi)容高度 */ 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 -> { //文字行數(shù)超過最大行 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) } /** * 設(shè)置尾部...全文點擊事件 */ 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>
注意:如果是有鏈接需求要主動調(diào)用該方法,否則鏈接的觸摸交互無效。
/** * 使用者主動調(diào)用 * 如果有顯示鏈接需求一定要調(diào)用此方法 */ fun setMovementMethodDefault() { movementMethod = MyLinkMovementMethod.instance highlightColor = Color.TRANSPARENT }
另外,這里沒有對內(nèi)容連續(xù)換行的處理,因為個人覺得列表數(shù)據(jù)是對主要內(nèi)容的顯示,另外客戶端不要做太多的數(shù)據(jù)處理的耗時操作,應(yīng)該是由后端的同學(xué)或者產(chǎn)品設(shè)計時避免這種情況的產(chǎn)生。
四、效果
五、代碼地址
作者:笑慢
鏈接:https://juejin.cn/post/7037416456782348295
來源:稀土掘金
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請聯(lián)系作者獲得授權(quán),非商業(yè)轉(zhuǎn)載請注明出處。
相關(guān)文章
Android ListView實現(xiàn)仿iPhone實現(xiàn)左滑刪除按鈕的簡單實例
下面小編就為大家?guī)硪黄狝ndroid ListView實現(xiàn)仿iPhone實現(xiàn)左滑刪除按鈕的簡單實例。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2016-08-08詳談OnTouchListener與OnGestureListener的區(qū)別
下面小編就為大家?guī)硪黄斦凮nTouchListener與OnGestureListener的區(qū)別。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-04-04Android 中無法取消標題欄的問題小結(jié)(兩種方法)
我們都知道取消標題欄有兩種方式,一種是在Java代碼中取消,另一種通過設(shè)置styles.xml文件中的Theme即可,下面就兩種方法給大家簡答介紹下2016-12-12android基礎(chǔ)教程之a(chǎn)ndroid的listview與edittext沖突解決方法
這篇文章主要介紹了android的listview與edittext沖突解決方法,需要的朋友可以參考下2014-02-02