UI之可摺疊的TextView

Sunmi_Android發表於2017-03-17

先上效果

UI之可摺疊的TextView

一、思路

1. 計算text的行數

實現可摺疊的TextView最重要的一點是在setText()前計算出text所需的行數
計算行數需要分為兩種情況

1.1 沒有換行符的text
    行數等於text的寬度除於TextView的寬度
    再判斷text的寬度對TextView的寬度取餘是否為0,如果不等於0則加1
    lines = textWidth / TextViewWidth + textWidth % TextViewWidth == 0 ? 0 : 1複製程式碼
1.2 含有換行符的text
    1. 先用換行符拆分 
    2. 對於拆分後的文字
        如果不為空,則然後再按照沒有換行符的方式計算   
        如果為空,則行數為1
    3. 累加所有的拆分文字行數複製程式碼

2. 擷取text

計算出text的行數之後,需要對text進行擷取,擷取到text能在指定的行數內顯示完的位置,

  1. 首先用換行符對text進行拆分,將text分為若干段落
  2. 對拆分後的文字段落迴圈計算行數累加,並累加字元數
  3. 累加的行數小於指定行數,繼續迴圈,直到累加的行數大於指定行數或迴圈完成;如果在迴圈完成之前累加的行數大於指定行數,則擷取該次迴圈的段落
  4. 呼叫TextUtils的ellipsize()方法對指定的段落進行擷取,ellipsize()方法中的avail引數,傳入剩餘的可顯示寬度

    因為在文字的最後要拼接上“...提示文字”,所以可顯寬度的計算方式如下:

     TextViewWidth * (指定行數 - 累加行數) - (... + 提示文字)Width複製程式碼
    1. 把擷取後的文字設定給TextView

二、實現

實現可摺疊的TextView需要繼承TextView並重寫setText(CharSequence text, BufferType type)方法

因為setText(CharSequence text)方法是final的,並且setText(CharSequence text)最終呼叫的也是setText(CharSequence text, BufferType type)方法,所以重寫後者即可。

核心程式碼

/**
 * 末尾省略號
 */
private static final String ELLIPSE = "...";
/**
 * 預設的摺疊行數
 */
public static final int COLLAPSED_LINES = 4;
/**
 * 摺疊時的預設文字
 */
private static final String EXPANDED_TEXT = "展開全文";
/**
 * 展開時的預設文字
 */
private static final String COLLAPSED_TEXT = "收起全文";
/**
 * 在文字末尾
 */
public static final int END = 0;
/**
 * 在文字下方
 */
public static final int BOTTOM = 1;
/**
 * 提示文字展示的位置
 */
@IntDef({END, BOTTOM})
@Retention(RetentionPolicy.SOURCE)
public @interface TipsGravityMode {}
/**
 * 摺疊的行數
 */
private int mCollapsedLines;
/**
 * 摺疊時的文字
 */
private String mExpandedText;
/**
 * 展開時的文字
 */
private String mCollapsedText;
/**
 * 摺疊時的圖片資源
 */
private Drawable mExpandedDrawabl
/**
 * 展開時的圖片資源
 */
private Drawable mCollapsedDrawab
/**
 * 原始的文字
 */
private CharSequence mOriginalTex
/**
 * TextView中文字可顯示的寬度
 */
private int mShowWidth;
/**
 * 是否是展開的
 */
private boolean mIsExpanded;
/**
 * 提示文字位置
 */
private int mTipsGravity;
/**
 * 提示文字顏色
 */
private int mTipsColor;
/**
 * 提示文字是否顯示下劃線
 */
private boolean mTipsUnderline;
/**
 * 提示是否可點選
 */
private boolean mTipsClickable;

... 

@Override
public void setText(CharSequence text, final BufferType type) {
    // 如果text為空或mCollapsedLines為0則直接顯示
    if (TextUtils.isEmpty(text) || mCollapsedLines == 0) {
        super.setText(text, type);
    } else if (mIsExpanded) {
        // 儲存原始文字,去掉文字末尾的空字元
        this.mOriginalText = CharUtil.trimFrom(text);
        formatExpandedText(type);
    } else {
        // 儲存原始文字,去掉文字末尾的空字元
        this.mOriginalText = CharUtil.trimFrom(text);
        // 獲取TextView中文字顯示的寬度,需要在layout之後才能獲取到,避免在列表中重複獲取
        if (mCollapsedLines > 0 && mShowWidth == 0) {
            getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
                @Override
                public void onGlobalLayout() {
                    getViewTreeObserver().removeOnGlobalLayoutListener(this);
                    mShowWidth = getWidth() - getPaddingLeft() - getPaddingRight();
                    formatCollapsedText(type);
                }
            });
        } else {
            formatCollapsedText(type);
        }
    }
}

/**
 * 格式化摺疊時的文字
 *
 * @param type ref android.R.styleable#TextView_bufferType
 */
