记录实现一个动态列表中展示话题、圈子、网址的可展开收起的特殊TextView

2,338 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第11天,点击查看活动详情

前言

之前的文章我们都讲到了WX盆友圈动态列表的效果,九宫格控件的实现【传送门】。并且讲到了发布动态中话题的处理【传送门】。那么在动态列表中我们如何显示我们发布的话题数据和一些圈子数据呢?

大致实现效果如下:(本地测试环境,无其他含义)

Screenrecording_20221014_150538 00_00_00-00_00_30.gif

TextView的特殊文本处理

我们在把服务器返回的文本设置给自定义折叠的TextView之前,我们先对文本进行Span的预处理。

 /**
     * 暴露方法-替换原文本中的话题数据,变色处理
     *
     * @param topics  服务器返回的话题数据
     * @param content 服务器返回的原始文本数据
     */
    public CharSequence replaceTopicSpan(List<RemoteTopicBean> topics, String content, OnTopicClickListener listener) {

        if (!CheckUtil.isEmpty(topics) && !CheckUtil.isEmpty(content)) {

            CharSequence topicCharSequece = content;

            int startPosition = 0;
            int endPosition = 0;
            for (RemoteTopicBean bean : topics) {
                startPosition = content.indexOf(bean.topic_name, startPosition);
                endPosition = startPosition + bean.topic_name.length();
                if (startPosition == -1)
                    break;

                topicCharSequece = SpanUtils.getInstance()
                        .toClickSpan(topicCharSequece, startPosition, endPosition, CommUtils.getColor(R.color.app_blue), false, charSequence -> {
                            //话题的点击(路由直接跳转搜索结果展示)
                            listener.onTopicClick(charSequence.toString());

                        });

                startPosition = endPosition;
            }

            return topicCharSequece;
        }

        return "";
    }

其实就是对多个话题进行遍历,找到start和end,然后使用Span的工具类,把普通的文本转为可点击和变色的Span。并回调出去外界使用。关键是要返回处理之后的文本 CharSequece 返回外部去设置。

具体富文本的转换方法如下:

    /**
     * 可点击-带下划线
     */
    public CharSequence toClickSpan(CharSequence charSequence, int start, int end, int color, boolean needUnderLine, OnSpanClickListener listener) {

        SpannableString spannableString = new SpannableString(charSequence);

        ClickableSpan clickableSpan = new ClickableSpan() {
            @Override
            public void onClick(@NonNull View widget) {
                if (listener != null) {
                    //防止重复点击
                    if (System.currentTimeMillis() - mLastClickTime >= TIME_INTERVAL) {
                        //to do
                        listener.onClick(charSequence.subSequence(start, end));

                        mLastClickTime = System.currentTimeMillis();
                    }

                }
            }

            @Override
            public void updateDrawState(@NonNull TextPaint ds) {
                ds.setColor(color);
                ds.setUnderlineText(needUnderLine);
            }
        };

        spannableString.setSpan(
                clickableSpan,
                start,
                end,
                Spannable.SPAN_INCLUSIVE_EXCLUSIVE);

        return spannableString;
    }

使用的时候:

        //展开文本设置
        ExpandTextView tvContent = helper.getView(R.id.tv_feed_news_content);
        String content = item.contentDesc;

        CharSequence topicCharSequece  =  tvContent.replaceTopicSpan(item.topics, content, new ExpandTextView.OnTopicClickListener() {
            @Override
            public void onTopicClick(String topic) {
                YYRouterService.newsFeedComponentService.startSearchResultActivity(mActivity, topic, true);
            }
        });

        tvContent.setVisibility(View.VISIBLE);
        tvContent.initWidth(mTvWidth);
        tvContent.setMaxLines(3);
        tvContent.setTypeface(TypefaceUtil.getSFLight(mContext));
        tvContent.setCloseText(topicCharSequece);

如果自己想显示的控件文本需要显示一些自定义字体,那么我们需要在设置文本之前就设置字体。

