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
    
  5. 把截取后的文本设置给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接口的文本,所以在拆分并拼接文本的时候不能直接使用拆分后的字符串,会丢失原有样式,需要重新在原始文本中截取

可以从这里获取代码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容