1. 程式人生 > >Android尾部帶“檢視更多”的TextView

Android尾部帶“檢視更多”的TextView

http://blog.csdn.net/tanxuewe/article/details/50793630

宣告一下,最開始是從這篇文章看到的,拿過來後呢,做了一些改進。

首先是增添了幾個屬性;其次,也是最重要的,改進了呼叫setText()重新設定文字時,其下方的View會發生抖動的問題,也就是onMeasure()中的那段程式碼。

FolderTextView.java

package com.xiaobo.foldertextview;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.text.Layout;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.widget.TextView;

/**
 * 結尾帶“檢視全部”的TextView,點選可以展開文字,展開後可收起。
 * <p/>
 * 目前存在一個問題:外部呼叫setText()時會造成介面該TextView下方的View抖動;
 * <p/>
 * 可以先呼叫getFullText(),當已有文字和要設定的文字不一樣才呼叫setText(),可降低抖動的次數;
 * <p/>
 * 通過在onMeasure()中設定高度已經修復了該問題了。
 * <p/>
 * Created by moxiaobo on 16/8/9.
 */
public class FolderTextView extends TextView {

    // TAG
    private static final String TAG = "xiaobo";

    // 預設打點文字
    private static final String DEFAULT_ELLIPSIZE = "...";
    // 預設收起文字
    private static final String DEFAULT_FOLD_TEXT = "[收起]";
    // 預設展開文字
    private static final String DEFAULT_UNFOLD_TEXT = "[檢視全部]";
    // 預設固定行數
    private static final int DEFAULT_FOLD_LINE = 2;
    // 預設收起和展開文字顏色
    private static final int DEFAULT_TAIL_TEXT_COLOR = Color.GRAY;
    // 預設是否可以再次收起
    private static final boolean DEFAULT_CAN_FOLD_AGAIN = true;

    // 收起文字
    private String mFoldText;
    // 展開文字
    private String mUnFoldText;
    // 固定行數
    private int mFoldLine;
    // 尾部文字顏色
    private int mTailColor;
    // 是否可以再次收起
    private boolean mCanFoldAgain = false;

    // 收縮狀態
    private boolean mIsFold = false;
    // 繪製,防止重複進行繪製
    private boolean mHasDrawn = false;
    // 內部繪製
    private boolean mIsInner = false;

    // 全文字
    private String mFullText;
    // 行間距倍數
    private float mLineSpacingMultiplier = 1.0f;
    // 行間距額外畫素
    private float mLineSpacingExtra = 0.0f;

    // 統計使用二分法裁剪源文字的次數
    private int mCountBinary = 0;
    // 統計使用備用方法裁剪源文字的次數
    private int mCountBackUp = 0;
    // 統計onDraw呼叫的次數
    private int mCountOnDraw = 0;

    // 點選處理
    private ClickableSpan clickSpan = new ClickableSpan() {
        @Override
        public void onClick(View widget) {
            mIsFold = !mIsFold;
            mHasDrawn = false;
            invalidate();
        }

        @Override
        public void updateDrawState(TextPaint ds) {
            ds.setColor(mTailColor);
        }
    };

    /**
     * 構造
     *
     * @param context 上下文
     */
    public FolderTextView(Context context) {
        this(context, null);
    }