setCloseText 方法就是具体的实现展开收起入口方法,我们看看它是怎么实现的。

TextView的展开收起功能

关于TextView的展开收起,都离不开 StaticLayout 这个神器。

我们主要需要用到它的两个方法 :

  • 通过 StaticLayout 的 getLineCount() 方法知道文本是否会超出我们设置的maxLines,
  • 通过 getLineEnd(int line) 方法可以找到最后一行的最后一个字符在文本中的位置。

由于我们的需求是[展开]与[收起]的标签是紧接着文章后面而不是换行展示,所以我们需要循环遍历才能找到最佳的位置。

setCloseText 的方法如下:

    private String TEXT_EXPAND = "  [More]";
    private String TEXT_CLOSE = "  [Show Less]";

 /**
     * 暴露的方法-默认设置文本方法(如果需要折叠就会默认折叠)
     * 如果有特殊的Span如话题之类的,需要处理完毕之后再调用此方法。
     */
    public void setCloseText(CharSequence text) {

        if (SPAN_CLOSE == null) {
            initCloseEnd();
        }
        boolean appendShowAll = false; // 需要展开收起功能,先使用flag拦截,等测量完毕之后再setText显示真正的文本
        originText = text;

        int maxLines = getMaxLines();

        CharSequence workingText = originText;
        if (maxLines >= 0) {

            //创建出一个StaticLayout主要是为了计算行数
            Layout layout = createStaticLayout(workingText);
            //计算全部展开的文本高度
            mOpenHeight = layout.getHeight() + getPaddingTop() + getPaddingBottom();
            if (layout.getLineCount() > maxLines) {
                //获取一行显示字符个数,然后截取字符串数, 收起状态原始文本截取展示的部分
                workingText = originText.subSequence(0, layout.getLineEnd(maxLines - 1));
                //再对加上[收起]标签的文本进行测量
                String showText = originText.subSequence(0, layout.getLineEnd(maxLines - 1)) + "..." + SPAN_CLOSE;
                Layout layout2 = createStaticLayout(showText);

                // 对workingText进行-1截取,直到展示行数==最大行数,并且添加 SPAN_CLOSE 后刚好占满最后一行
                while (layout2.getLineCount() > maxLines) {
                    int lastSpace = workingText.length() - 1;
                    if (lastSpace == -1) {
                        break;
                    }
                    workingText = workingText.subSequence(0, lastSpace);
                    layout2 = createStaticLayout(workingText + "..." + SPAN_CLOSE);
                }

                //计算收起的文本高度
                mCLoseHeight = layout2.getHeight() + getPaddingTop() + getPaddingBottom();
                appendShowAll = true;

            }
        }

        setText(workingText);

        if (appendShowAll) {
            // 必须使用append,不能在上面使用+连接,否则会失效
            append("...");
            append(SPAN_CLOSE);
        }

        setMovementMethod(LinkMovementMethod.getInstance());

        replaceUrlSpan();
    }

    /**
     * 收起的文案(颜色处理)初始化
     */
    private void initCloseEnd() {
        //设置展开的文本
        SPAN_CLOSE = new SpannableString(TEXT_EXPAND);

        ButtonSpan span = new ButtonSpan(getContext(), new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ExpandTextView.super.setMaxLines(Integer.MAX_VALUE);
                setExpandText(originText);
                if (mCallback != null) mCallback.isExpand(1);
            }
        }, R.color.color_expand_span);

        SPAN_CLOSE.setSpan(span, 0, TEXT_EXPAND.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        SPAN_CLOSE.setSpan(new MyTypefaceSpan(TypefaceUtil.getSFRegular(getContext())), 0, TEXT_EXPAND.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
    }

其实只需要这两个方法就可以展示一个折叠起来的文本了。那么如何切换展开与收起的状态呢?

多种方式的实现展开

第一种方法是直接修改setMaxLine的方式,设置最大允许展示行的方式。

/**
     * 展开的文案(颜色处理)初始化
     */
    private void initExpandEnd() {
        //设置关闭的文本
        SPAN_EXPAND = new SpannableString(TEXT_CLOSE);
        ButtonSpan span = new ButtonSpan(getContext(), new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ExpandTextView.super.setMaxLines(mMaxLines);
                setCloseText(originText);
                if (mCallback != null) mCallback.isExpand(0);
            }
        }, R.color.color_expand_span);

        SPAN_EXPAND.setSpan(span, 0, TEXT_CLOSE.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        SPAN_EXPAND.setSpan(new MyTypefaceSpan(TypefaceUtil.getSFRegular(getContext())), 0, TEXT_CLOSE.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
    }

     /**
     * 设置展开的文本展示-后面加上[收起]的文本标签
     */
    private void setExpandText(CharSequence text) {
        if (SPAN_EXPAND == null) {
            initExpandEnd();
        }
        //创建出一个StaticLayout主要是为了计算行数
        Layout layout1 = createStaticLayout(text);
        Layout layout2 = createStaticLayout(text + TEXT_CLOSE);
        //判断- 当展示全部原始内容时 如果 TEXT_CLOSE 需要换行才能显示完整,则直接将TEXT_CLOSE展示在下一行
        if (layout2.getLineCount() > layout1.getLineCount()) {
            setText(originText + "\n");
        } else {
            setText(originText);
        }
        //加上[收起]的标签
        append(SPAN_EXPAND);

        setMovementMethod(LinkMovementMethod.getInstance());

        replaceUrlSpan();
    }

我们在[展开]和[收起]的标签中先设置他们为可点击的标签,然后再回调的Click方法中我们是设置切换 ExpandTextView.super.setMaxLines(mMaxLines); 的方式来实现的。

当然如果觉得这样的切换比较生硬,想用动画来实现也是可以的。

另一种方法是记录展开与收起的高度,然后做属性动画直接改变layoutParams的height,从而改变高度,实现对应展开收起的状态切换。

之前在 setCloseText 方法中,我们预测量文本布局的时候已经记录了展开与收起的高度记录。

    private int mOpenHeight;   //展开的文本高度
    private int mCLoseHeight;  //收起的文本高度

那么我就能用动画来封装一下实现

class ExpandCollapseAnimation extends Animation {
    private final View mTargetView;//动画执行view
    private final int mStartHeight;//动画执行的开始高度
    private final int mEndHeight;//动画结束后的高度

    ExpandCollapseAnimation(View target, int startHeight, int endHeight) {
        mTargetView = target;
        mStartHeight = startHeight;
        mEndHeight = endHeight;
        setDuration(400);
    }

    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        //计算出每次应该显示的高度,改变执行view的高度,实现动画
        mTargetView.getLayoutParams().height = (int) ((mEndHeight - mStartHeight) * interpolatedTime + mStartHeight);
        mTargetView.requestLayout();
    }
}