private void formatCollapsedText(BufferType type) {
    // 將原始文字按換行符拆分成段落
    String[] paragraphs = mOriginalText.toString().split("\\n");
    // 獲取paint,用於計算文字寬度
    TextPaint paint = getPaint();
    // 文字寬度
    float textWidth;
    // 字元數,用於最後擷取字串
    int charCount = 0;
    // 剩餘行數
    int lastLines = mCollapsedLines;
    for (int i = 0; i < paragraphs.length; i++) {
        // 每個段落
        String paragraph = paragraphs[i];
        // 每個段落文字的寬度
        textWidth = paint.measureText(paragraph);
        // 計算每段的行數
        int paragraphLines = (int) (textWidth / mShowWidth);
        // 如果該段為空(表示空行)或還有餘,多加一行
        if (TextUtils.isEmpty(paragraph) || textWidth % mShowWidth != 0) {
            paragraphLines++;
        }
        if (paragraphLines < lastLines) {
            // 如果該段落行數小於等於剩餘的行數,則減少lastLines,並增加字元數
            // 這裡只計算字元數,並不拼接字元
            charCount += paragraph.length() + 1;
            lastLines -= paragraphLines;
            if (i == paragraphs.length - 1) {
                super.setText(mOriginalText, type);
                break;
            }
        } else if (paragraphLines == lastLines && i == paragraphs.length - 1) {
            // 如果該段落行數等於剩餘行數,並且是最後一個段落,表示剛好能夠顯示完全
            super.setText(mOriginalText, type);
            break;
        } else {
            // 如果該段落的行數大於等於剩餘的行數,則格式化文字
            // 因設定的文字可能是帶有樣式的文字,如SpannableStringBuilder,所以根據計算的字元數從原始文字中擷取
            SpannableStringBuilder spannable = new SpannableStringBuilder(mOriginalText, 0, charCount);
            // 計算字尾的寬度,因樣式的問題對字尾的寬度乘2
            int expandedTextWidth = 2 * (int) (paint.measureText(ELLIPSE + mExpandedText));
            // 獲取最後一段的文字,還是因為原始文字的樣式原因不能直接使用paragraphs中的文字
            CharSequence lastParagraph = mOriginalText.subSequence(charCount, charCount + paragraph.length());
            // 對最後一段文字進行擷取
            CharSequence ellipsizeText = TextUtils.ellipsize(lastParagraph, paint,
                    mShowWidth * lastLines - expandedTextWidth, TextUtils.TruncateAt.END);
            spannable.append(ellipsizeText);
            // 如果lastParagraph == ellipsizeText表示最後一段文字在可顯示範圍內,此時需要手動加上"..."
            // 如果lastParagraph != ellipsizeText表示進行了擷取TextUtils.ellipsize()方法會自動加上"..."
            if (lastParagraph == ellipsizeText) {
                spannable.append(ELLIPSE);
            }
            // 設定樣式
            setSpan(spannable);
            // 使點選有效
            setMovementMethod(LinkMovementMethod.getInstance());
            super.setText(spannable, type);
            break;
        }
    }
}

/**
 * 格式化展開式的文字,直接在後面拼接即可
 *
 * @param type
 */
private void formatExpandedText(BufferType type) {
    SpannableStringBuilder spannable = new SpannableStringBuilder(mOriginalText);
    setSpan(spannable);
    super.setText(spannable, type);
}

/**
 * 設定提示的樣式
 *
 * @param spannable 需修改樣式的文字
 */
private void setSpan(SpannableStringBuilder spannable) {
    Drawable drawable;
    // 根據提示文字需要展示的文字拼接不同的字元
    if (mTipsGravity == END) {
        spannable.append(" ");
    } else {
        spannable.append("\n");
    }
    int tipsLen;
    // 判斷是展開還是收起
    if (mIsExpanded) {
        spannable.append(mCollapsedText);
        drawable = mCollapsedDrawable;
        tipsLen = mCollapsedText.length();
    } else {
        spannable.append(mExpandedText);
        drawable = mExpandedDrawable;
        tipsLen = mExpandedText.length();
    }
    // 設定點選事件
    spannable.setSpan(new ExpandedClickableSpan(), spannable.length() - tipsLen,
            spannable.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
    // 如果提示的圖片資源不為空,則使用圖片代替提示文字
    if (drawable != null) {
        spannable.setSpan(new ImageSpan(drawable, ImageSpan.ALIGN_BASELINE),
                spannable.length() - tipsLen, spannable.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
    }
}

/**
 * 提示的點選事件
 */
private class ExpandedClickableSpan extends ClickableSpan {
    @Override
    public void onClick(View widget) {
        // 是否可點選
        if (mTipsClickable) {
            mIsExpanded = !mIsExpanded;
            setText(mOriginalText);
        }
    }
    @Override
    public void updateDrawState(TextPaint ds) {
        // 設定提示文字的顏色和是否需要下劃線
        ds.setColor(mTipsColor == 0 ? ds.linkColor : mTipsColor);
        ds.setUnderlineText(mTipsUnderline);
    }
}複製程式碼
因為使用者設定給TextView的文字可能是含有樣式的文字,即實現了Spannable介面的文字,所以在拆分並拼接文字的時候不能直接使用拆分後的字串,會丟失原有樣式,需要重新在原始文字中擷取

可以從這裡獲取程式碼

相關文章