Android - 仿小紅書自定義展開/收起的TextView

圖片發自簡書App
故事是這麼開始的,有個產品需求需求,要做一個小紅書文字摺疊的功能,於是就有了後面一系列的東西。不過實現了之後,自己對 TextView
擷取文字也瞭解了不少,具體效果如下:

圖片發自簡書App
先總結一下實現的時候需要注意的幾個點:
- 顯示 “...展開” 時,是擷取的一定行數之後,在最後一行的末尾直接顯示
- “收起” 顯示在全部文字的下一行,並且是右對齊
- 展開和收起的動畫效果
如果歸納的不完善,還請指出,不想看過程了可以直接跳到文末檢視 ExpandableTextView
程式碼
文字的擷取
參考了好些文章,很多實現都是擷取文字的最大行,在文字的下一行新增一個按鈕,這個做法並不符合需求,所以直接可以PASS了。
轉換一下思路,會發現其實這個效果與 TextView
設定 android:maxLines
之後,再設定 android:ellipsize
為 end
很相似,只是 ...
替換換成了 ...展開
,遺憾的是系統並沒有提供直接替換 ...
的API。
但是,在涉及到 android:ellipsize
屬性處理的 TextView
的原始碼中可以看到使用了 StaticLayout
了一個可以幫助我們實現效果的工具類 StaticLayout
, StaticLayout
是 android
中處理文字換行的一個工具類。
有 BoringLayout
、 StaticLayout
和 DynamicLayout
三個工具類
BoringLayout StaticLayout DynamicLayout
接下來,就需要知道 StaticLayout
怎麼使用了,我們可以直接使用的建構函式有三個
public StaticLayout(CharSequence source, TextPaint paint, int width, Alignment align, float spacingmult, float spacingadd, boolean includepad) { this(source, 0, source.length(), paint, width, align, spacingmult, spacingadd, includepad); } public StaticLayout(CharSequence source, int bufstart, int bufend, TextPaint paint, int outerwidth, Alignment align, float spacingmult, float spacingadd, boolean includepad) { this(source, bufstart, bufend, paint, outerwidth, align, spacingmult, spacingadd, includepad, null, 0); } public StaticLayout(CharSequence source, int bufstart, int bufend, TextPaint paint, int outerwidth, Alignment align, float spacingmult, float spacingadd, boolean includepad, TextUtils.TruncateAt ellipsize, int ellipsizedWidth) { this(source, bufstart, bufend, paint, outerwidth, align, TextDirectionHeuristics.FIRSTSTRONG_LTR, spacingmult, spacingadd, includepad, ellipsize, ellipsizedWidth, Integer.MAX_VALUE); }
使用之前,稍微瞭解一下方法中引數的作用,以下是比較全的引數說明
- CharSequence source:需要分行的字串
- int bufstart:需要分行的字串從第幾個位置開始
- int bufend:需要分行的字串到哪裡結束
- TextPaint paint:畫筆物件
- int outerwidth:layout的寬度,字元超出寬度時自動換行,也就是內容要顯示的寬度
- Alignment align:對齊方式,有
ALIGN_CENTER
、ALIGN_NORMAL
、ALIGN_OPPOSITE
三種 - float spacingmult:行間距倍數,相當於
android:lineSpacingMultiplier
- float spacingadd:額外增加的行間距,相當於
android:lineSpacingExtra
- boolean includepad:是否包含padding
- TextUtils.TruncateAt ellipsize:省略的位置,
TruncateAt
是一個enum
,有START
、MIDDLE
、END
、MARQUEE
(跑馬燈),還有END_SMALL
但是被隱藏了 - int ellipsizedWidth:開始省略的位置
我們只需要使用引數最少的那個構造方法就能滿足了
private Layout createStaticLayout(SpannableStringBuilder spannable) { int contentWidth = initWidth - getPaddingLeft() - getPaddingRight(); return new StaticLayout(spannable, getPaint(), contentWidth, Layout.Alignment.ALIGN_NORMAL, getLineSpacingMultiplier(), getLineSpacingExtra(), false); }
獲取到對應文字的 StaticLayout
物件之後,可以通過 StaticLayout
的 getLineCount()
方法知道文字是否會超出我們設定的 maxLines
,配合 getLineEnd(int line)
方法可以找到最後一行的最後一個字元在文字中的位置。關鍵程式碼如下:
Layout layout = createStaticLayout(tempText); mExpandable = layout.getLineCount() > maxLines; if(mExpandable){ //計算原文擷取位置 int endPos = layout.getLineEnd(maxLines - 1); mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, endPos)); SpannableStringBuilder tempText2 = charSequenceToSpannable(mCloseSpannableStr).append(ELLIPSIS_STRING); if (mOpenSuffixSpan != null) { tempText2.append(tempText2); } //迴圈判斷,收起內容新增展開字尾後的內容 Layout tempLayout = createStaticLayout(tempText2); while (tempLayout.getLineCount() > maxLines) { int lastSpace = mCloseSpannableStr.length() - 1; if (lastSpace == -1) { break; } mCloseSpannableStr = charSequenceToSpannable(originalText.subSequence(0, lastSpace)); tempText2 = charSequenceToSpannable(mCloseSpannableStr).append(ELLIPSIS_STRING); if (mOpenSuffixSpan != null) { tempText2.append(mOpenSuffixSpan); } tempLayout = createStaticLayout(tempText2); } //計算收起的文字高度 mCLoseHeight = tempLayout.getHeight() + getPaddingTop() + getPaddingBottom(); mCloseSpannableStr.append(ELLIPSIS_STRING); if (mOpenSuffixSpan != null) { mCloseSpannableStr.append(mOpenSuffixSpan); } }
這樣一來,文字的擷取問題就解決了。程式碼中的 mCloseSpannableStr
就是被摺疊之後需要顯示的文字物件,考慮到文字中會存在表情或者圖片的可能,所以使用 SpannableStringBuilder
來作為文字物件。
“收起”右對齊
對 收起
文字的處理,使用 SpannableString
,設定 new AlignmentSpan.Standard(Layout.Alignment.ALIGN_OPPOSITE)
就可以將 收起
文字顯示成右對齊,換行的話,需要在原始文字和 收起
文字之間在新增 '\n'
就可以了。
private void updateCloseSuffixSpan() { if (TextUtils.isEmpty(mCloseSuffixStr)) { mCloseSuffixSpan = null; return; } mCloseSuffixSpan = new SpannableString(mCloseSuffixStr); mCloseSuffixSpan.setSpan(new ForegroundColorSpan(mCloseSuffixColor), 0, mCloseSuffixStr.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); if (mCloseInNewLine) { AlignmentSpan alignmentSpan = new AlignmentSpan.Standard(Layout.Alignment.ALIGN_OPPOSITE); mCloseSuffixSpan.setSpan(alignmentSpan, 0, mCloseSuffixStr.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); } }
動畫效果
動畫效果就比較簡單了,執行動畫時在 applyTransformation
方法中改變 TextView
的高度就可以了
class ExpandCollapseAnimation extends Animation { private final View mTargetView;//動畫執行view private final int mStartHeight;//動畫執行的開始高度 private final int mEndHeight;//動畫結束後的高度 ExpandCollapseAnimation(View target, int startHeight, int endHeight) { mTargetView = target; mStartHeight = startHeight; mEndHeight = endHeight; setDuration(400); } @Override protected void applyTransformation(float interpolatedTime, Transformation t) { //計算出每次應該顯示的高度,改變執行view的高度,實現動畫 mTargetView.getLayoutParams().height = (int) ((mEndHeight - mStartHeight) * interpolatedTime + mStartHeight); mTargetView.requestLayout(); } }
而 TextView
在展開和收起狀態的高度就需要在處理文字是通過 StaticLayout
的 getHeight()
來獲取了。還有就是動畫前後 TextView
高度和文字的更新問題,具體程式碼如下:
/** 執行展開動畫 */ private void executeOpenAnim() { //建立展開動畫 if (mOpenAnim == null) { mOpenAnim = new ExpandCollapseAnimation(this, mCLoseHeight, mOpenHeight); mOpenAnim.setFillAfter(true); mOpenAnim.setAnimationListener(new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) { ExpandableTextView.super.setMaxLines(Integer.MAX_VALUE); setText(mOpenSpannableStr); } @Override public void onAnimationEnd(Animation animation) { //動畫結束後textview設定展開的狀態 getLayoutParams().height = mOpenHeight; requestLayout(); animating = false; } @Override public void onAnimationRepeat(Animation animation) { } }); } if (animating) { return; } animating = true; clearAnimation(); //執行動畫 startAnimation(mOpenAnim); } /** 執行收起動畫 */ private void executeCloseAnim() { //建立收起動畫 if (mCloseAnim == null) { mCloseAnim = new ExpandCollapseAnimation(this, mOpenHeight, mCLoseHeight); mCloseAnim.setFillAfter(true); mCloseAnim.setAnimationListener(new Animation.AnimationListener() { @Override public void onAnimationStart(Animation animation) { } @Override public void onAnimationEnd(Animation animation) { animating = false; ExpandableTextView.super.setMaxLines(mMaxLines); setText(mCloseSpannableStr); getLayoutParams().height = mCLoseHeight; requestLayout(); } @Override public void onAnimationRepeat(Animation animation) { } }); } if (animating) { return; } animating = true; clearAnimation(); //執行動畫 startAnimation(mCloseAnim); }
最終效果

圖片發自簡書App
終於完了
考慮到 ExpandableTextView
並不應該處理emoji表情等等一些特殊的文字形式,所以提供了 CharSequenceToSpannableHandler
擴充套件介面,可以自行擴充套件處理文字的顯示。
以上就是 ExpandableTextView
整個的實現過程和思路,分享出來,如果有更好的方法歡迎評論中討論
原始碼地址: ExpandableTextView.java