大致的实现如下:


private void executeOpenAnim() {

    if (mOpenAnim == null) {
        mOpenAnim = new ExpandCollapseAnimation(this, mCLoseHeight, mOpenHeight);
        mOpenAnim.setFillAfter(true);
        mOpenAnim.setAnimationListener(new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) {
                ExpandableTextView.super.setMaxLines(Integer.MAX_VALUE);
                setText(mOpenSpannableStr);
            }

            @Override
            public void onAnimationEnd(Animation animation) {
              
                getLayoutParams().height = mOpenHeight;
                requestLayout();
                animating = false;
            }

            @Override
            public void onAnimationRepeat(Animation animation) {

            }
        });
    }

    if (animating) {
        return;
    }
    animating = true;
    clearAnimation();

    startAnimation(mOpenAnim);
}


private void executeCloseAnim() {
  
    if (mCloseAnim == null) {
        mCloseAnim = new ExpandCollapseAnimation(this, mOpenHeight, mCLoseHeight);
        mCloseAnim.setFillAfter(true);
        mCloseAnim.setAnimationListener(new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) {

            }

            @Override
            public void onAnimationEnd(Animation animation) {
                animating = false;
                ExpandableTextView.super.setMaxLines(mMaxLines);
                setText(mCloseSpannableStr);
                getLayoutParams().height = mCLoseHeight;
                requestLayout();
            }

            @Override
            public void onAnimationRepeat(Animation animation) {

            }
        });
    }

    if (animating) {
        return;
    }
    animating = true;
    clearAnimation();
 
    startAnimation(mCloseAnim);
}

