UI之可折叠的TextView
wxjiang
8年前
<h3>先上效果</h3> <p><img src="https://simg.open-open.com/show/773c1df4e917c8c1e0693c03a1d18711.gif"></p> <h3>一、思路</h3> <p>1. 计算text的行数</p> <p>实现可折叠的TextView最重要的一点是在setText()前计算出text所需的行数</p> <p>计算行数需要分为两种情况</p> <p>1.1 没有换行符的text</p> <pre> <code class="language-java">行数等于text的宽度除于TextView的宽度再加上text的宽度对TextView的宽度取余 lines = textWidth / TextViewWidth + textWidth % TextViewWidth</code></pre> <p>1.2 含有换行符的text</p> <pre> <code class="language-java">1. 先用换行符拆分 2. 对于拆分后的文本 如果不为空,则然后再按照没有换行符的方式计算 如果为空,则行数为1 3. 累加所有的拆分文本行数</code></pre> <p>2. 截取text</p> <p>计算出text的行数之后,需要对text进行截取,截取到text能在指定的行数内显示完的位置,</p> <ol> <li>首先用换行符对text进行拆分,将text分为若干段落</li> <li>对拆分后的文本段落循环计算行数累加,并累加字符数</li> <li>累加的行数小于指定行数,继续循环,直到累加的行数大于指定行数或循环完成;如果在循环完成之前累加的行数大于指定行数,则截取该次循环的段落</li> <li> <p>调用 <a href="/misc/goto?guid=4959742191475904170" rel="nofollow,noindex">TextUtils</a> 的ellipsize()方法对指定的段落进行截取,ellipsize()方法中的avail参数,传入剩余的可显示宽度</p> <p>因为在文本的最后要拼接上“...提示文本”,所以可显宽度的计算方式如下:</p> <pre> <code class="language-java">TextViewWidth * (指定行数 - 累加行数) - (... + 提示文本)Width</code></pre> <ol> <li>把截取后的文本设置给TextView</li> </ol> </li> </ol> <h3>二、实现</h3> <p>实现可折叠的TextView需要继承TextView并重写setText(CharSequence text, BufferType type)方法</p> <p>因为setText(CharSequence text)方法是final的,并且setText(CharSequence text)最终调用的也是setText(CharSequence text, BufferType type)方法,所以重写后者即可。</p> <p>核心代码</p> <pre> <code class="language-java">/** * 末尾省略号 */ 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); } }</code></pre> <p>因为用户设置给TextView的文本可能是含有样式的文本,即实现了Spannable接口的文本,所以在拆分并拼接文本的时候不能直接使用拆分后的字符串,会丢失原有样式,需要重新在原始文本中截取</p>