Android 仿朋友圈全文、收起功能,支持话题、网址...
    
       安卓进阶涨薪训练营
      ,让一部分人先进大厂
      
      
大家好,我是皇叔,最近开了一个安卓进阶涨薪训练营,可以帮助大家突破技术&职场瓶颈,从而度过难关,进入心仪的公司。
      
详情见文章:没错!皇叔开了个训练营
      
      
作者:newki
https://juejin.cn/post/7154271756214075428
前言
之前的文章我们都讲到了WX盆友圈动态列表的效果,九宫格控件的实现 【传送门】 。并且讲到了发布动态中话题的处理 【传送门】 。那么在动态列表中我们如何显示我们发布的话题数据和一些圈子数据呢?
https://juejin.cn/post/7153192823880155143
https://juejin.cn/post/7153932700066250760
    
    
1.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 方法就是具体的实现展开收起入口方法,我们看看它是怎么实现的。
2.TextView的展开收起功能
关于TextView的展开收起,都离不开 StaticLayout 这个神器。我们主要需要用到它的两个方法 :
- 通过 StaticLayout 的 getLineCount() 方法知道文本是否会超出我们设置的maxLines,
- 通过 getLineEnd(int line) 方法可以找到最后一行的最后一个字符在文本中的位置。
      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);
}
    
  
    
其实只需要这两个方法就可以展示一个折叠起来的文本了。那么如何切换展开与收起的状态呢?
3.多种方式的实现展开
    第一种方法是直接修改
    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();
}
      
    
      
    
        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 的方法,没有整那么多动画。
4.内部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);
      
    
      
      /**
 * 填充文本之后尝试替换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 方法。
    

5.结语
涉及到的一些知识,文本Span的转换, StaticLayout 的使用,URLSpan的查找与替换等。
主要是和我们的需求相互对应,如果是要展开标签要在文本后面显示就简单一点,如果换行展示就简单一点,总的来说其实也不是很难,明确需求之后分解为一步一步的小需求,然后一步一步的实现小需求,串联起来就是我们最终的效果。 由于一些隐私问题就没有很方便的直接在我的Demo中完整贴出。如果大家对代码有需求的话,全部的代码其实都已经在文中贴出了,大家细心整合一下就是完整的代码了。 当然了,我这种方案可能也只是闭门造车,还需要大家提提意见,如果你有更好的方案,或者优化的空间都也可以一起交流一下。如有错漏的地方还请指出,如果有疑问也可以在评论区大家一起讨论哦。 如果感觉本文对你有一点点的启发,还望你能 点赞 支持一下,你的支持是我最大的动力。 Ok,这一期就此完结。
    
 
    
   
    
  为了防止失联,欢迎关注我防备的小号
    
        
           
        
      
    
  微信改了推送机制,真爱请星标本公号👇
