【需求解决系列之三】Android 自定义可展开收回的ExpandableTextView

前言

最近慢慢习惯了新环境,也渐渐的变得忙碌起来。以前暴雷的事情有同窗仍是比较关注,我想说的是,已经一而再再而三的展期了,老赖加上老赖平台,结果是至关明确的,不说了,说多了都是泪。java

前两天接到一个需求,须要完成如下效果。git

  • 一、内容超过指定行数须要折叠起来;
  • 二、内容中有连接的话,须要隐藏连接,将连接显示成“网页连接”,并实现点击跳转网页;
  • 三、内容中含有@+“内容”,须要携带“内容”跳转指定页面。
  • 四、有可能会在“展开”或者“收回”前面附加显示其余内容,好比demo里面的时间串

目标效果

Demo效果实现

下面是实现的效果图,@用户和连接会高亮显示,能够点击,包含展开和回收功能。如下作了不一样状况下的显示效果:github

tips.jpg

Demo下载体验

Demo下载正则表达式

扫描二维码下载 bash

扫描二维码下载

实现思路

主流思路有两个:一个是曲线救国,另外一个是对着TextView直接撸app

思路1、曲线救国

用两个TextView来分别显示,上面的主要负责显示内容,下面的负责展开和收回的功能。这种方式实现起来的好处是实现比较简单,缺点是很难作到如图所示在文字的最后添加展开和收回两个字,也就是很难还原设计稿;并且对于内容仍是须要额外处理@用户和连接的操做,不太方便。ide

思路2、对着TextView直接撸

所谓“对着TextView直接撸”就是自定义View继承TextView,在自定义View里面去处理全部的逻辑,好处是用起来方便点,并且也能尽可能还原设计稿。在这里咱们采用第二种方式,第一种方式提供一个思路,你们感兴趣的能够本身试试。字体

具体实现

考虑在先

在开始写代码以前,咱们须要考虑几个点ui

  • 1、怎么保证“展开”或者“收回”放在文字的最后面
  • 2、如何识别文字中的@用户
  • 3、如何识别文字中的连接
  • 4、处理@用户,连接和“展开”或者“收回”三者的高亮显示和点击事件

解决问题

1、怎么保证“展开”或者“收回”放在文字的最后面

其实这个问题算是整个实现中最难的一个吧!在此以前也是让我头疼的一个问题,不事后来我遇到了DynamicLayout,使用它咱们能够获取行的最后位置,行的开始位置,行的行宽以及指定内容的所占的行数。spa

//用来计算内容的大小
        DynamicLayout mDynamicLayout =
                new DynamicLayout(mFormatData.formatedContent, mPaint, mWidth, Layout.Alignment.ALIGN_NORMAL, 1.2f, 0.0f,
                        true);
        //获取行数
        int mLineCount = mDynamicLayout.getLineCount();
        int index = currentLines - 1;
        //获取指定行的最后位置
        int endPosition = mDynamicLayout.getLineEnd(index);
        //获取指定行的开始位置
        int startPosition = mDynamicLayout.getLineStart(index);
        //获取指定行的行宽
        float lineWidth = mDynamicLayout.getLineWidth(index);
复制代码

下面这个图会对上面的参数进行简单的说明:

参数说明
有了这些东西通过简单的计算咱们就能够获取到咱们须要截取的内容长度。对原内容进行截取再拼接上“展开”或“收回”便可!

/** * 计算原内容被裁剪的长度 * * @param endPosition * @param startPosition * @param lineWidth * @param endStringWith * @param offset * @return */
    private int getFitPosition(int endPosition, int startPosition, float lineWidth, float endStringWith, float offset, String aimContent) {
        //最后一行须要添加的文字的字数 
        int position = (int) ((lineWidth - (endStringWith + offset)) * (endPosition - startPosition)/ lineWidth);

        if (position < 0) return endPosition;
		//计算最后一行须要显示的正文的长度
        float measureText = mPaint.measureText(
                (aimContent.substring(startPosition, startPosition + position)));
		//若是最后一行须要显示的正文的长度比最后一行的长减去“展开”文字的长度要短就能够了 不然加个空格继续算
        if (measureText <= lineWidth - endStringWith) {
            return startPosition + position;
        } else {
            return getFitPosition(endPosition, startPosition, lineWidth, endStringWith, offset + mPaint.measureText(" "));
        }
    }
