阅读 2884

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

前言

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

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

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

目标效果

Demo效果实现

下面是实现的效果图,@用户和链接会高亮显示,可以点击,包含展开和回收功能。以下做了不同情况下的显示效果:

tips.jpg

Demo下载体验

Demo下载

扫描二维码下载

扫描二维码下载

实现思路

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

思路一、曲线救国

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

思路二、对着TextView直接撸

所谓“对着TextView直接撸”就是自定义View继承TextView,在自定义View里面去处理所有的逻辑,好处是用起来方便点,而且也能尽量还原设计稿。在这里我们采用第二种方式,第一种方式提供一个思路,大家感兴趣的可以自己试试。

具体实现

考虑在先

在开始写代码之前,我们需要考虑几个点

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

解决问题

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

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

		//用来计算内容的大小
        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(" "));
        }
    }
复制代码

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

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

@[\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));
    }
复制代码

三、如何识别文字中的链接

在开始的时候,找了很多的匹配文字中链接的正则表达式,后来发现好多都有问题。联想到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()));
复制代码

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

对于@用户,链接和“展开”或者“收回”三者的实现,最终都是使用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,一个可爱的小男孩。

关注下面的标签,发现更多相似文章
评论