两种方法都是可以的,我这里的做法是第一种做法,直接设置maxLine的方法,没有整那么多动画。

内部Link链接的自定义处理

这里的Demo,做了两种演示,其实我么可以直接通过工具类转换到我们自定义的ClickSpan,也可以通过new 一个 ButtonSpan 来替换实现

例如使用ButtonSpan,我们可以设置点击,设置自定义字体等等。

       SPAN_CLOSE = new SpannableString(TEXT_EXPAND);

        ButtonSpan span = new ButtonSpan(getContext(), new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ExpandTextView.super.setMaxLines(Integer.MAX_VALUE);
                setExpandText(originText);
                if (mCallback != null) mCallback.isExpand(1);
            }
        }, R.color.color_expand_span);

        SPAN_CLOSE.setSpan(span, 0, TEXT_EXPAND.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        SPAN_CLOSE.setSpan(new MyTypefaceSpan(TypefaceUtil.getSFRegular(getContext())), 0, TEXT_EXPAND.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);

而内部的网址点击,由于默认是跳转到浏览器,我们想App自己处理,那么我们就需要找到文本中的 URLSpan 对象,然后对他进行替换,换成我们自己的 InterceptUrlSpan 对象,跳转到我们自己的WebView。

    /**
     * 填充文本之后尝试替换URLSpan
     */
    private void replaceUrlSpan() {
        CharSequence text = getText();
        if (text instanceof Spannable) {
            int end = text.length();
            Spannable sp = (Spannable) text;

            URLSpan[] urls = sp.getSpans(0, end, URLSpan.class);
            SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(text);

            if (urls.length > 0) {
                for (URLSpan urlSpan : urls) {
                    //拦截点击,替换Span
                    InterceptUrlSpan interceptUrlSpan = new InterceptUrlSpan(urlSpan.getURL());
                    spannableStringBuilder.setSpan(interceptUrlSpan, sp.getSpanStart(urlSpan), sp.getSpanEnd(urlSpan), Spannable.SPAN_EXCLUSIVE_INCLUSIVE);
                }

                //替换之后重新设置进去
                setText(spannableStringBuilder);
            }

        }
    }

一般是在我们设置玩文本显示之后再调用,如 setCloseText setExpandText 方法。

效果:

Screenrecording_20221014_153618 00_00_00-00_00_30.gif

结语

涉及到的一些知识,文本Span的转换,StaticLayout的使用,URLSpan的查找与替换等。

主要是和我们的需求相互对应,如果是要展开标签要在文本后面显示就简单一点,如果换行展示就简单一点,总的来说其实也不是很难,明确需求之后分解为一步一步的小需求,然后一步一步的实现小需求,串联起来就是我们最终的效果。

由于一些隐私问题就没有很方便的直接在我的Demo中完整贴出。如果大家对代码有需求的话,全部的代码其实都已经在文中贴出了,大家细心整合一下就是完整的代码了。

当然了,我这种方案可能也只是闭门造车,还需要大家提提意见,如果你有更好的方案,或者优化的空间都也可以一起交流一下。如有错漏的地方还请指出,如果有疑问也可以在评论区大家一起讨论哦。

如果感觉本文对你有一点点的启发,还望你能 点赞 支持一下,你的支持是我最大的动力。

Ok,这一期就此完结。