复制代码

2、如何识别文字中的@用户

使用正则表达式对原内容进行匹配,下面是正则表达式:

@[\w\p{InCJKUnifiedIdeographs}-]{1,26}
复制代码

将匹配到内容作一下记录,最后再使用SpannableStringBuilder对匹配到的内容设置可点击的span并设置其余颜色等具体样式。在如下代码中,咱们将匹配到的信息的内容和位置信息保存下来,后面会用到的。对于@用户这块,后面会提到怎么添加高亮显示和添加点击事件。

//对@用户 进行正则匹配
    Pattern pattern = Pattern.compile(regexp_mention, Pattern.CASE_INSENSITIVE);
    Matcher matcher = pattern.matcher(newResult.toString());
    List<FormatData.PositionData> datasMention = new ArrayList<>();
    while (matcher.find()) {
        //将匹配到的内容进行统计处理
        datasMention.add(new FormatData.PositionData(matcher.start(), matcher.end(), matcher.group(), LinkType.MENTION_TYPE));
    }
复制代码

3、如何识别文字中的连接

在开始的时候,找了不少的匹配文字中连接的正则表达式,后来发现好多都有问题。联想到TextView自己就有对连接跳转的支持,就想着TextView的内部必定有相关的正则来匹配,后来查看TextView的源码,发现还真有。

对于连接,后面会提到怎么添加高亮显示和添加点击事件。下面是匹配连接的代码:

List<FormatData.PositionData> datas = new ArrayList<>();
        //对连接进行正则匹配
        Pattern pattern = AUTOLINK_WEB_URL;
        Matcher matcher = pattern.matcher(content);
        StringBuffer newResult = new StringBuffer();
        int start = 0;
        int end = 0;
        int temp = 0;
        while (matcher.find()) {
            start = matcher.start();
            end = matcher.end();
            newResult.append(content.toString().substring(temp, start));
            //将匹配到的内容进行统计处理
            datas.add(new FormatData.PositionData(newResult.length() + 1, newResult.length() + 2 + TARGET.length(), matcher.group(), LinkType.LINK_TYPE));
            newResult.append(" " + TARGET + " ");
            temp = end;
        }
复制代码

除了对连接进行匹配之外,咱们还须要将识别到的连接用掩码隐藏起来。如何掩码呢?也就是把原文中的连接用“网页连接”替换掉。那么如何替换掉呢?上面的代码中咱们会获取到对应的连接以及连接所在的位置,那么咱们只须要使用“网页连接”替换掉匹配到的连接便可。

//newResult是最终会显示在页面上的内容容器
newResult.append(content.toString().substring(end, content.toString().length()));
复制代码

4、处理@用户,连接和“展开”或者“收回”三者的高亮显示和点击事件

对于@用户,连接和“展开”或者“收回”三者的实现,最终都是使用SpannableStringBuilder来处理。以前咱们在对原内容进行解析的时候,将匹配到的连接或者@用户进行了存储,而且存储了他们所在的位置(start,end)以及类型。

//定义类型的枚举类型
    public enum LinkType {
        //普通连接
        LINK_TYPE,
        //@用户
        MENTION_TYPE
    }
复制代码

有了这些数据的集合,咱们只须要遍历这些数据,并分别对这些数据进行setSpan处理,而且在setSpan的过程当中设置字体颜色,以及点击事件的回调便可。