    /**
     * 構造
     *
     * @param context 上下文
     * @param attrs   屬性
     */
    public FolderTextView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    /**
     * 構造
     *
     * @param context      上下文
     * @param attrs        屬性
     * @param defStyleAttr 樣式
     */
    public FolderTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FolderTextView);
        mFoldText = a.getString(R.styleable.FolderTextView_foldText);
        if (null == mFoldText) {
            mFoldText = DEFAULT_FOLD_TEXT;
        }
        mUnFoldText = a.getString(R.styleable.FolderTextView_unFoldText);
        if (null == mUnFoldText) {
            mUnFoldText = DEFAULT_UNFOLD_TEXT;
        }
        mFoldLine = a.getInt(R.styleable.FolderTextView_foldLine, DEFAULT_FOLD_LINE);
        if (mFoldLine < 1) {
            throw new RuntimeException("foldLine must not less than 1");
        }
        mTailColor = a.getColor(R.styleable.FolderTextView_tailTextColor, DEFAULT_TAIL_TEXT_COLOR);
        mCanFoldAgain = a.getBoolean(R.styleable.FolderTextView_canFoldAgain, DEFAULT_CAN_FOLD_AGAIN);

        a.recycle();
    }

    @Override
    public void setText(CharSequence text, BufferType type) {
        if (TextUtils.isEmpty(mFullText) || !mIsInner) {
            mHasDrawn = false;
            mFullText = String.valueOf(text);
        }
        super.setText(text, type);
    }

    @Override
    public void setLineSpacing(float extra, float multiplier) {
        mLineSpacingExtra = extra;
        mLineSpacingMultiplier = multiplier;
        super.setLineSpacing(extra, multiplier);
    }

    @Override
    public void invalidate() {
        super.invalidate();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        // 必須解釋下:由於為了得到實際一行的寬度(makeTextLayout()中需要使用),必須要先把源文字設定上,然後再裁剪至指定行數;
        // 這就導致了該TextView會先佈局一次高度很高(源文字行數高度)的佈局,裁剪後再次佈局成指定行數高度,因而下方View會抖動;
        // 這裡的處理是,super.onMeasure()已經計算出了源文字的實際寬高了,取出指定行數的文字再次測量一下其高度,
        // 然後把這個高度設定成最終的高度就行了!
        if (!mIsFold) {
            Layout layout = getLayout();
            int line = getFoldLine();
            if (line < layout.getLineCount()) {
                int index = layout.getLineEnd(line - 1);
                if (index > 0) {
                    // 得到一個字串,該字串恰好佔據mFoldLine行數的高度
                    String strWhichHasExactlyFoldLine = getText().subSequence(0, index).toString();
                    Log.d(TAG, "strWhichHasExactlyFoldLine-->" + strWhichHasExactlyFoldLine);
                    layout = makeTextLayout(strWhichHasExactlyFoldLine);
                    // 把這個高度設定成最終的高度,這樣下方View就不會抖動了
                    setMeasuredDimension(getMeasuredWidth(), layout.getHeight() + getPaddingTop() + getPaddingBottom());
                }
            }
        }

    }

    @Override
    protected void onDraw(Canvas canvas) {
        Log.d(TAG, "onDraw() " + mCountOnDraw++ + ", getMeasuredHeight() " + getMeasuredHeight());

        if (!mHasDrawn) {
            resetText();
        }
        super.onDraw(canvas);
        mHasDrawn = true;
        mIsInner = false;
    }

    /**
     * 獲取摺疊文字
     *
     * @return 摺疊文字
     */
    public String getFoldText() {
        return mFoldText;
    }

    /**
     * 設定摺疊文字
     *
     * @param foldText 摺疊文字
     */
    public void setFoldText(String foldText) {
        mFoldText = foldText;
        invalidate();
    }

    /**
     * 獲取展開文字
     *
     * @return 展開文字
     */
    public String getUnFoldText() {
        return mUnFoldText;
    }

    /**
     * 設定展開文字
     *
     * @param unFoldText 展開文字
     */
    public void setUnFoldText(String unFoldText) {
        mUnFoldText = unFoldText;
        invalidate();
    }

    /**
     * 獲取摺疊行數
     *
     * @return 摺疊行數
     */
    public int getFoldLine() {
        return mFoldLine;
    }

    /**
     * 設定摺疊行數
     *
     * @param foldLine 摺疊行數
     */
    public void setFoldLine(int foldLine) {
        mFoldLine = foldLine;
        invalidate();
    }

    /**
     * 獲取尾部文字顏色
     *
     * @return 尾部文字顏色
     */
    public int getTailColor() {
        return mTailColor;
    }

    /**
     * 設定尾部文字顏色
     *
     * @param tailColor 尾部文字顏色
     */
    public void setTailColor(int tailColor) {
        mTailColor = tailColor;
        invalidate();
    }

    /**
     * 獲取是否可以再次摺疊
     *
     * @return 是否可以再次摺疊
     */
    public boolean isCanFoldAgain() {
        return mCanFoldAgain;
    }

    /**
     * 獲取全文字
     *
     * @return 全文字
     */
    public String getFullText() {
        return mFullText;
    }

    /**
     * 設定是否可以再次摺疊
     *
     * @param canFoldAgain 是否可以再次摺疊
     */
    public void setCanFoldAgain(boolean canFoldAgain) {
        mCanFoldAgain = canFoldAgain;
        invalidate();
    }

    /**
     * 獲取TextView的Layout,注意這裡使用getWidth()得到寬度
     *
     * @param text 源文字
     * @return Layout
     */
    private Layout makeTextLayout(String text) {
        return new StaticLayout(text, getPaint(), getWidth() - getPaddingLeft() - getPaddingRight(), Layout.Alignment
                .ALIGN_NORMAL, mLineSpacingMultiplier, mLineSpacingExtra, true);
    }

    /**
     * 重置文字
     */
    private void resetText() {
        // 文字本身就小於固定行數的話,不新增尾部的收起/展開文字
        Layout layout = makeTextLayout(mFullText);
        if (layout.getLineCount() <= getFoldLine()) {
            setText(mFullText);
            return;
        }

        SpannableString spanStr = new SpannableString(mFullText);

        if (mIsFold) {
            // 收縮狀態
            if (mCanFoldAgain) {
                spanStr = createUnFoldSpan(mFullText);
            }
        } else {
            // 展開狀態
            spanStr = createFoldSpan(mFullText);
        }

        updateText(spanStr);
        setMovementMethod(LinkMovementMethod.getInstance());
    }

    /**
     * 不更新全文字下,進行展開和收縮操作
     *
     * @param text 源文字
     */
    private void updateText(CharSequence text) {
        mIsInner = true;
        setText(text);
    }

    /**
     * 建立展開狀態下的Span
     *
     * @param text 源文字
     * @return 展開狀態下的Span
     */
    private SpannableString createUnFoldSpan(String text) {
        String destStr = text + mFoldText;
        int start = destStr.length() - mFoldText.length();
        int end = destStr.length();

        SpannableString spanStr = new SpannableString(destStr);
        spanStr.setSpan(clickSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        return spanStr;
    }

    /**
     * 建立收縮狀態下的Span
     *
     * @param text
     * @return 收縮狀態下的Span
     */
    private SpannableString createFoldSpan(String text) {
        long startTime = System.currentTimeMillis();
        String destStr = tailorText(text);
        Log.d(TAG, (System.currentTimeMillis() - startTime) + "ms");

        int start = destStr.length() - mUnFoldText.length();
        int end = destStr.length();

        SpannableString spanStr = new SpannableString(destStr);
        spanStr.setSpan(clickSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        return spanStr;
    }

    /**
     * 裁剪文字至固定行數(備用方法)
     *
     * @param text 源文字
     * @return 裁剪後的文字
     */
    private String tailorTextBackUp(String text) {
        Log.d(TAG, "使用備用方法: tailorTextBackUp() " + mCountBackUp++);

        String destStr = text + DEFAULT_ELLIPSIZE + mUnFoldText;
        Layout layout = makeTextLayout(destStr);

        // 如果行數大於固定行數
        if (layout.getLineCount() > getFoldLine()) {
            int index = layout.getLineEnd(getFoldLine() - 1);
            if (text.length() < index) {
                index = text.length();
            }
            // 從最後一位逐漸試錯至固定行數(可以考慮用二分法改進)
            if (index <= 1) {
                return DEFAULT_ELLIPSIZE + mUnFoldText;
            }
            String subText = text.substring(0, index - 1);
            return tailorText(subText);
        } else {
            return destStr;
        }
    }

    /**
     * 裁剪文字至固定行數(二分法)。經試驗,在文字長度不是很長時,效率比備用方法高不少;當文字長度過長時,備用方法則優勢明顯。
     *
     * @param text 源文字
     * @return 裁剪後的文字
     */
    private String tailorText(String text) {
        // return tailorTextBackUp(text);

        int start = 0;
        int end = text.length() - 1;
        int mid = (start + end) / 2;
        int find = finPos(text, mid);
        while (find != 0 && end > start) {
            Log.d(TAG, "使用二分法: tailorText() " + mCountBinary++);
            if (find > 0) {
                end = mid - 1;
            } else if (find < 0) {
                start = mid + 1;
            }
            mid = (start + end) / 2;
            find = finPos(text, mid);
        }
        Log.d(TAG, "mid is: " + mid);

        String ret;
        if (find == 0) {
            ret = text.substring(0, mid) + DEFAULT_ELLIPSIZE + mUnFoldText;
        } else {
            ret = tailorTextBackUp(text);
        }
        return ret;
    }

    /**
     * 查詢一個位置P,到P時為mFoldLine這麼多行,加上一個字元‘A’後則剛好為mFoldLine+1這麼多行
     *
     * @param text 源文字
     * @param pos  位置
     * @return 查詢結果
     */
    private int finPos(String text, int pos) {
        String destStr = text.substring(0, pos) + DEFAULT_ELLIPSIZE + mUnFoldText;
        Layout layout = makeTextLayout(destStr);
        Layout layoutMore = makeTextLayout(destStr + "A");

        int lineCount = layout.getLineCount();
        int lineCountMore = layoutMore.getLineCount();

        if (lineCount == getFoldLine() && (lineCountMore == getFoldLine() + 1)) {
            // 行數剛好到摺疊行數
            return 0;
        } else if (lineCount > getFoldLine()) {
            // 行數比摺疊行數多
            return 1;
        } else {
            // 行數比摺疊行數少
            return -1;
        }
    }

}


attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="FolderTextView">
        <attr name="foldText" format="string"/>
        <attr name="unFoldText" format="string"/>
        <attr name="foldLine" format="integer"/>
        <attr name="tailTextColor" format="color"/>
        <attr name="canFoldAgain" format="boolean"/>
    </declare-styleable>
</resources>
通過螢幕錄製可以看出,改進前的抖動是這樣的:

上一幀

下一幀


最後一幀

可看到,上方的TextView先是佔據了一個很高的高度,然後才會恢復,也就造成了下方View的抖動,修復後無此問題。