//处理连接或者@用户
    private void dealLinksOrMention(FormatData formatData,SpannableStringBuilder ssb) {
        List<FormatData.PositionData> positionDatas = formatData.getPositionDatas();
        HH:
        for (FormatData.PositionData data : positionDatas) {
            if (data.getType().equals(LinkType.LINK_TYPE)) {
                int fitPosition = ssb.length() - getHideEndContent().length();
                if (data.getStart() < fitPosition) {
                    SelfImageSpan imageSpan = new SelfImageSpan(mLinkDrawable, ImageSpan.ALIGN_BASELINE);
                    //设置连接图标
                    ssb.setSpan(imageSpan, data.getStart(), data.getStart() + 1, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
                    //设置连接文字样式
                    int endPosition = data.getEnd();
                    if (fitPosition > data.getStart() + 1 && fitPosition < data.getEnd()) {
                        endPosition = fitPosition;
                    }
                    if (data.getStart() + 1 < fitPosition) {
                        ssb.setSpan(new ClickableSpan() {
                            @Override
                            public void onClick(View widget) {
                                if (linkClickListener != null)
                                    linkClickListener.onLinkClickListener(LinkType.LINK_TYPE, data.getUrl());
                            }

                            @Override
                            public void updateDrawState(TextPaint ds) {
                                ds.setColor(mLinkTextColor);
                                ds.setUnderlineText(false);
                            }
                        }, data.getStart() + 1, endPosition, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
                    }
                }
            } else {
                int fitPosition = ssb.length() - getHideEndContent().length();
                if (data.getStart() < fitPosition) {
                    int endPosition = data.getEnd();
                    if (fitPosition < data.getEnd()) {
                        endPosition = fitPosition;
                    }
                    ssb.setSpan(new ClickableSpan() {
                        @Override
                        public void onClick(View widget) {
                            if (linkClickListener != null)
                                linkClickListener.onLinkClickListener(LinkType.MENTION_TYPE, data.getUrl());
                        }

                        @Override
                        public void updateDrawState(TextPaint ds) {
                            ds.setColor(mLinkTextColor);
                            ds.setUnderlineText(false);
                        }
                    }, data.getStart(), endPosition, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
                }
            }
        }
    }
    
	/** * 设置 "展开" * @param ssb * @param formatData */
    private void setExpandSpan(SpannableStringBuilder ssb,FormatData formatData){
        int index = currentLines - 1;
        int endPosition = mDynamicLayout.getLineEnd(index);
        int startPosition = mDynamicLayout.getLineStart(index);
        float lineWidth = mDynamicLayout.getLineWidth(index);

        String endString = getHideEndContent();

        //计算原内容被截取的位置下标
        int fitPosition =
                getFitPosition(endPosition, startPosition, lineWidth, mPaint.measureText(endString), 0);

        ssb.append(formatData.formatedContent.substring(0, fitPosition));

        //在被截断的文字后面添加 展开 文字
        ssb.append(endString);

        int expendLength = TextUtils.isEmpty(mEndExpandContent) ? 0 : 2 + mEndExpandContent.length();
        ssb.setSpan(new ClickableSpan() {
            @Override
            public void onClick(View widget) {
                action();
            }

            @Override
            public void updateDrawState(TextPaint ds) {
                super.updateDrawState(ds);
                ds.setColor(mExpandTextColor);
                ds.setUnderlineText(false);
            }
        }, ssb.length() - TEXT_EXPEND.length() - expendLength, ssb.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
    }
复制代码

在处理这一块的时候有个细节须要注意,那就是假如在文字切割后的末尾正好有个一个连接,而这个地方又要显示“展开”或者“收回”,这个地方要特别注意连接setSpan的范围,一不注意就可能连同把后面的“展开”或者“收回”也一块儿设置了,致使事件不对。处理“收回”是差很少的,就不贴代码了。最后还有一个附加功能就是在最后添加时间串的功能,其实也就是在“展开”和“收回”前面加一个串,作好这方面的判断就行了,代码里面已经作了处理。具体能够去Github上面去看。

项目地址和结语

Github地址: ExpandableTextView

若是链接失效就直接点击这个连接吧!github.com/MZCretin/Ex…

您的star就是对我最大的鼓励!

关于个人

我就是比较喜欢用代码解决生活中的问题,感受很开心,哈哈哈。也但愿你们关注个人简书,掘金,Github和CSDN。

简书首页,连接是 www.jianshu.com/u/123f97613…

掘金首页,连接是 juejin.im/user/5838d5…

Github首页,连接是 github.com/MZCretin

CSDN首页,连接是 blog.csdn.net/u010998327

我是Cretin,一个可爱的小男孩。

相关文章
相关标签/搜索